From 4cd8eceab85f36ae9a44d9cf4a485a79fa7dcec0 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Sat, 29 Aug 2026 17:27:19 +0200 Subject: [PATCH 01/19] creates the enums --- pylabrobot/agilent/biotek/lhc/__init__.py | 1 + .../agilent/biotek/lhc/enums/__init__.py | 15 ++++ .../biotek/lhc/enums/instrument/__init__.py | 19 +++++ .../biotek/lhc/enums/instrument/basecode.py | 15 ++++ .../lhc/enums/instrument/instrument_family.py | 16 ++++ .../lhc/enums/instrument/jig_location.py | 10 +++ .../lhc/enums/instrument/keypad_security.py | 13 ++++ .../enums/instrument/level_sensor_state.py | 13 ++++ .../lhc/enums/instrument/offset_manifold.py | 19 +++++ .../lhc/enums/instrument/product_type.py | 20 +++++ .../biotek/lhc/enums/instrument/sensor.py | 14 ++++ .../enums/instrument/strip_washer_manifold.py | 18 +++++ .../biotek/lhc/enums/instrument/subsystem.py | 22 ++++++ .../lhc/enums/instrument/syringe_box_size.py | 14 ++++ .../lhc/enums/instrument/syringe_box_type.py | 14 ++++ .../lhc/enums/instrument/syringe_manifold.py | 22 ++++++ .../biotek/lhc/enums/instrument/valve_box.py | 18 +++++ .../lhc/enums/instrument/washer_manifold.py | 18 +++++ .../biotek/lhc/enums/motion/__init__.py | 12 +++ .../lhc/enums/motion/basecode_motor_405ts.py | 12 +++ .../lhc/enums/motion/basecode_motor_406.py | 15 ++++ .../enums/motion/basecode_motor_multiflo.py | 18 +++++ .../biotek/lhc/enums/motion/carrier_speed.py | 13 ++++ .../biotek/lhc/enums/motion/carrier_type.py | 16 ++++ .../agilent/biotek/lhc/enums/motion/motor.py | 26 +++++++ .../lhc/enums/motion/motor_home_type.py | 18 +++++ .../biotek/lhc/enums/motion/motor_sensor.py | 12 +++ .../biotek/lhc/enums/plates/__init__.py | 3 + .../biotek/lhc/enums/plates/plate_type.py | 33 ++++++++ .../biotek/lhc/enums/status/__init__.py | 4 + .../biotek/lhc/enums/status/activity.py | 19 +++++ .../biotek/lhc/enums/status/run_state.py | 17 ++++ .../biotek/lhc/enums/steps/__init__.py | 52 +++++++++++++ .../agilent/biotek/lhc/enums/steps/buffer.py | 12 +++ .../biotek/lhc/enums/steps/cassette_head.py | 23 ++++++ .../biotek/lhc/enums/steps/cassette_mode.py | 13 ++++ .../biotek/lhc/enums/steps/cassette_type.py | 34 ++++++++ .../biotek/lhc/enums/steps/fill_pattern.py | 12 +++ .../biotek/lhc/enums/steps/peri_flow_rate.py | 9 +++ .../biotek/lhc/enums/steps/peri_pump.py | 13 ++++ .../enums/steps/secondary_aspirate_pattern.py | 18 +++++ .../biotek/lhc/enums/steps/shake_axis.py | 9 +++ .../biotek/lhc/enums/steps/shake_intensity.py | 26 +++++++ .../biotek/lhc/enums/steps/step_action.py | 26 +++++++ .../biotek/lhc/enums/steps/step_type.py | 33 ++++++++ .../agilent/biotek/lhc/enums/steps/syringe.py | 13 ++++ .../biotek/lhc/enums/steps/syringe_bottle.py | 23 ++++++ .../biotek/lhc/enums/steps/travel_rate.py | 77 +++++++++++++++++++ .../biotek/lhc/enums/steps/wash_format.py | 13 ++++ 49 files changed, 905 insertions(+) create mode 100644 pylabrobot/agilent/biotek/lhc/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/basecode.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/instrument_family.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/jig_location.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/keypad_security.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/level_sensor_state.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/offset_manifold.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/product_type.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/sensor.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/strip_washer_manifold.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/subsystem.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_size.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_type.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_manifold.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/valve_box.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/washer_manifold.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_405ts.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_406.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_multiflo.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/carrier_speed.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/carrier_type.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/motor.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/motor_home_type.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/motor_sensor.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/plates/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/plates/plate_type.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/status/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/status/activity.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/status/run_state.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/buffer.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/cassette_head.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/cassette_mode.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/cassette_type.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/fill_pattern.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/peri_flow_rate.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/peri_pump.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/secondary_aspirate_pattern.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/shake_axis.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/shake_intensity.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/step_action.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/step_type.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/syringe.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/syringe_bottle.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/travel_rate.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/wash_format.py diff --git a/pylabrobot/agilent/biotek/lhc/__init__.py b/pylabrobot/agilent/biotek/lhc/__init__.py new file mode 100644 index 00000000000..a2f581dddc9 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/__init__.py @@ -0,0 +1 @@ +"""Driver generation for the BioTek washer/dispenser family.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/__init__.py new file mode 100644 index 00000000000..1493f366ae2 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/__init__.py @@ -0,0 +1,15 @@ +"""Device vocabulary for the BioTek washer/dispenser family. + +Grouped by what it describes: :mod:`instrument` for the instrument and its fitted options, +:mod:`motion` for motors and the carrier, :mod:`steps` for what a protocol step is written in, +:mod:`plates` for labware formats, and :mod:`status` for what a run reports. + +The words a caller types are string ``Literal`` aliases with a dict to their wire value; integer +enums are reserved for wire codes a caller never writes. +""" + +from pylabrobot.agilent.biotek.lhc.enums.instrument import * +from pylabrobot.agilent.biotek.lhc.enums.motion import * +from pylabrobot.agilent.biotek.lhc.enums.plates import * +from pylabrobot.agilent.biotek.lhc.enums.status import * +from pylabrobot.agilent.biotek.lhc.enums.steps import * diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/__init__.py new file mode 100644 index 00000000000..bd6686319e1 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/__init__.py @@ -0,0 +1,19 @@ +"""What an instrument is and what is bolted onto it.""" + +from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.instrument.jig_location import JigLocation +from pylabrobot.agilent.biotek.lhc.enums.instrument.keypad_security import KeypadSecurity +from pylabrobot.agilent.biotek.lhc.enums.instrument.level_sensor_state import LevelSensorDataState +from pylabrobot.agilent.biotek.lhc.enums.instrument.offset_manifold import OffsetManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.product_type import ProductType +from pylabrobot.agilent.biotek.lhc.enums.instrument.sensor import Sensor +from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import ( + StripWasherManifold, +) +from pylabrobot.agilent.biotek.lhc.enums.instrument.subsystem import Subsystem +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_size import SyringeBoxSize +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.valve_box import ValveBox +from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/basecode.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/basecode.py new file mode 100644 index 00000000000..dbb5bedab90 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/basecode.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import enum + + +class Basecode(enum.IntEnum): + """The firmware variant an instrument is running. + + The variant is a property of the installed firmware image, not a fitted option, and decides + whether the random-access and peristaltic-wash step types are available. + """ + + BASIC = 0 + RANDOM_ACCESS = 1 + PERI_WASH = 2 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/instrument_family.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/instrument_family.py new file mode 100644 index 00000000000..0e7917f437a --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/instrument_family.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import enum + + +class InstrumentFamily(enum.IntEnum): + """The hardware family a model belongs to. + + Several models share one family. The family determines which motor numbering applies, which + option queries the instrument answers, and which step types can be offered. + """ + + EL406 = 0 + MULTIFLO = 1 + MODEL_405_TS = 2 + MULTIFLO_FX = 3 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/jig_location.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/jig_location.py new file mode 100644 index 00000000000..4cd0e51f86b --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/jig_location.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import enum + + +class JigLocation(enum.IntEnum): + """Which of the two carrier positions a calibration jig is placed in.""" + + LEFT = 0 + RIGHT = 1 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/keypad_security.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/keypad_security.py new file mode 100644 index 00000000000..aa81ca15b15 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/keypad_security.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import enum + + +class KeypadSecurity(enum.IntEnum): + """Whether the instrument's front-panel keypad accepts input. + + Locking the keypad prevents a bystander from interfering with a run in progress. + """ + + NONE = 0 + LOCKED = 1 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/level_sensor_state.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/level_sensor_state.py new file mode 100644 index 00000000000..fad5d4507f3 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/level_sensor_state.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import enum + + +class LevelSensorDataState(enum.IntEnum): + """Whether a stored level-sensor calibration record holds measured data. + + A record reads back as :attr:`DEFAULT` until a calibration has written measurements into it. + """ + + VALID = 51 + DEFAULT = 119 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/offset_manifold.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/offset_manifold.py new file mode 100644 index 00000000000..aa84f4058e7 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/offset_manifold.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import enum + + +class OffsetManifold(enum.IntEnum): + """A manifold whose stored X/Y/Z position offsets can be read or written. + + Each fitted manifold carries its own calibrated offsets, because the tubes sit at a different + place relative to the carrier for every manifold geometry. + """ + + PERI_PUMP = 0 + SYRINGE_8_TUBE = 1 + SYRINGE_16_7_TUBE = 2 + SYRINGE_16_TUBE = 3 + SYRINGE_32_TUBE_LARGE_BORE = 4 + SYRINGE_32_TUBE_SMALL_BORE = 5 + WASHER_128_TUBE = 6 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/product_type.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/product_type.py new file mode 100644 index 00000000000..066343c7179 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/product_type.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import enum + + +class ProductType(enum.IntEnum): + """A model in the BioTek washer/dispenser family. + + Identifies which instrument a connection is expected to talk to. The model is declared rather + than discovered: nothing in the wire protocol reports it. + """ + + UNDEFINED = 0 + EL406 = 1 + ELX405 = 2 + MICROFLO = 3 + MULTIFLO = 4 + MODEL_405_TS = 5 + MULTIFLO_FX = 6 + MODEL_50_TS = 7 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/sensor.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/sensor.py new file mode 100644 index 00000000000..4b68311b39a --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/sensor.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import enum + + +class Sensor(enum.IntEnum): + """A sensor whose presence and enabled state can be queried.""" + + VACUUM = 0 + WASTE = 1 + FLUID = 2 + FLOW = 3 + FILTER_VACUUM = 4 + PLATE = 5 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/strip_washer_manifold.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/strip_washer_manifold.py new file mode 100644 index 00000000000..f4014089a68 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/strip_washer_manifold.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import enum + + +class StripWasherManifold(enum.IntEnum): + """The strip-washer manifold fitted to the instrument. + + Named by the plate format it services. Strip washing is a separate wash head from the main wash + manifold and is offered only by the models that can carry one. + """ + + PLATE_6_WELL = 0 + PLATE_12_WELL = 1 + PLATE_24_WELL = 2 + PLATE_48_WELL = 3 + PLATE_96_WELL = 4 + NOT_INSTALLED = 255 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/subsystem.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/subsystem.py new file mode 100644 index 00000000000..113e8ba5843 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/subsystem.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import enum + + +class Subsystem(enum.IntEnum): + """A liquid-moving subsystem, addressed by calibration and verification routines. + + The verify-jig members are not pumps but the measurement fixtures used to calibrate the + dispense, aspirate and single-well positions. + """ + + PERI_PUMP_PRIMARY = 0 + SYRINGE_A = 1 + SYRINGE_B = 2 + WASHER = 3 + PERI_PUMP_SECONDARY = 4 + DISPENSER_VERIFY_JIG = 5 + STRIP_WASHER_ASPIRATE = 6 + STRIP_WASHER_DISPENSE = 7 + ASPIRATE_VERIFY_JIG = 8 + SINGLE_WELL_VERIFY_JIG = 9 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_size.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_size.py new file mode 100644 index 00000000000..993d426e8be --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_size.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import enum + + +class SyringeBoxSize(enum.IntEnum): + """How many syringes the fitted syringe box holds. + + A single box drives syringe A only; a double box drives A and B. + """ + + UNKNOWN = 0 + SINGLE = 1 + DOUBLE = 2 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_type.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_type.py new file mode 100644 index 00000000000..6ff1519c546 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_type.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import enum + + +class SyringeBoxType(enum.IntEnum): + """The syringe box fitted to the instrument. + + Whether the box is autoclavable determines the cleaning procedures it supports. + """ + + NOT_INSTALLED = 0 + AUTOCLAVABLE = 1 + NON_AUTOCLAVABLE = 2 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_manifold.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_manifold.py new file mode 100644 index 00000000000..1dfe0b1eaa5 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_manifold.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import enum + + +class SyringeManifold(enum.IntEnum): + """The syringe dispense manifold fitted to the instrument. + + Tube manifolds are named by tube count and bore size; plate manifolds are named by the plate + format they dispense into. + """ + + NOT_INSTALLED = 0 + TUBE_16 = 1 + TUBE_32_LARGE_BORE = 2 + TUBE_32_SMALL_BORE = 3 + TUBE_16_7 = 4 + TUBE_8 = 5 + PLATE_6_WELL = 6 + PLATE_12_WELL = 7 + PLATE_24_WELL = 8 + PLATE_48_WELL = 9 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/valve_box.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/valve_box.py new file mode 100644 index 00000000000..bb7d510c96e --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/valve_box.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import enum + + +class ValveBox(enum.IntEnum): + """The buffer selection valve fitted to the instrument. + + Any value other than :attr:`NOT_INSTALLED` means a step may select a buffer other than A. + """ + + NOT_INSTALLED = 0 + WASHER = 1 + SYRINGE = 2 + INTERNAL_1 = 3 + INTERNAL_2 = 4 + INTERNAL_3 = 5 + INTERNAL_4 = 6 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/washer_manifold.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/washer_manifold.py new file mode 100644 index 00000000000..1c90c3f6099 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/washer_manifold.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import enum + + +class WasherManifold(enum.IntEnum): + """The wash manifold fitted to the instrument. + + The tube count and whether the manifold is dual-action (separate dispense and aspirate tubes per + well) decide which plate formats and wash step types are usable. + """ + + TUBE_96_DUAL = 0 + TUBE_192 = 1 + TUBE_128 = 2 + TUBE_96_SINGLE = 3 + DEEP_PIN_96 = 4 + NOT_INSTALLED = 255 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/motion/__init__.py new file mode 100644 index 00000000000..10383d0b2d1 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/__init__.py @@ -0,0 +1,12 @@ +"""Motors, homing and the plate carrier.""" + +from pylabrobot.agilent.biotek.lhc.enums.motion.basecode_motor_405ts import Basecode405TSMotor +from pylabrobot.agilent.biotek.lhc.enums.motion.basecode_motor_406 import Basecode406Motor +from pylabrobot.agilent.biotek.lhc.enums.motion.basecode_motor_multiflo import ( + BasecodeMultiFloMotor, +) +from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_speed import CarrierSpeed +from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType +from pylabrobot.agilent.biotek.lhc.enums.motion.motor import Motor +from pylabrobot.agilent.biotek.lhc.enums.motion.motor_home_type import MotorHomeType +from pylabrobot.agilent.biotek.lhc.enums.motion.motor_sensor import MotorSensor diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_405ts.py b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_405ts.py new file mode 100644 index 00000000000..548acd57dd9 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_405ts.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import enum + + +class Basecode405TSMotor(enum.IntEnum): + """Motor numbering used by 405 TS firmware fault codes.""" + + CARRIER_X = 0 + CARRIER_Y = 1 + WASH_HEAD_Z = 2 + LEVEL_SENSE_Y = 3 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_406.py b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_406.py new file mode 100644 index 00000000000..f1ec08b5222 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_406.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import enum + + +class Basecode406Motor(enum.IntEnum): + """Motor numbering used by EL406 firmware fault codes.""" + + CARRIER_X = 0 + CARRIER_Y = 1 + DISPENSE_HEAD_Z = 2 + WASH_HEAD_Z = 3 + SYRINGE_A = 4 + SYRINGE_B = 5 + PERI_PUMP_PRIMARY = 6 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_multiflo.py b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_multiflo.py new file mode 100644 index 00000000000..728cdc3eb7a --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_multiflo.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import enum + + +class BasecodeMultiFloMotor(enum.IntEnum): + """Motor numbering used by MultiFlo firmware fault codes.""" + + CARRIER_X = 0 + CARRIER_Y = 1 + DISPENSE_HEAD_Z = 2 + PERI_PUMP_SECONDARY = 3 + SYRINGE_A = 4 + SYRINGE_B = 5 + PERI_PUMP_PRIMARY = 6 + STRIP_WASHER_SYRINGE = 7 + ASPIRATE_HEAD_Z = 8 + PERI_RANDOM_ACCESS_Y = 9 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_speed.py b/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_speed.py new file mode 100644 index 00000000000..d8fb7679489 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_speed.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import enum + + +class CarrierSpeed(enum.IntEnum): + """How fast the carrier travels in and out. + + The slow setting reduces splashing when carrying full wells. + """ + + DEFAULT = 0 + SLOW = 1 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_type.py b/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_type.py new file mode 100644 index 00000000000..eb100d5dd2d --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_type.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import enum + + +class CarrierType(enum.IntEnum): + """The plate carrier fitted to the instrument. + + The carrier determines which labware the instrument can hold, and a step is rejected when the + plate it names is incompatible with the fitted carrier. + """ + + STANDARD = 0 + MINI_TUBE = 1 + VACUUM_FILTRATION = 2 + MAG_BEAD = 3 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/motor.py b/pylabrobot/agilent/biotek/lhc/enums/motion/motor.py new file mode 100644 index 00000000000..2b26932937d --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/motor.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import enum + + +class Motor(enum.IntEnum): + """A motor addressable by the homing and verification commands. + + This is the union over the family; an instrument carries only the motors its fitted hardware + needs. Firmware-level fault codes number motors per family instead — see + :class:`~pylabrobot.agilent.biotek.lhc.enums.motion.basecode_motor_406.Basecode406Motor` and its + siblings. + """ + + CARRIER_X = 0 + CARRIER_Y = 1 + DISPENSE_HEAD_Z = 2 + WASH_HEAD_Z = 3 + SYRINGE_A = 4 + SYRINGE_B = 5 + PERI_PUMP_PRIMARY = 6 + PERI_PUMP_SECONDARY = 7 + LEVEL_SENSE_Y = 8 + WASH_SYRINGE = 9 + WASH_ASPIRATE_HEAD_Z = 10 + SINGLE_WELL_Y = 11 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/motor_home_type.py b/pylabrobot/agilent/biotek/lhc/enums/motion/motor_home_type.py new file mode 100644 index 00000000000..9fc6786ad45 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/motor_home_type.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import enum + + +class MotorHomeType(enum.IntEnum): + """What a homing command should do. + + Initializing runs a full power-on sequence; homing drives to the home sensor; verifying only + confirms the current position against the home sensor without a full re-reference. + """ + + INIT_ALL_MOTORS = 1 + INIT_PERI_PUMP = 2 + HOME_MOTOR = 3 + HOME_XYZ_MOTORS = 4 + VERIFY_MOTOR = 5 + VERIFY_XYZ_MOTORS = 6 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/motor_sensor.py b/pylabrobot/agilent/biotek/lhc/enums/motion/motor_sensor.py new file mode 100644 index 00000000000..53784da33d0 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/motor_sensor.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import enum + + +class MotorSensor(enum.IntEnum): + """Which of a motor's position sensors a fault refers to.""" + + NONE = 0 + HOME = 1 + AUX_1 = 2 + AUX_2 = 3 diff --git a/pylabrobot/agilent/biotek/lhc/enums/plates/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/plates/__init__.py new file mode 100644 index 00000000000..ee13b324a75 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/plates/__init__.py @@ -0,0 +1,3 @@ +"""Labware formats the instrument can be set to.""" + +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType diff --git a/pylabrobot/agilent/biotek/lhc/enums/plates/plate_type.py b/pylabrobot/agilent/biotek/lhc/enums/plates/plate_type.py new file mode 100644 index 00000000000..f8107c623de --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/plates/plate_type.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import enum + + +class PlateType(enum.IntEnum): + """The labware format the instrument is set to. + + This is how the instrument is told what sits on the carrier; it selects the well pitch, the + travel limits and the default head heights the firmware uses. Public APIs take a + :class:`~pylabrobot.resources.Plate` and resolve it to a member of this enum. + + Values 15 through 18 are unassigned. The two test plates are calibration labware, not + general-purpose plates. + """ + + PLATE_1536_WELL = 0 + PLATE_384_WELL = 1 + PLATE_384_WELL_PCR = 2 + PLATE_384_DEEP_WELL = 3 + PLATE_96_WELL = 4 + PLATE_96_DEEP_WELL = 5 + PLATE_96_HALF_WELL = 6 + PLATE_96_MINI_TUBES = 7 + PLATE_48_WELL = 8 + PLATE_24_WELL = 9 + TUBES_20_12X75 = 10 + TUBES_20_13X100 = 11 + PLATE_12_WELL = 12 + PLATE_6_WELL = 13 + PLATE_1536_FLANGE = 14 + TEST_PLATE_96_WELL_MB = 19 + TEST_PLATE_96_WELL_BD = 20 diff --git a/pylabrobot/agilent/biotek/lhc/enums/status/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/status/__init__.py new file mode 100644 index 00000000000..0dc9e854afd --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/status/__init__.py @@ -0,0 +1,4 @@ +"""What the instrument reports while a step runs.""" + +from pylabrobot.agilent.biotek.lhc.enums.status.activity import Activity +from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState diff --git a/pylabrobot/agilent/biotek/lhc/enums/status/activity.py b/pylabrobot/agilent/biotek/lhc/enums/status/activity.py new file mode 100644 index 00000000000..4d5a7b3fbb5 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/status/activity.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import enum + + +class Activity(enum.IntEnum): + """The timed phase a running step is in, as reported by a status poll. + + Reported alongside the time remaining in that phase. :attr:`NONE` means the step is running but + is not in a phase with a countdown. + """ + + NONE = 0 + SOAKING = 1 + SHAKING = 2 + SUBMERGING_TIPS = 3 + AUTO_CLEANING = 4 + RESERVED_5 = 5 + RESERVED_6 = 6 diff --git a/pylabrobot/agilent/biotek/lhc/enums/status/run_state.py b/pylabrobot/agilent/biotek/lhc/enums/status/run_state.py new file mode 100644 index 00000000000..71dcc8e05b6 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/status/run_state.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import enum + + +class RunState(enum.IntEnum): + """What the instrument is doing, as reported by a status poll. + + A step command returns as soon as the instrument accepts it, so polling for a state other than + :attr:`BUSY` is the only way to learn that the step has finished. + """ + + READY = 1 + BUSY = 2 + PAUSED = 3 + ERROR = 4 + STOPPED = 5 diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/steps/__init__.py new file mode 100644 index 00000000000..38b0366c08b --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/__init__.py @@ -0,0 +1,52 @@ +"""The vocabulary a protocol step is written in. + +The word vocabularies a caller types are string ``Literal`` aliases, each with a dict mapping a +value to the byte a step command encodes it as. Step type and step action stay integer enums: they +are dispatch keys, not text a caller writes. +""" + +from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import BUFFERS, Buffer +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_head import ( + CASSETTE_HEAD_TO_BYTE, + CassetteHead, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_mode import ( + CASSETTE_MODE_TO_BYTE, + CassetteMode, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import ( + CASSETTE_TYPE_TO_BYTE, + CassetteType, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.fill_pattern import FILL_PATTERN_TO_BYTE, FillPattern +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import ( + PERI_FLOW_RATE_TO_BYTE, + PeriFlowRate, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE, PeriPump +from pylabrobot.agilent.biotek.lhc.enums.steps.secondary_aspirate_pattern import ( + SECONDARY_ASPIRATE_PATTERN_TO_BYTE, + SecondaryAspiratePattern, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import SHAKE_AXIS_TO_BYTE, ShakeAxis +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ( + SHAKE_INTENSITY_TO_BYTE, + SHAKE_INTENSITY_TO_FREQUENCY, + ShakeIntensity, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe import SYRINGE_TO_BYTE, Syringe +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import ( + SYRINGE_BOTTLE_TO_BYTE, + SyringeBottle, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import ( + STRIP_TRAVEL_RATES, + TRAVEL_RATE_TO_BYTE, + TRAVEL_RATE_TO_SPEED, + WASHER_TRAVEL_RATES, + TravelRate, + is_cell_washing, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WASH_FORMAT_TO_BYTE, WashFormat diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/buffer.py b/pylabrobot/agilent/biotek/lhc/enums/steps/buffer.py new file mode 100644 index 00000000000..17adb305924 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/buffer.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Literal + +Buffer = Literal["A", "B", "C", "D"] +"""Which buffer inlet a dispensing step draws from. + +Only instruments with a valve box fitted can select anything other than ``"A"``. +""" + +BUFFERS: tuple[Buffer, ...] = ("A", "B", "C", "D") +"""Every buffer inlet, for validation and iteration.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_head.py b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_head.py new file mode 100644 index 00000000000..fe98c6a4529 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_head.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Literal + +CassetteHead = Literal[ + "8 tubes to 8 wells", + "8 tubes to 1 well", + "8 tubes to 1 chute", + "1 tube to 1 well", +] +"""How a peristaltic cassette's tubes map onto wells. + +A chute head routes all tubes into a single waste chute and is used for purging rather than +dispensing. Only random-access dispense steps select a head; any other step carries None. +""" + +CASSETTE_HEAD_TO_BYTE: dict[CassetteHead, int] = { + "8 tubes to 8 wells": 0, + "8 tubes to 1 well": 1, + "8 tubes to 1 chute": 2, + "1 tube to 1 well": 3, +} +"""The value each head is encoded as in a step command. No head encodes 255.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_mode.py b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_mode.py new file mode 100644 index 00000000000..725931d4394 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_mode.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Literal + +CassetteMode = Literal["Error", "Prompt", "Auto set"] +"""What to do when the fitted cassette is not the one a step requires. + +``"Error"`` aborts the run, ``"Prompt"`` asks for the correct cassette to be fitted, and +``"Auto set"`` accepts the fitted cassette and adjusts the step to it. +""" + +CASSETTE_MODE_TO_BYTE: dict[CassetteMode, int] = {"Error": 0, "Prompt": 1, "Auto set": 2} +"""The value each mode is encoded as. An unset mode encodes 255.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_type.py b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_type.py new file mode 100644 index 00000000000..5f663b14601 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_type.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Literal + +CassetteType = Literal[ + "Any", + "1uL", + "5uL", + "10uL", + "PeriWash aspirate", + "PeriWash dispense", + "Any random access", + "Any non-random access", +] +"""The peristaltic pump cassette a step requires. + +Tubing cassettes are named by their per-revolution delivery volume. ``"Any"`` accepts whatever is +fitted; ``"Any random access"`` and ``"Any non-random access"`` narrow that to the cassettes with +and without a random-access dispense head. The PeriWash cassettes are set on the instrument rather +than requested by a step. A step with no requirement carries None rather than a member of this +type. +""" + +CASSETTE_TYPE_TO_BYTE: dict[CassetteType, int] = { + "Any": 0, + "1uL": 1, + "5uL": 2, + "10uL": 3, + "PeriWash aspirate": 4, + "PeriWash dispense": 5, + "Any random access": 253, + "Any non-random access": 254, +} +"""The value each cassette is encoded as in a step command. No requirement encodes 255.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/fill_pattern.py b/pylabrobot/agilent/biotek/lhc/enums/steps/fill_pattern.py new file mode 100644 index 00000000000..7780f096c09 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/fill_pattern.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Literal + +FillPattern = Literal["Column", "Row"] +"""The order in which a random-access dispense visits the selected wells. + +A step that does not choose an order carries None. +""" + +FILL_PATTERN_TO_BYTE: dict[FillPattern, int] = {"Column": 0, "Row": 1} +"""The value each pattern is encoded as in a step command. No pattern encodes 255.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/peri_flow_rate.py b/pylabrobot/agilent/biotek/lhc/enums/steps/peri_flow_rate.py new file mode 100644 index 00000000000..221fa4cec30 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/peri_flow_rate.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Literal + +PeriFlowRate = Literal["Low", "Medium", "High"] +"""How fast a peristaltic step pumps.""" + +PERI_FLOW_RATE_TO_BYTE: dict[PeriFlowRate, int] = {"Low": 0, "Medium": 1, "High": 2} +"""The value each rate is encoded as in a step command.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/peri_pump.py b/pylabrobot/agilent/biotek/lhc/enums/steps/peri_pump.py new file mode 100644 index 00000000000..4eeea6269c0 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/peri_pump.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Literal + +PeriPump = Literal["Primary", "Secondary"] +"""Which peristaltic pump a step drives. + +A second pump is an option; a step naming ``"Secondary"`` fails on an instrument without one. A +step that leaves the choice to the instrument carries None. +""" + +PERI_PUMP_TO_BYTE: dict[PeriPump, int] = {"Primary": 1, "Secondary": 2} +"""The value each pump is encoded as in a step command. An unspecified pump encodes 0.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/secondary_aspirate_pattern.py b/pylabrobot/agilent/biotek/lhc/enums/steps/secondary_aspirate_pattern.py new file mode 100644 index 00000000000..3b38519bdbf --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/secondary_aspirate_pattern.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Literal + +SecondaryAspiratePattern = Literal["None", "Point", "Circle", "Square"] +"""The path a secondary aspirate traces in the well after the primary aspirate. + +``"Point"`` aspirates once at the offset position; ``"Circle"`` and ``"Square"`` sweep the tip +around the well bottom to reach residue the primary aspirate leaves behind. +""" + +SECONDARY_ASPIRATE_PATTERN_TO_BYTE: dict[SecondaryAspiratePattern, int] = { + "None": 0, + "Point": 1, + "Circle": 2, + "Square": 3, +} +"""The value each pattern is encoded as in a step command.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/shake_axis.py b/pylabrobot/agilent/biotek/lhc/enums/steps/shake_axis.py new file mode 100644 index 00000000000..2d861f7fc5a --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/shake_axis.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Literal + +ShakeAxis = Literal["X", "Y"] +"""The axis the carrier shakes along.""" + +SHAKE_AXIS_TO_BYTE: dict[ShakeAxis, int] = {"X": 0, "Y": 1} +"""The value each axis is encoded as in a step command.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/shake_intensity.py b/pylabrobot/agilent/biotek/lhc/enums/steps/shake_intensity.py new file mode 100644 index 00000000000..d3db2551912 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/shake_intensity.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Literal + +ShakeIntensity = Literal["Variable", "Slow", "Medium", "Fast"] +"""How vigorously the carrier shakes. + +The three fixed levels shake at a set frequency; ``"Variable"`` sweeps across the range instead of +holding one frequency. +""" + +SHAKE_INTENSITY_TO_BYTE: dict[ShakeIntensity, int] = { + "Variable": 1, + "Slow": 2, + "Medium": 3, + "Fast": 4, +} +"""The value each intensity is encoded as in a step command.""" + +SHAKE_INTENSITY_TO_FREQUENCY: dict[ShakeIntensity, float | None] = { + "Variable": None, + "Slow": 3.5, + "Medium": 5.0, + "Fast": 8.0, +} +"""Shake frequency in Hz per intensity, or None where the frequency is swept.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/step_action.py b/pylabrobot/agilent/biotek/lhc/enums/steps/step_action.py new file mode 100644 index 00000000000..11dca338556 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/step_action.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import enum + + +class StepAction(enum.IntEnum): + """A control-flow or plate-handling action attached to a protocol step. + + These sit alongside the liquid-handling operation in :class:`StepType`: they sequence a run + rather than move liquid, covering delays, loops, remarks, and plate transfers to and from a + stacker. + """ + + UNDEFINED = 0 + END_OF_LIST = 1 + CUSTOM = 2 + DELAY = 3 + REMARK = 4 + LOOP_START = 5 + LOOP_END = 6 + DELIVER_PLATE = 7 + NTH_PLATE = 8 + NTH_PLATE_END = 9 + RETRIEVE_PLATE = 10 + RESTACK = 11 + DELAY_START_TIMER = 12 diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/step_type.py b/pylabrobot/agilent/biotek/lhc/enums/steps/step_type.py new file mode 100644 index 00000000000..69d78e857d2 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/step_type.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import enum + + +class StepType(enum.IntEnum): + """The operation a protocol step performs. + + Members are prefixed by the hardware that carries them out: ``PERI_`` for the peristaltic pumps, + ``SYRINGE_`` for the syringe dispenser, ``MANIFOLD_`` for the wash manifold, ``STRIP_`` for the + strip washer and ``PERI_WASH_`` for peristaltic media exchange. Which members an instrument + offers depends on its fitted hardware and firmware. + """ + + UNDEFINED = 0 + PERI_DISPENSE = 1 + PERI_PRIME = 2 + PERI_PURGE = 3 + SYRINGE_DISPENSE = 4 + SYRINGE_PRIME = 5 + MANIFOLD_WASH = 6 + MANIFOLD_ASPIRATE = 7 + MANIFOLD_DISPENSE = 8 + MANIFOLD_PRIME = 9 + MANIFOLD_AUTO_CLEAN = 10 + SHAKE_SOAK = 11 + WASH_1536 = 12 + STRIP_WASH = 13 + STRIP_ASPIRATE = 14 + STRIP_DISPENSE = 15 + STRIP_PRIME = 16 + PERI_WASH_ASPIRATE = 17 + PERI_WASH_DISPENSE = 18 diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/syringe.py b/pylabrobot/agilent/biotek/lhc/enums/steps/syringe.py new file mode 100644 index 00000000000..31055f92be0 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/syringe.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Literal + +Syringe = Literal["A", "B", "Both"] +"""Which syringe a syringe step drives. + +``"Both"`` dispenses from A and B simultaneously and requires a double syringe box. A step that +drives no syringe carries None rather than a member of this type. +""" + +SYRINGE_TO_BYTE: dict[Syringe, int] = {"A": 1, "B": 2, "Both": 3} +"""The value each selection is encoded as in a step command. No syringe encodes 0.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/syringe_bottle.py b/pylabrobot/agilent/biotek/lhc/enums/steps/syringe_bottle.py new file mode 100644 index 00000000000..41ace424ba8 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/syringe_bottle.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Literal + +SyringeBottle = Literal["A1", "A2", "B1", "B2", "A1B1", "A1B2", "A2B1", "A2B2"] +"""Which supply bottle each driven syringe draws from. + +Every syringe has two selectable bottles. Combined values name the pairing used when both syringes +run together: ``"A1B2"`` draws syringe A from its first bottle and syringe B from its second. A +step that names no bottle carries None rather than a member of this type. +""" + +SYRINGE_BOTTLE_TO_BYTE: dict[SyringeBottle, int] = { + "A1": 1, + "A2": 2, + "B1": 3, + "B2": 4, + "A1B1": 5, + "A1B2": 6, + "A2B1": 7, + "A2B2": 8, +} +"""The value each selection is encoded as in a step command. No bottle encodes 0.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/travel_rate.py b/pylabrobot/agilent/biotek/lhc/enums/steps/travel_rate.py new file mode 100644 index 00000000000..15fbd30f555 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/travel_rate.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Literal + +TravelRate = Literal[ + "1", "2", "3", "4", "5", "0 CW", "1 CW", "2 CW", "3 CW", "4 CW", "6 CW", "7 CW" +] +"""How fast the aspirate head travels down through a well. + +Plain rates are two-speed: the tips descend at the listed rate and cover the last stretch at +1.0 mm/s (2.0 mm/s for ``"5"``), a slow final pass that pulls the well dry. Rates suffixed ``CW`` +hold one speed the whole way down, leaving an adherent monolayer intact, and require the cell +washing module. + +``"0 CW"`` and ``"7 CW"`` are available on the strip washer only. There is no ``"5 CW"``. +""" + +TRAVEL_RATE_TO_BYTE: dict[TravelRate, int] = { + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6 CW": 6, + "1 CW": 7, + "2 CW": 8, + "3 CW": 9, + "4 CW": 10, + "0 CW": 11, + "7 CW": 12, +} +"""The value each rate is encoded as in a step command.""" + +TRAVEL_RATE_TO_SPEED: dict[TravelRate, float] = { + "1": 4.1, + "2": 5.0, + "3": 7.3, + "4": 9.4, + "5": 9.4, + "0 CW": 1.0, + "1 CW": 4.1, + "2 CW": 5.0, + "3 CW": 7.3, + "4 CW": 9.4, + "6 CW": 14.7, + "7 CW": 30.0, +} +"""Descent speed in mm/s per rate.""" + +WASHER_TRAVEL_RATES: tuple[TravelRate, ...] = ( + "1", + "2", + "3", + "4", + "5", + "1 CW", + "2 CW", + "3 CW", + "4 CW", + "6 CW", +) +"""The rates a wash manifold aspirate step accepts.""" + +STRIP_TRAVEL_RATES: tuple[TravelRate, ...] = WASHER_TRAVEL_RATES + ("0 CW", "7 CW") +"""The rates a strip washer aspirate step accepts.""" + + +def is_cell_washing(rate: TravelRate) -> bool: + """Whether a rate needs the cell washing module. + + Args: + rate: The travel rate to test. + + Returns: + True for the single-speed ``CW`` rates. + """ + return rate.endswith(" CW") diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/wash_format.py b/pylabrobot/agilent/biotek/lhc/enums/steps/wash_format.py new file mode 100644 index 00000000000..40566eab18b --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/wash_format.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Literal + +WashFormat = Literal["Plate", "Sector", "Strip"] +"""Which part of the plate a wash step covers per pass. + +``"Plate"`` washes every well in one pass. ``"Sector"`` and ``"Strip"`` wash a subset at a time, so +a manifold with fewer tubes than the plate has wells can cover the whole plate in several passes. +""" + +WASH_FORMAT_TO_BYTE: dict[WashFormat, int] = {"Plate": 0, "Sector": 1, "Strip": 2} +"""The value each format is encoded as in a step command.""" From 76409dab72e172eeba18996919f2ab2cd073de61 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Sun, 30 Aug 2026 09:54:34 +0200 Subject: [PATCH 02/19] implemented steps and their serialization --- .../agilent/biotek/lhc/comm/__init__.py | 26 + .../agilent/biotek/lhc/comm/connection.py | 67 +++ .../agilent/biotek/lhc/comm/ftdi_transport.py | 131 +++++ .../biotek/lhc/comm/serial_transport.py | 95 ++++ .../agilent/biotek/lhc/comm/transport.py | 126 +++++ .../agilent/biotek/lhc/devices/__init__.py | 0 .../biotek/lhc/devices/components/__init__.py | 0 .../biotek/lhc/devices/instrument_settings.py | 75 +++ .../agilent/biotek/lhc/protocols/__init__.py | 0 .../biotek/lhc/protocols/steps/__init__.py | 34 ++ .../biotek/lhc/protocols/steps/definition.py | 166 ++++++ .../biotek/lhc/protocols/steps/packing.py | 73 +++ .../lhc/protocols/steps/step_interface.py | 69 +++ .../protocols/steps/step_parts/__init__.py | 1 + .../protocols/steps/step_parts/durations.py | 55 ++ .../lhc/protocols/steps/step_parts/groups.py | 480 ++++++++++++++++++ .../lhc/protocols/steps/step_parts/masks.py | 114 +++++ .../protocols/steps/step_parts/positioning.py | 48 ++ .../lhc/protocols/steps/step_parts/rates.py | 23 + .../lhc/protocols/steps/steps/__init__.py | 73 +++ .../steps/steps/manifold_aspirate.py | 146 ++++++ .../steps/steps/manifold_auto_clean.py | 79 +++ .../steps/steps/manifold_dispense.py | 139 +++++ .../protocols/steps/steps/manifold_prime.py | 111 ++++ .../protocols/steps/steps/manifold_wash.py | 162 ++++++ .../protocols/steps/steps/peri_dispense.py | 210 ++++++++ .../lhc/protocols/steps/steps/peri_prime.py | 151 ++++++ .../lhc/protocols/steps/steps/peri_purge.py | 20 + .../steps/steps/peri_wash_aspirate.py | 119 +++++ .../steps/steps/peri_wash_dispense.py | 144 ++++++ .../lhc/protocols/steps/steps/shake_soak.py | 106 ++++ .../protocols/steps/steps/strip_aspirate.py | 130 +++++ .../protocols/steps/steps/strip_dispense.py | 160 ++++++ .../lhc/protocols/steps/steps/strip_prime.py | 89 ++++ .../lhc/protocols/steps/steps/strip_wash.py | 164 ++++++ .../protocols/steps/steps/syringe_dispense.py | 172 +++++++ .../protocols/steps/steps/syringe_prime.py | 133 +++++ .../lhc/protocols/steps/steps/wash_1536.py | 184 +++++++ .../biotek/lhc/serialization/__init__.py | 27 + .../biotek/lhc/serialization/command.py | 69 +++ .../lhc/serialization/command_numbers.py | 145 ++++++ .../lhc/serialization/commands/__init__.py | 61 +++ .../serialization/commands/configuration.py | 149 ++++++ .../lhc/serialization/commands/diagnostics.py | 66 +++ .../lhc/serialization/commands/queries.py | 110 ++++ .../lhc/serialization/commands/run_control.py | 129 +++++ .../agilent/biotek/lhc/serialization/frame.py | 105 ++++ 47 files changed, 4936 insertions(+) create mode 100644 pylabrobot/agilent/biotek/lhc/comm/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/comm/connection.py create mode 100644 pylabrobot/agilent/biotek/lhc/comm/ftdi_transport.py create mode 100644 pylabrobot/agilent/biotek/lhc/comm/serial_transport.py create mode 100644 pylabrobot/agilent/biotek/lhc/comm/transport.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/components/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/instrument_settings.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/definition.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/packing.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/durations.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/groups.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/masks.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/positioning.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/rates.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_purge.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/command.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/command_numbers.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/commands/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/commands/diagnostics.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/commands/queries.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/commands/run_control.py create mode 100644 pylabrobot/agilent/biotek/lhc/serialization/frame.py diff --git a/pylabrobot/agilent/biotek/lhc/comm/__init__.py b/pylabrobot/agilent/biotek/lhc/comm/__init__.py new file mode 100644 index 00000000000..9fb55b4a5d7 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/comm/__init__.py @@ -0,0 +1,26 @@ +"""Communication with an instrument: transport selection, link lifecycle and byte-level I/O.""" + +from __future__ import annotations + +from .connection import open_transport, transport_for +from .ftdi_transport import FtdiTransport, is_ftdi_port, serial_number +from .serial_transport import SerialTransport +from .transport import ( + BAUDRATE, + DEFAULT_READ_TIMEOUT, + DEFAULT_WRITE_TIMEOUT, + Transport, +) + +__all__ = [ + "BAUDRATE", + "DEFAULT_READ_TIMEOUT", + "DEFAULT_WRITE_TIMEOUT", + "FtdiTransport", + "SerialTransport", + "Transport", + "is_ftdi_port", + "open_transport", + "serial_number", + "transport_for", +] diff --git a/pylabrobot/agilent/biotek/lhc/comm/connection.py b/pylabrobot/agilent/biotek/lhc/comm/connection.py new file mode 100644 index 00000000000..13901c88e54 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/comm/connection.py @@ -0,0 +1,67 @@ +"""Choosing a transport for a port string. + +This is the one place in the package where the two transports are told apart. Everything above it +holds a :class:`~.transport.Transport` and never learns which kind it got. +""" + +from __future__ import annotations + +import logging + +from .ftdi_transport import FtdiTransport, is_ftdi_port +from .serial_transport import SerialTransport +from .transport import DEFAULT_READ_TIMEOUT, Transport + +logger = logging.getLogger(__name__) + + +def transport_for( + port: str, + name: str = "BioTek instrument", + timeout: float = DEFAULT_READ_TIMEOUT, +) -> Transport: + """Build the transport a port string names, without opening it. + + A string carrying a device serial number (``USB sn:`` or ``ftdi:``) names + a USB bridge; anything else is taken to be an operating system serial port and is passed through + unexamined, so ``/dev/ttyUSB0``, ``/dev/ttyS4`` and ``COM3`` are all equally valid. + + Args: + port: The port the instrument is on. + name: Human-readable instrument name, used in logs. + timeout: Read timeout in seconds. + + Returns: + An unopened transport. + + Raises: + ValueError: If ``port`` is empty. + """ + if not port: + raise ValueError("no port given") + if is_ftdi_port(port): + return FtdiTransport(port=port, name=name, timeout=timeout) + return SerialTransport(port=port, name=name, timeout=timeout) + + +async def open_transport( + port: str, + name: str = "BioTek instrument", + timeout: float = DEFAULT_READ_TIMEOUT, +) -> Transport: + """Build the transport a port string names and open it. + + Args: + port: The port the instrument is on. + name: Human-readable instrument name, used in logs. + timeout: Read timeout in seconds. + + Returns: + An open transport, configured for 38400 8N2 with no flow control. + + Raises: + ValueError: If ``port`` is empty. + """ + transport = transport_for(port=port, name=name, timeout=timeout) + await transport.setup() + return transport diff --git a/pylabrobot/agilent/biotek/lhc/comm/ftdi_transport.py b/pylabrobot/agilent/biotek/lhc/comm/ftdi_transport.py new file mode 100644 index 00000000000..7096c1c8de4 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/comm/ftdi_transport.py @@ -0,0 +1,131 @@ +"""The link over a USB bridge, driven directly rather than through a virtual serial port. + +The instrument behind the bridge speaks the same asynchronous serial link either way; only the +host-side plumbing differs. A port string names this transport by carrying a device serial number, +in one of two forms:: + + USB 405 TS/LS sn:13010914 + ftdi:13010914 + +The serial number is all that is needed to open the device. +""" + +from __future__ import annotations + +import logging + +from pylabrobot.io.ftdi import FTDI + +from .transport import BAUDRATE, DATA_BITS, DEFAULT_READ_TIMEOUT, STOP_BITS, Transport + +logger = logging.getLogger(__name__) + +_USB_PREFIX = "USB " +_SERIAL_MARKER = " sn:" +_FTDI_PREFIX = "ftdi:" + +_PARITY_NONE = 0 +_FLOW_CONTROL_NONE = 0x0 + + +def is_ftdi_port(port: str) -> bool: + """Does this port string name a USB bridge rather than an operating system serial port? + + The test is the port string's format, never a platform-specific prefix: a serial port is + ``/dev/ttyUSB0`` as readily as ``COM3``, and neither says anything about how the host is cabled. + + Args: + port: The port string to inspect. + + Returns: + True if the string carries a device serial number. + """ + return port.startswith(_FTDI_PREFIX) or (port.startswith(_USB_PREFIX) and _SERIAL_MARKER in port) + + +def serial_number(port: str) -> str: + """The device serial number carried by an FTDI port string. + + Args: + port: A port string accepted by :func:`is_ftdi_port`. + + Returns: + The serial number, with surrounding whitespace removed. + + Raises: + ValueError: If the string carries no serial number. + """ + if port.startswith(_FTDI_PREFIX): + return port[len(_FTDI_PREFIX) :].strip() + if _SERIAL_MARKER in port: + return port.split(_SERIAL_MARKER, 1)[1].strip() + raise ValueError(f"{port!r} carries no device serial number") + + +class FtdiTransport(Transport): + """A link over a USB bridge, opened by device serial number. + + The bridge has no read timeout of its own -- a read returns whatever has already arrived -- so + :meth:`Transport.read_exactly` is what turns it into a timed read. + + Args: + port: A port string carrying a device serial number. + name: Human-readable instrument name, used in logs. + timeout: Read timeout in seconds. + io: An already-built transport to use instead of opening ``port``. Intended for tests that + replay a captured session. + """ + + def __init__( + self, + port: str, + name: str = "BioTek instrument", + timeout: float = DEFAULT_READ_TIMEOUT, + io: FTDI | None = None, + ) -> None: + super().__init__(port=port, timeout=timeout) + self.io = io or FTDI(human_readable_device_name=name, device_id=serial_number(port)) + + async def setup(self) -> None: + """Open the device and configure it for 38400 8N2 with no flow control.""" + await self.io.setup() + await self.io.set_baudrate(BAUDRATE) + await self.io.set_line_property(DATA_BITS, STOP_BITS, _PARITY_NONE) + await self.io.set_flowctrl(_FLOW_CONTROL_NONE) + await self.io.set_rts(True) + await self.io.set_dtr(True) + logger.info("[%s] usb link open at %d baud, 8N2", self.port, BAUDRATE) + + async def stop(self) -> None: + """Close the device.""" + await self.io.stop() + logger.info("[%s] usb link closed", self.port) + + async def write(self, data: bytes) -> None: + """Write every byte of ``data``. + + Args: + data: The bytes to write. + + Raises: + RuntimeError: If the bridge accepted fewer bytes than it was given. + """ + written = await self.io.write(data) + if written is not None and written != len(data): + raise RuntimeError(f"[{self.port}] wrote {written} of {len(data)} bytes") + + async def read(self, num_bytes: int = 1) -> bytes: + """Read whatever has already arrived, up to ``num_bytes`` bytes. + + Args: + num_bytes: The most bytes to return. + + Returns: + What had arrived, which is often shorter than requested and may be empty. + """ + return await self.io.read(num_bytes) + + async def purge(self) -> None: + """Discard whatever is buffered in either direction.""" + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() diff --git a/pylabrobot/agilent/biotek/lhc/comm/serial_transport.py b/pylabrobot/agilent/biotek/lhc/comm/serial_transport.py new file mode 100644 index 00000000000..8688dcff5d5 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/comm/serial_transport.py @@ -0,0 +1,95 @@ +"""The link over an operating system serial port.""" + +from __future__ import annotations + +import logging + +from pylabrobot.io.serial import Serial + +from .transport import ( + BAUDRATE, + DATA_BITS, + DEFAULT_READ_TIMEOUT, + DEFAULT_WRITE_TIMEOUT, + PARITY_NONE, + STOP_BITS, + Transport, +) + +logger = logging.getLogger(__name__) + + +class SerialTransport(Transport): + """A link over a serial port, named the way the operating system names it. + + Args: + port: The serial port to open, such as ``/dev/ttyUSB0``, ``/dev/ttyS4`` or ``COM3``. + name: Human-readable instrument name, used in logs. + timeout: Read timeout in seconds. + io: An already-built transport to use instead of opening ``port``. Intended for tests that + replay a captured session. + """ + + def __init__( + self, + port: str, + name: str = "BioTek instrument", + timeout: float = DEFAULT_READ_TIMEOUT, + io: Serial | None = None, + ) -> None: + super().__init__(port=port, timeout=timeout) + self.io = io or Serial( + human_readable_device_name=name, + port=port, + baudrate=BAUDRATE, + bytesize=DATA_BITS, + parity=PARITY_NONE, + stopbits=STOP_BITS, + timeout=timeout, + write_timeout=DEFAULT_WRITE_TIMEOUT, + rtscts=False, + dsrdtr=False, + xonxoff=False, + ) + + def _apply_read_timeout(self, timeout: float) -> None: + """Set the port's own read timeout, which is what makes a blocking read return. + + Args: + timeout: Read timeout in seconds. + """ + self.io.set_read_timeout(timeout) + + async def setup(self) -> None: + """Open the port. The link parameters were fixed when the port was constructed.""" + await self.io.setup() + logger.info("[%s] serial link open at %d baud, 8N2", self.port, BAUDRATE) + + async def stop(self) -> None: + """Close the port.""" + await self.io.stop() + logger.info("[%s] serial link closed", self.port) + + async def write(self, data: bytes) -> None: + """Write every byte of ``data``. + + Args: + data: The bytes to write. + """ + await self.io.write(data) + + async def read(self, num_bytes: int = 1) -> bytes: + """Read up to ``num_bytes`` bytes, blocking until the port's read timeout passes. + + Args: + num_bytes: The most bytes to return. + + Returns: + What had arrived, which may be shorter than requested. + """ + return await self.io.read(num_bytes) + + async def purge(self) -> None: + """Discard whatever is buffered in either direction.""" + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() diff --git a/pylabrobot/agilent/biotek/lhc/comm/transport.py b/pylabrobot/agilent/biotek/lhc/comm/transport.py new file mode 100644 index 00000000000..78ba46f66d5 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/comm/transport.py @@ -0,0 +1,126 @@ +"""The byte-level link to an instrument, independent of how the host is cabled to it. + +An instrument speaks the same asynchronous serial link whether it is reached through an operating +system serial port or through a USB bridge, so everything above this module is written once. +:class:`Transport` is that shared surface; :mod:`.serial_transport` and :mod:`.ftdi_transport` +supply the two implementations, and :func:`.connection.open_transport` picks between them. +""" + +from __future__ import annotations + +import abc +import asyncio +import logging +import time + +logger = logging.getLogger(__name__) + +BAUDRATE = 38400 +DATA_BITS = 8 +STOP_BITS = 2 +PARITY_NONE = "N" + +DEFAULT_READ_TIMEOUT = 15.0 +DEFAULT_WRITE_TIMEOUT = 5.0 + +_POLL_INTERVAL = 0.005 + + +class Transport(abc.ABC): + """A 38400 8N2 link to an instrument, with no flow control. + + Reads are the only subtle part. A single :meth:`read` may return fewer bytes than asked for, so + callers that need a fixed number of them use :meth:`read_exactly`, which keeps reading until the + count is met or a deadline passes. A short return from :meth:`read_exactly` is how a caller + detects an instrument that stopped answering part-way through a reply, so it is not an error + here. + + Args: + port: The port string this transport was opened from, kept for logging and error messages. + timeout: Default read timeout in seconds. + """ + + def __init__(self, port: str, timeout: float = DEFAULT_READ_TIMEOUT) -> None: + self._port = port + self._read_timeout = timeout + + @property + def port(self) -> str: + """The port string this transport was opened from.""" + return self._port + + @property + def read_timeout(self) -> float: + """The default read timeout in seconds.""" + return self._read_timeout + + @read_timeout.setter + def read_timeout(self, timeout: float) -> None: + self._read_timeout = timeout + self._apply_read_timeout(timeout) + + def _apply_read_timeout(self, timeout: float) -> None: + """Push the read timeout down to the transport, for transports that enforce it themselves. + + Args: + timeout: Read timeout in seconds. + """ + + @abc.abstractmethod + async def setup(self) -> None: + """Open the link and configure it for 38400 8N2 with no flow control.""" + + @abc.abstractmethod + async def stop(self) -> None: + """Close the link. Calling this on a closed link does nothing.""" + + @abc.abstractmethod + async def write(self, data: bytes) -> None: + """Write every byte of ``data``. + + Args: + data: The bytes to write. + + Raises: + RuntimeError: If the transport accepted fewer bytes than it was given. + """ + + @abc.abstractmethod + async def read(self, num_bytes: int = 1) -> bytes: + """Read up to ``num_bytes`` bytes. May return fewer, including none at all. + + Args: + num_bytes: The most bytes to return. + + Returns: + What had arrived, which may be shorter than requested. + """ + + @abc.abstractmethod + async def purge(self) -> None: + """Discard whatever is buffered in either direction.""" + + async def read_exactly(self, num_bytes: int, timeout: float | None = None) -> bytes: + """Read ``num_bytes`` bytes, or fewer once ``timeout`` has passed. + + Args: + num_bytes: How many bytes to read. + timeout: Read timeout in seconds. Defaults to :attr:`read_timeout`. + + Returns: + Exactly ``num_bytes`` bytes, or fewer if the deadline passed first. + """ + timeout = self._read_timeout if timeout is None else timeout + self._apply_read_timeout(timeout) + deadline = time.monotonic() + timeout + data = bytearray() + while len(data) < num_bytes: + chunk = await self.read(num_bytes - len(data)) + if chunk: + data += chunk + continue + if time.monotonic() >= deadline: + logger.debug("[%s] read %d of %d bytes before timing out", self._port, len(data), num_bytes) + break + await asyncio.sleep(_POLL_INTERVAL) + return bytes(data) diff --git a/pylabrobot/agilent/biotek/lhc/devices/__init__.py b/pylabrobot/agilent/biotek/lhc/devices/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/__init__.py b/pylabrobot/agilent/biotek/lhc/devices/components/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/agilent/biotek/lhc/devices/instrument_settings.py b/pylabrobot/agilent/biotek/lhc/devices/instrument_settings.py new file mode 100644 index 00000000000..caa1b57474b --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/instrument_settings.py @@ -0,0 +1,75 @@ +"""What an instrument has fitted. + +A step encodes itself against this record, and validation measures a step against it, so it is the +one description of the hardware that the rest of the package reads. Reading it off the instrument +and loading it from a protocol file both happen in this package; the record itself is plain data. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import StripWasherManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_size import SyringeBoxSize +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.valve_box import ValveBox +from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold + + +@dataclass +class InstrumentSettings: + """The options an instrument is fitted with. + + Attributes: + family: Which instrument model this is. + washer_manifold: The wash manifold fitted, or ``NOT_INSTALLED``. + syringe_box: The syringe box fitted, or ``NOT_INSTALLED``. + syringe_manifold: The syringe manifold fitted, or ``NOT_INSTALLED``. + buffer_switching: Whether the buffer switching module is fitted. + valve_box: The valve box fitted, or ``NOT_INSTALLED``. + vacuum_filtration: Whether the vacuum filtration option is fitted. + peri_pump: Whether the primary peristaltic pump is fitted. + peri_pump_2: Whether the secondary peristaltic pump is fitted. + ultrasonic: Whether the ultrasonic cleaning module is fitted. + cell_washing: Whether the cell washing module is fitted, which unlocks the single-speed + travel rates and flow rates below 3. + y_axis_installed: Whether the manifold can be offset along Y. + half_ul_enabled: Whether syringe volumes may be given in half microlitres. + strip_washer_manifold: The strip washer manifold fitted, or ``NOT_INSTALLED``. + single_well_enabled: Whether single-well operations are enabled. + peri_wash_enabled: Whether the peristaltic wash step types are available. + advanced_dispense_offsets: Whether the dispensers accept the wider X offset range. Set only by + reading the instrument, never by loading a protocol file. + syringe_box_size: How many syringe bottles the fitted box holds, or ``UNKNOWN`` when the + instrument has not been asked. + """ + + family: InstrumentFamily = InstrumentFamily.EL406 + washer_manifold: WasherManifold = WasherManifold.TUBE_96_DUAL + syringe_box: SyringeBoxType = SyringeBoxType.AUTOCLAVABLE + syringe_manifold: SyringeManifold = SyringeManifold.TUBE_16 + buffer_switching: bool = True + valve_box: ValveBox = ValveBox.WASHER + vacuum_filtration: bool = False + peri_pump: bool = True + peri_pump_2: bool = False + ultrasonic: bool = True + cell_washing: bool = True + y_axis_installed: bool = True + half_ul_enabled: bool = True + strip_washer_manifold: StripWasherManifold = StripWasherManifold.NOT_INSTALLED + single_well_enabled: bool = False + peri_wash_enabled: bool = False + advanced_dispense_offsets: bool = False + syringe_box_size: SyringeBoxSize = SyringeBoxSize.UNKNOWN + + @property + def supports_random_access_tail(self) -> bool: + """Whether peristaltic step definitions on this model may carry random-access fields. + + The MultiFlo predates random-access dispensing and reads a definition of the exact older + length, so the extra fields make a definition it cannot parse at all. + """ + return self.family is not InstrumentFamily.MULTIFLO diff --git a/pylabrobot/agilent/biotek/lhc/protocols/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/__init__.py new file mode 100644 index 00000000000..d75337b2328 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/__init__.py @@ -0,0 +1,34 @@ +"""Protocol steps: what they hold, how a protocol file stores them, how they are encoded.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step + + +def step_from_definition(text: str) -> Step: + """Read a step of any type back from its definition text. + + Which type it is comes from the definition itself. A composite step's parts are separated + before the step type is read, so the type of the whole is what decides. + + Args: + text: The ``|``-separated definition, or the ``#``-joined parts of a composite one. + + Returns: + The step, of whichever class implements its type. + + Raises: + ValueError: If the definition names no known step type, or does not have that type's layout. + """ + from pylabrobot.agilent.biotek.lhc.protocols.steps.steps import STEP_CLASSES + + found, start = definition.fields(text.split("#")[0]) + step_type = StepType(int(found[start])) + if step_type not in STEP_CLASSES: + raise ValueError(f"no step class for {step_type.name}") + return STEP_CLASSES[step_type].from_definition(text) + + +__all__ = ["Step", "step_from_definition"] diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/definition.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/definition.py new file mode 100644 index 00000000000..a42e0977174 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/definition.py @@ -0,0 +1,166 @@ +"""Reading the delimited text a protocol file stores a step as. + +Every step in a protocol file is one ``|``-separated string. The first field is an optional format +marker, the field after it is the step type, and the rest belong to that type. These helpers cover +what all step types share: splitting, locating the step-type field and converting a single field +with the width the format uses for it. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TRAVEL_RATE_TO_BYTE, TravelRate +from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import ( + WASH_FORMAT_TO_BYTE, + WashFormat, +) + +FORMAT_MARKER = "DV103" +"""The format marker written on every step this package produces.""" + +_MARKER_PREFIX = "DV" +_MARKER_VERSION = 103.0 + + +def fields(text: str, empty_ok: bool = False) -> tuple[list[str], int]: + """Split a step definition and say where its step-type field sits. + + Args: + text: The step definition. + empty_ok: Whether an empty field holds its place. Some step types write empty fields and count + them; the rest drop them. + + Returns: + The fields, and the index of the step-type field among them. + + Raises: + ValueError: If the definition is empty, or carries a newer format marker than this package + reads. + """ + found = text.split("|") + if not empty_ok: + found = [field for field in found if field] + if not found: + raise ValueError("empty step definition") + if not found[0].startswith(_MARKER_PREFIX): + return found, 0 + if float(found[0][len(_MARKER_PREFIX) :]) > _MARKER_VERSION: + raise ValueError(f"step definition {found[0]} is newer than {FORMAT_MARKER}") + return found, 1 + + +def own_fields(text: str, step_type: StepType, count: int, empty_ok: bool = False) -> list[str]: + """The fields belonging to a step type whose layout is a fixed length. + + Args: + text: The step definition. + step_type: The step type the layout belongs to, named in the error message. + count: How many fields the layout has, counting the step-type field. + empty_ok: Whether an empty field holds its place. + + Returns: + The fields after the step-type field, ``count - 1`` of them. + + Raises: + ValueError: If the definition has a different number of fields. A definition one field short + is another instrument family's, not a repairable one. + """ + found, start = fields(text, empty_ok) + if len(found) - start != count: + raise ValueError(f"{step_type.name} expects {count} fields, got {len(found) - start}: {text!r}") + return found[start + 1 :] + + +def own_fields_at_least( + text: str, step_type: StepType, minimum: int, empty_ok: bool = False +) -> list[str]: + """The fields belonging to a step type whose layout ends in an optional tail. + + What a surplus means depends on how much of it there is, so the caller decides. + + Args: + text: The step definition. + step_type: The step type the layout belongs to, named in the error message. + minimum: The shortest the layout can be, counting the step-type field. + empty_ok: Whether an empty field holds its place. + + Returns: + The fields after the step-type field, at least ``minimum - 1`` of them. + + Raises: + ValueError: If the definition has fewer fields than that. + """ + found, start = fields(text, empty_ok) + if len(found) - start < minimum: + raise ValueError( + f"{step_type.name} expects at least {minimum} fields, got {len(found) - start}: {text!r}" + ) + return found[start + 1 :] + + +def flag(field: str) -> bool: + """Read a boolean field. + + Args: + field: The field text, ``"True"`` or ``"False"`` in any case. + + Returns: + What it says. + + Raises: + ValueError: If it says anything else. + """ + lowered = field.strip().lower() + if lowered in ("true", "false"): + return lowered == "true" + raise ValueError(f"not a boolean: {field!r}") + + +def number(field: str, bits: int) -> int: + """Read an unsigned field of a given width. + + Args: + field: The field text. + bits: How many bits the value is stored in. + + Returns: + The value. + + Raises: + ValueError: If it does not fit, which makes the whole definition unreadable rather than + merely invalid. + """ + value = int(field) + if not 0 <= value < 1 << bits: + raise ValueError(f"{value} does not fit in {bits} unsigned bits") + return value + + +def signed(field: str, bits: int) -> int: + """Read a signed field of a given width, as the offsets are stored. + + Args: + field: The field text. + bits: How many bits the value is stored in. + + Returns: + The value. + + Raises: + ValueError: If it does not fit. + """ + value = int(field) + if not -(1 << (bits - 1)) <= value < 1 << (bits - 1): + raise ValueError(f"{value} does not fit in {bits} signed bits") + return value + + +BUFFERS: dict[str, Buffer] = {"A": "A", "B": "B", "C": "C", "D": "D"} +"""The buffer inlet each buffer field names.""" + +TRAVEL_RATES: dict[str, TravelRate] = {rate: rate for rate in TRAVEL_RATE_TO_BYTE} +"""The travel rate each travel-rate field names.""" + +WASH_FORMATS: dict[str, WashFormat] = {value: value for value in WASH_FORMAT_TO_BYTE} +"""The wash format each format field names.""" diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/packing.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/packing.py new file mode 100644 index 00000000000..65fb2d969dc --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/packing.py @@ -0,0 +1,73 @@ +"""Packing step parameters into the payload of a step command. + +Every multi-byte value is little-endian, and every step type pads its payload to a fixed length +that belongs to the step type rather than to its contents. +""" + +from __future__ import annotations + + +def u16(value: int) -> bytes: + """Two bytes for an unsigned value. + + Args: + value: The value to pack. + + Returns: + The two bytes, least significant first. + """ + return int(value).to_bytes(2, "little", signed=False) + + +def i16(value: int) -> bytes: + """Two bytes for a signed value. + + Args: + value: The value to pack. + + Returns: + The two bytes, least significant first. + """ + return int(value).to_bytes(2, "little", signed=True) + + +def u8(value: int) -> bytes: + """One byte for an unsigned value. + + Args: + value: The value to pack. + + Returns: + The byte. + """ + return int(value).to_bytes(1, "little", signed=False) + + +def i8(value: int) -> bytes: + """One byte for a signed value. + + Args: + value: The value to pack. + + Returns: + The byte. + """ + return int(value).to_bytes(1, "little", signed=True) + + +def pad(payload: bytes, length: int) -> bytes: + """Pad a payload to the length its step type sends. + + Args: + payload: The payload so far. + length: The length to pad to. + + Returns: + The payload followed by zero bytes. + + Raises: + ValueError: If the payload is already longer, which means the layout is miscounted. + """ + if len(payload) > length: + raise ValueError(f"step payload is {len(payload)} bytes, expected at most {length}") + return payload + bytes(length - len(payload)) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py new file mode 100644 index 00000000000..c5b2978a7c5 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py @@ -0,0 +1,69 @@ +"""What every protocol step is. + +A step is a dataclass of parameters plus three conversions: to and from the text a protocol file +stores it as, and to the payload of the command that runs it. The command a step is sent as, and +the frame around that payload, belong to the serialization layer; whether a step *may* run belongs +to validation. A step itself only knows how to express its own parameters. +""" + +from __future__ import annotations + +import abc +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType + + +class Step(abc.ABC): + """One step of a protocol. + + Attributes: + step_type: Which operation this class performs. + """ + + step_type: ClassVar[StepType] + + @abc.abstractmethod + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Most step types ignore the settings; the peristaltic ones use them to decide whether the + instrument reads the random-access fields at all. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition, opening with the format marker and the step type. + """ + + @classmethod + @abc.abstractmethod + def from_definition(cls, text: str) -> Step: + """Read a step of this type back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this type's layout. + """ + + @abc.abstractmethod + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + The settings must be what the instrument reports, not what a protocol file declares: what is + fitted changes the layout, widening an offset on the dispensers and adding a field to a strip + dispense. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload, padded to the length this step type sends. + """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/__init__.py new file mode 100644 index 00000000000..71e093a8e00 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/__init__.py @@ -0,0 +1 @@ +"""Reusable pieces of a protocol step.""" diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/durations.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/durations.py new file mode 100644 index 00000000000..0250f7ab9c7 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/durations.py @@ -0,0 +1,55 @@ +"""Durations, which a protocol file stores as clock text but a caller gives in seconds.""" + +from __future__ import annotations + + +def format_hours_minutes(duration: int) -> str: + """Write a duration as the ``HH:MM`` text a protocol file stores. + + Args: + duration: Duration in seconds. Seconds below a whole minute are dropped, which is the + resolution the instrument runs these at. + + Returns: + The field text. + """ + minutes = duration // 60 + return f"{minutes // 60:02d}:{minutes % 60:02d}" + + +def parse_hours_minutes(field: str) -> int: + """Read a ``HH:MM`` field. + + Args: + field: The field text. + + Returns: + The duration in seconds. + """ + hours, _, minutes = field.strip().partition(":") + return (int(hours) * 60 + int(minutes)) * 60 + + +def format_minutes_seconds(duration: int) -> str: + """Write a duration as the ``MM:SS`` text a protocol file stores. + + Args: + duration: Duration in seconds. + + Returns: + The field text. + """ + return f"{duration // 60:02d}:{duration % 60:02d}" + + +def parse_minutes_seconds(field: str) -> int: + """Read a ``MM:SS`` field. + + Args: + field: The field text. + + Returns: + The duration in seconds. + """ + minutes, _, seconds = field.strip().partition(":") + return int(minutes) * 60 + int(seconds) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/groups.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/groups.py new file mode 100644 index 00000000000..b6e44a598b8 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/groups.py @@ -0,0 +1,480 @@ +"""Groups of parameters that recur across step types. + +Each class here covers one group of fields several step types carry, so a decision like the order +offsets are stored in is made once. A group that every step writes the same way knows its own +definition text; the two that different steps write different subsets of leave that to the step. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_head import CassetteHead +from pylabrobot.agilent.biotek.lhc.enums.steps.secondary_aspirate_pattern import ( + SecondaryAspiratePattern, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.durations import ( + format_hours_minutes, + format_minutes_seconds, + parse_hours_minutes, + parse_minutes_seconds, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_SHAKE_AXIS_TO_FIELD: dict[ShakeAxis, str] = {"X": "X-axis", "Y": "Y-axis"} +"""How a protocol file spells each shake axis.""" + +_SHAKE_INTENSITY_TO_FIELD: dict[ShakeIntensity, str] = { + "Variable": "Variable", + "Slow": "Slow (3.5 Hz)", + "Medium": "Medium (5 Hz)", + "Fast": "Fast (8 Hz)", +} +"""How a protocol file spells each shake intensity.""" + +_FIELD_TO_SHAKE_INTENSITY: dict[str, ShakeIntensity] = { + "variable": "Variable", + "slow": "Slow", + "medium": "Medium", + "fast": "Fast", +} +"""The first word of an intensity field, which is all that identifies it.""" + +_CASSETTE_HEAD_TO_FIELD: dict[CassetteHead, str] = { + "8 tubes to 8 wells": "0", + "8 tubes to 1 well": "1", + "8 tubes to 1 chute": "2", + "1 tube to 1 well": "3", +} +"""How a protocol file spells each cassette head. No head is stored as 255.""" + +_FIELD_TO_CASSETTE_HEAD: dict[str, CassetteHead] = { + value: key for key, value in _CASSETTE_HEAD_TO_FIELD.items() +} + +_SECONDARY_ASPIRATE_PATTERNS: dict[str, SecondaryAspiratePattern] = { + "None": "None", + "Point": "Point", + "Circle": "Circle", + "Square": "Square", +} + + +@dataclass +class PreDispense: + """A small volume dispensed to waste before the step proper, to fill the lines. + + Which of these a step encodes differs per step type, so the step writes the fields it uses and + ignores the rest. + + Attributes: + enabled: Whether to pre-dispense at all. + volume: Volume per tube in µL. + flow_rate: Flow rate to pre-dispense at. + count: How many pre-dispenses to perform. + """ + + enabled: bool = False + volume: int = 50 + flow_rate: int = 9 + count: int = 2 + + @property + def wire_volume(self) -> int: + """The volume as encoded, which is zero when pre-dispensing is off.""" + return self.volume if self.enabled else 0 + + +@dataclass +class SecondaryAspirate: + """A second aspirate at its own position, to clear what the first one leaves behind. + + Attributes: + pattern: The path the tip traces, or ``"None"`` to skip the secondary aspirate. + positioning: Where the secondary aspirate works. + """ + + pattern: SecondaryAspiratePattern = "None" + positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + + @property + def enabled(self) -> bool: + """Whether a secondary aspirate runs.""" + return self.pattern != "None" + + def to_definition(self) -> str: + """The four fields a protocol file stores. + + Returns: + The fields, ``|``-separated. + """ + return f"{self.pattern}|{self.positioning.to_definition()}" + + @classmethod + def from_definition(cls, pattern: str, z: str, x: str, y: str) -> SecondaryAspirate: + """Read the four fields back. + + Args: + pattern: The pattern field. + z: The depth field. + x: The across-plate field. + y: The along-plate field. + + Returns: + The secondary aspirate. + + Raises: + ValueError: If the pattern is not one of the four. + """ + if pattern not in _SECONDARY_ASPIRATE_PATTERNS: + raise ValueError(f"unknown secondary aspirate pattern: {pattern!r}") + return cls( + pattern=_SECONDARY_ASPIRATE_PATTERNS[pattern], + positioning=Positioning.from_definition(z, x, y), + ) + + +@dataclass +class VacuumDelay: + """Holding the vacuum off until a dispense has put a given volume in the well. + + Attributes: + enabled: Whether to delay the vacuum. + volume: Volume per well in µL to dispense first. + """ + + enabled: bool = False + volume: int = 0 + + @property + def wire_volume(self) -> int: + """The volume as encoded, which is zero when the delay is off.""" + return self.volume if self.enabled else 0 + + def to_definition(self) -> str: + """The two fields a protocol file stores. + + Returns: + The fields, ``|``-separated. + """ + return f"{self.enabled}|{self.volume}" + + @classmethod + def from_definition(cls, enabled: str, volume: str) -> VacuumDelay: + """Read the two fields back. + + Args: + enabled: The flag field. + volume: The volume field. + + Returns: + The vacuum delay. + """ + return cls(enabled=definition.flag(enabled), volume=definition.number(volume, 16)) + + +@dataclass +class Submerge: + """Leaving the tips standing in fluid once a prime has finished. + + Attributes: + enabled: Whether to submerge. + duration: How long to stay submerged, in seconds. The instrument runs this at whole-minute + resolution. + """ + + enabled: bool = False + duration: int = 300 + + @property + def wire_minutes(self) -> int: + """The duration as encoded, in minutes, and zero when submerging is off.""" + return self.duration // 60 if self.enabled else 0 + + def to_definition(self) -> str: + """The two fields a protocol file stores. + + Returns: + The fields, ``|``-separated. + """ + return f"{self.enabled}|{format_hours_minutes(self.duration)}" + + @classmethod + def from_definition(cls, enabled: str, duration: str) -> Submerge: + """Read the two fields back. + + Args: + enabled: The flag field. + duration: The duration field. + + Returns: + The submerge settings. + """ + return cls(enabled=definition.flag(enabled), duration=parse_hours_minutes(duration)) + + +@dataclass +class Shake: + """Shaking the carrier. + + Attributes: + enabled: Whether to shake. + duration: How long to shake, in seconds. + axis: Which axis to shake along. + intensity: How vigorously to shake. + """ + + enabled: bool = False + duration: int = 5 + axis: ShakeAxis = "X" + intensity: ShakeIntensity = "Medium" + + @property + def wire_duration(self) -> int: + """The duration as encoded, in seconds, and zero when shaking is off.""" + return self.duration if self.enabled else 0 + + def to_definition(self) -> str: + """The four fields a protocol file stores. + + Returns: + The fields, ``|``-separated. + """ + return ( + f"{self.enabled}|{format_minutes_seconds(self.duration)}" + f"|{_SHAKE_AXIS_TO_FIELD[self.axis]}|{_SHAKE_INTENSITY_TO_FIELD[self.intensity]}" + ) + + @classmethod + def from_definition(cls, enabled: str, duration: str, axis: str, intensity: str) -> Shake: + """Read the four fields back. + + An intensity is identified by its first word, and an unrecognised one reads as ``"Medium"``, + which is the intensity the instrument shakes at when given one. + + Args: + enabled: The flag field. + duration: The duration field. + axis: The axis field. + intensity: The intensity field. + + Returns: + The shake settings. + """ + return cls( + enabled=definition.flag(enabled), + duration=parse_minutes_seconds(duration), + axis="Y" if axis.strip().upper().startswith("Y") else "X", + intensity=_FIELD_TO_SHAKE_INTENSITY.get(intensity.strip().split(" ")[0].lower(), "Medium"), + ) + + +@dataclass +class Soak: + """Standing still for a while. + + Attributes: + enabled: Whether to soak. + duration: How long to soak, in seconds. + """ + + enabled: bool = False + duration: int = 30 + + @property + def wire_duration(self) -> int: + """The duration as encoded, in seconds, and zero when soaking is off.""" + return self.duration if self.enabled else 0 + + def to_definition(self) -> str: + """The two fields a protocol file stores. + + Returns: + The fields, ``|``-separated. + """ + return f"{self.enabled}|{format_minutes_seconds(self.duration)}" + + @classmethod + def from_definition(cls, enabled: str, duration: str) -> Soak: + """Read the two fields back. + + Args: + enabled: The flag field. + duration: The duration field. + + Returns: + The soak settings. + """ + return cls(enabled=definition.flag(enabled), duration=parse_minutes_seconds(duration)) + + +@dataclass +class RandomAccess: + """Dispensing into individually chosen wells rather than the whole plate. + + Attributes: + enabled: Whether the step dispenses at random access. + cassette_head: Which cassette head is fitted, or None when the step names no head. + """ + + enabled: bool = False + cassette_head: CassetteHead | None = None + + def to_definition(self) -> str: + """The two fields a protocol file stores, present only on a random-access step. + + Returns: + The fields, ``|``-separated. + """ + head = "255" if self.cassette_head is None else _CASSETTE_HEAD_TO_FIELD[self.cassette_head] + return f"{self.enabled}|{head}" + + @classmethod + def from_definition(cls, enabled: str, cassette_head: str) -> RandomAccess: + """Read the two fields back. + + Args: + enabled: The flag field. + cassette_head: The head field. + + Returns: + The random-access settings. + """ + return cls( + enabled=definition.flag(enabled), + cassette_head=_FIELD_TO_CASSETTE_HEAD.get(cassette_head.strip()), + ) + + +@dataclass +class WellVolumeMap: + """Per-well volumes for a random-access dispense, three tubes deep for each of sixteen wells. + + Attributes: + values: Sixteen rows of three values each. + """ + + values: list[list[int]] = field(default_factory=lambda: [[0xFF] * 3 for _ in range(16)]) + + def to_definition(self) -> str: + """The single field a protocol file stores, two upper-case hex digits per value. + + Returns: + The field text. + """ + return "".join(f"{value:02X}" for row in self.values for value in row) + + @classmethod + def from_definition(cls, field_text: str) -> WellVolumeMap: + """Read the field back. + + Args: + field_text: Two hex digits per value. + + Returns: + The map. + """ + values = [int(field_text[at : at + 2], 16) for at in range(0, len(field_text), 2)] + return cls([values[row * 3 : row * 3 + 3] for row in range(len(values) // 3)]) + + +@dataclass +class Sectors: + """Which quarters of the plate a wash covers. + + Attributes: + selected: One flag per sector, in sector order. + """ + + selected: list[bool] = field(default_factory=lambda: [True] * 4) + + @property + def value(self) -> int: + """The selection as a bit per sector, the first sector being the least significant bit.""" + return sum(1 << index for index, on in enumerate(self.selected) if on) + + def to_definition(self) -> str: + """The single field a protocol file stores. + + Returns: + The field text. + """ + return str(self.value) + + @classmethod + def from_definition(cls, field_text: str) -> Sectors: + """Read the field back, which holds a bit per sector rather than a count. + + Args: + field_text: The field text. + + Returns: + The sector selection. + """ + mask = definition.number(field_text, 16) + return cls([bool(mask & (1 << index)) for index in range(4)]) + + +@dataclass +class WashStages: + """Which optional stages of a wash run. + + Each flag says whether its stage runs. The stages themselves are always stored, whether they run + or not. + + Attributes: + pre_dispense_before: Pre-dispense before the wash starts. + bottom_wash: Wash the bottom of the well. Not available on a 1536-well wash. + pre_dispense_between: Pre-dispense between wash cycles. + final_aspirate: Aspirate once more after the last cycle. + shake_soak_after_dispense: Shake or soak after each dispense. + """ + + pre_dispense_before: bool = False + bottom_wash: bool = False + pre_dispense_between: bool = False + final_aspirate: bool = True + shake_soak_after_dispense: bool = False + + def to_definition(self) -> str: + """The five fields a protocol file stores, for the wash types that store them together. + + Returns: + The fields, ``|``-separated. + """ + return ( + f"{self.pre_dispense_before}|{self.bottom_wash}|{self.pre_dispense_between}" + f"|{self.final_aspirate}|{self.shake_soak_after_dispense}" + ) + + @classmethod + def from_definition( + cls, + pre_dispense_before: str, + bottom_wash: str, + pre_dispense_between: str, + final_aspirate: str, + shake_soak_after_dispense: str, + ) -> WashStages: + """Read the five fields back. + + Args: + pre_dispense_before: The flag field. + bottom_wash: The flag field. + pre_dispense_between: The flag field. + final_aspirate: The flag field. + shake_soak_after_dispense: The flag field. + + Returns: + The stage selection. + """ + return cls( + pre_dispense_before=definition.flag(pre_dispense_before), + bottom_wash=definition.flag(bottom_wash), + pre_dispense_between=definition.flag(pre_dispense_between), + final_aspirate=definition.flag(final_aspirate), + shake_soak_after_dispense=definition.flag(shake_soak_after_dispense), + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/masks.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/masks.py new file mode 100644 index 00000000000..deec0072ba3 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/masks.py @@ -0,0 +1,114 @@ +"""Selecting which columns or rows of a plate a step works on.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +COLUMNS = 48 +"""How many entries a column selection always has, whatever the plate holds.""" + +ROWS = 4 +"""How many entries a row selection always has.""" + + +@dataclass +class WellMask: + """A column or row selection, one entry per position, stored as a run of digits. + + A column selection with nothing selected is accepted and simply washes nothing; a row selection + with nothing selected is rejected by validation. Two step types encode a row selection inverted, + so a selected row contributes a zero bit; that is a property of those steps, not of the mask, + which is why both packings are offered here. + + Attributes: + values: One entry per position, 1 for selected and 0 for not. + """ + + values: list[int] = field(default_factory=lambda: [1] * COLUMNS) + + def to_definition(self) -> str: + """The single field a protocol file stores, one digit per entry. + + Returns: + The field text. + """ + return "".join(str(value) for value in self.values) + + @classmethod + def from_definition(cls, field_text: str) -> WellMask: + """Read the field back. + + Args: + field_text: One digit per entry. + + Returns: + The selection. + + Raises: + ValueError: If a character is not a digit. + """ + return cls([int(digit) for digit in field_text]) + + @classmethod + def all_columns(cls) -> WellMask: + """Every column selected. + + Returns: + The selection. + """ + return cls([1] * COLUMNS) + + @classmethod + def all_rows(cls) -> WellMask: + """Every row selected. + + Returns: + The selection. + """ + return cls([1] * ROWS) + + @property + def is_valid(self) -> bool: + """Whether every entry is 0 or 1.""" + return all(value in (0, 1) for value in self.values) + + @property + def selected_count(self) -> int: + """How many entries are selected.""" + return sum(1 for value in self.values if value) + + def to_bits(self) -> int: + """Pack a column selection into the 12 bits the aspirate steps send. + + The 48 entries are six blocks of eight, of which only the first two of each block are + distinct: bit ``i`` comes from entry ``(i // 2) * 8 + i % 2``. The remaining entries are + copies and never reach the instrument. + + Returns: + The packed bits. + """ + return sum(1 << i for i in range(12) if self.values[(i // 2) * 8 + i % 2]) + + def to_bytes(self) -> bytes: + """Pack the selection one bit per entry, least significant bit first. + + Returns: + The packed bytes, one per eight entries. + """ + packed = bytearray((len(self.values) + 7) // 8) + for index, value in enumerate(self.values): + if value: + packed[index // 8] |= 1 << (index % 8) + return bytes(packed) + + def to_bytes_inverted(self) -> bytes: + """Pack the selection with every bit flipped, as the two dispensers encode their rows. + + Returns: + The packed bytes, in which a selected entry contributes a zero bit. + """ + packed = bytearray((len(self.values) + 7) // 8) + for index, value in enumerate(self.values): + if not value: + packed[index // 8] |= 1 << (index % 8) + return bytes(packed) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/positioning.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/positioning.py new file mode 100644 index 00000000000..7c7b81d818f --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/positioning.py @@ -0,0 +1,48 @@ +"""Where in the well a step works.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Positioning: + """An X, Y and Z offset from the nominal position for the plate in use. + + Values are in the instrument's own motor steps, positive Z being further down into the well. + What range each axis accepts depends on the step and on the plate, and is checked by validation + rather than here. + + Attributes: + z: Depth offset. + x: Offset across the plate. + y: Offset along the plate. + """ + + z: int = 0 + x: int = 0 + y: int = 0 + + def to_definition(self) -> str: + """The three fields a protocol file stores, which are ordered Z, X, Y. + + Returns: + The fields, ``|``-separated. + """ + return f"{self.z}|{self.x}|{self.y}" + + @classmethod + def from_definition(cls, z: str, x: str, y: str) -> Positioning: + """Read the three fields back. + + Widths differ per step, so the range check belongs to the step that owns these fields. + + Args: + z: The depth field. + x: The across-plate field. + y: The along-plate field. + + Returns: + The offsets. + """ + return cls(z=int(z), x=int(x), y=int(y)) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/rates.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/rates.py new file mode 100644 index 00000000000..bd952d2cac0 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/rates.py @@ -0,0 +1,23 @@ +"""The numeric rate scales a step selects from. + +The word-valued rates -- travel rate and peristaltic flow rate -- are vocabulary and live in +``enums.steps``. What is here is the scales that are plain numbers. +""" + +from __future__ import annotations + +FLOW_RATES = range(1, 12) +"""The flow rates the washer and syringe steps accept, fastest last.""" + +CELL_WASHING_FLOW_RATES = (1, 2) +"""The flow rates below the normal minimum, available only with the cell washing module fitted.""" + +PERI_WASH_DISPENSE_RATES = (10, 15, 20, 25, 30, 120, 140, 160) +"""Dispense rate in µL/s for each peristaltic wash dispense rate, in selection order.""" + +PERI_WASH_ASPIRATE_RATES = (10, 15, 20, 25, 50) +"""Aspirate rate in µL/s for each peristaltic wash aspirate rate, in selection order. + +The scales are not prefixes of one another: the last aspirate rate is 50 µL/s where the dispense +scale reaches 30 at the same position. +""" diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/__init__.py new file mode 100644 index 00000000000..8c84f4fdf06 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/__init__.py @@ -0,0 +1,73 @@ +"""One module per step type. + +Every class here is a step, named for the operation it performs. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_auto_clean import ( + ManifoldAutoClean, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_wash import ManifoldWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_purge import PeriPurge +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_aspirate import PeriWashAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_dispense import PeriWashDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_aspirate import StripAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_dispense import StripDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_prime import StripPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_wash import StripWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_prime import SyringePrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.wash_1536 import Wash1536 + +STEP_CLASSES: dict[StepType, type[Step]] = { + StepType.PERI_DISPENSE: PeriDispense, + StepType.PERI_PRIME: PeriPrime, + StepType.PERI_PURGE: PeriPurge, + StepType.SYRINGE_DISPENSE: SyringeDispense, + StepType.SYRINGE_PRIME: SyringePrime, + StepType.MANIFOLD_WASH: ManifoldWash, + StepType.MANIFOLD_ASPIRATE: ManifoldAspirate, + StepType.MANIFOLD_DISPENSE: ManifoldDispense, + StepType.MANIFOLD_PRIME: ManifoldPrime, + StepType.MANIFOLD_AUTO_CLEAN: ManifoldAutoClean, + StepType.SHAKE_SOAK: ShakeSoak, + StepType.WASH_1536: Wash1536, + StepType.STRIP_WASH: StripWash, + StepType.STRIP_ASPIRATE: StripAspirate, + StepType.STRIP_DISPENSE: StripDispense, + StepType.STRIP_PRIME: StripPrime, + StepType.PERI_WASH_ASPIRATE: PeriWashAspirate, + StepType.PERI_WASH_DISPENSE: PeriWashDispense, +} +"""Which class implements each step type.""" + +__all__ = [ + "STEP_CLASSES", + "ManifoldAspirate", + "ManifoldAutoClean", + "ManifoldDispense", + "ManifoldPrime", + "ManifoldWash", + "PeriDispense", + "PeriPrime", + "PeriPurge", + "PeriWashAspirate", + "PeriWashDispense", + "ShakeSoak", + "StripAspirate", + "StripDispense", + "StripPrime", + "StripWash", + "SyringeDispense", + "SyringePrime", + "Wash1536", +] diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py new file mode 100644 index 00000000000..f8f6b625283 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py @@ -0,0 +1,146 @@ +"""Aspirating through the wash manifold.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.secondary_aspirate_pattern import ( + SECONDARY_ASPIRATE_PATTERN_TO_BYTE, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TRAVEL_RATE_TO_BYTE, TravelRate +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import SecondaryAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_PAYLOAD_LENGTH = 21 +_DEFINITION_FIELDS = 12 + + +@dataclass +class ManifoldAspirate(Step): + """Draw the wells empty through the wash manifold. + + Attributes: + vacuum_filtration: Whether to pull the wells through a filter plate instead of aspirating + from above. Not available on a 1536-well plate. + travel_rate: How fast the tips descend into the well. + delay: How long to keep aspirating once the tips are down, in ms. Under vacuum filtration this + is the filtration time in seconds instead. + positioning: Where in the well to aspirate. + secondary: Whether to aspirate a second time, in what pattern and where. + radius: A field the protocol file carries but the instrument is never sent; the payload holds + zero in its place. + columns: Which columns to aspirate. Only sent by a step that stands on its own. + in_wash: Whether the step belongs to a wash, which selects wells itself. Such a step stores + and sends no column selection. + """ + + step_type: ClassVar[StepType] = StepType.MANIFOLD_ASPIRATE + + vacuum_filtration: bool = False + travel_rate: TravelRate = "3" + delay: int = 0 + positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + secondary: SecondaryAspirate = field(default_factory=SecondaryAspirate) + radius: str = "0" + columns: WellMask = field(default_factory=WellMask.all_columns) + in_wash: bool = False + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition, with the column selection only when the step stands alone. + """ + text = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.vacuum_filtration}" + f"|{self.travel_rate}|{self.delay}|{self.positioning.to_definition()}" + f"|{self.secondary.to_definition()}|{self.radius}" + ) + if not self.in_wash: + text += f"|{self.columns.to_definition()}" + return text + + @classmethod + def from_definition(cls, text: str) -> ManifoldAspirate: + """Read the step back from its definition text. + + Whether the column selection is present is what says whether the step belongs to a wash. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a travel rate + that does not exist. + """ + own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS) + ( + vacuum, + travel_rate, + delay, + z, + x, + y, + pattern, + secondary_z, + secondary_x, + secondary_y, + radius, + ) = own[:11] + columns = own[11:] + if travel_rate not in definition.TRAVEL_RATES: + raise ValueError(f"unknown travel rate: {travel_rate!r}") + return cls( + vacuum_filtration=definition.flag(vacuum), + travel_rate=definition.TRAVEL_RATES[travel_rate], + delay=definition.number(delay, 16), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 8), y=definition.signed(y, 8) + ), + secondary=SecondaryAspirate.from_definition(pattern, secondary_z, secondary_x, secondary_y), + radius=radius, + columns=WellMask.from_definition(columns[0]) if columns else WellMask.all_columns(), + in_wash=not columns, + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + Both offset groups are sent X, Y, Z, the reverse of the order they are stored in. A step + belonging to a wash sends no columns. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + columns = 0 if self.in_wash else self.columns.to_bits() + return pad( + u8(1 if self.vacuum_filtration else 0) + + u16(self.delay) + + u8(TRAVEL_RATE_TO_BYTE[self.travel_rate]) + + i8(self.positioning.x) + + i8(self.positioning.y) + + i16(self.positioning.z) + + u8(SECONDARY_ASPIRATE_PATTERN_TO_BYTE[self.secondary.pattern]) + + i8(self.secondary.positioning.x) + + i8(self.secondary.positioning.y) + + i16(self.secondary.positioning.z) + + u16(0) + + u16(columns), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py new file mode 100644 index 00000000000..839b6d3c334 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py @@ -0,0 +1,79 @@ +"""Running the manifold's automatic cleaning cycle.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.durations import ( + format_hours_minutes, + parse_hours_minutes, +) + +_PAYLOAD_LENGTH = 7 +_DEFINITION_FIELDS = 3 + + +@dataclass +class ManifoldAutoClean(Step): + """Soak the manifold in cleaning fluid for a set time. + + Attributes: + buffer: Which buffer inlet the cleaning fluid comes from. + duration: How long to clean, in seconds. The instrument runs this at whole-minute resolution. + """ + + step_type: ClassVar[StepType] = StepType.MANIFOLD_AUTO_CLEAN + + buffer: Buffer = "A" + duration: int = 3600 + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + return ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.buffer}" + f"|{format_hours_minutes(self.duration)}" + ) + + @classmethod + def from_definition(cls, text: str) -> ManifoldAutoClean: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a buffer that + does not exist. + """ + buffer, duration = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + if buffer not in definition.BUFFERS: + raise ValueError(f"unknown buffer: {buffer!r}") + return cls(buffer=definition.BUFFERS[buffer], duration=parse_hours_minutes(duration)) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + return pad(u8(ord(self.buffer)) + u16(self.duration // 60), _PAYLOAD_LENGTH) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py new file mode 100644 index 00000000000..7f279a62ac6 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py @@ -0,0 +1,139 @@ +"""Dispensing through the wash manifold.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( + PreDispense, + VacuumDelay, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_PAYLOAD_LENGTH = 19 +_DEFINITION_FIELDS = 12 + + +@dataclass +class ManifoldDispense(Step): + """Dispense a volume into every selected well through the wash manifold. + + Attributes: + buffer: Which buffer inlet to draw from. + volume: Volume per well in µL. + flow_rate: How fast to dispense. The two slowest rates need the cell washing module and a + 96-tube dual-action manifold. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, and at what volume and rate. + vacuum: Whether to hold the vacuum off until a volume has been dispensed. + check_buffer: Whether validation checks the buffer. A wash clears this on the dispense it + owns, which draws from the wash's own inlet. + check_volume: Whether validation checks the volume and everything measured with it. A wash + clears this on a bottom-wash dispense, whose volume only matters when that stage runs. + """ + + step_type: ClassVar[StepType] = StepType.MANIFOLD_DISPENSE + + buffer: Buffer = "A" + volume: int = 0 + flow_rate: int = 7 + positioning: Positioning = field(default_factory=lambda: Positioning(z=120)) + pre_dispense: PreDispense = field(default_factory=lambda: PreDispense(volume=0, flow_rate=9)) + vacuum: VacuumDelay = field(default_factory=VacuumDelay) + check_buffer: bool = True + check_volume: bool = True + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + The pre-dispense count is not stored by this step type, and neither validation flag is stored + at all: a step read back from a protocol file checks both. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + return ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.buffer}|{self.volume}" + f"|{self.flow_rate}|{self.positioning.to_definition()}|{self.pre_dispense.enabled}" + f"|{self.pre_dispense.volume}|{self.pre_dispense.flow_rate}" + f"|{self.vacuum.to_definition()}" + ) + + @classmethod + def from_definition(cls, text: str) -> ManifoldDispense: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a buffer that + does not exist. + """ + ( + buffer, + volume, + flow_rate, + z, + x, + y, + pre_enabled, + pre_volume, + pre_flow_rate, + vacuum_enabled, + vacuum_volume, + ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + if buffer not in definition.BUFFERS: + raise ValueError(f"unknown buffer: {buffer!r}") + return cls( + buffer=definition.BUFFERS[buffer], + volume=definition.number(volume, 16), + flow_rate=definition.number(flow_rate, 8), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 8), y=definition.signed(y, 8) + ), + pre_dispense=PreDispense( + enabled=definition.flag(pre_enabled), + volume=definition.number(pre_volume, 16), + flow_rate=definition.number(pre_flow_rate, 8), + ), + vacuum=VacuumDelay.from_definition(vacuum_enabled, vacuum_volume), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + The offsets are sent X, Y, Z, the reverse of the order they are stored in, with X and Y one + byte each and Z two. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + return pad( + u8(ord(self.buffer)) + + u16(self.volume) + + u8(self.flow_rate) + + i8(self.positioning.x) + + i8(self.positioning.y) + + i16(self.positioning.z) + + u16(self.pre_dispense.wire_volume) + + u8(self.pre_dispense.flow_rate) + + u16(self.vacuum.wire_volume), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py new file mode 100644 index 00000000000..32d527463c3 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py @@ -0,0 +1,111 @@ +"""Priming the wash manifold.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Submerge + +_PAYLOAD_LENGTH = 12 +_DEFINITION_FIELDS = 8 + + +@dataclass +class ManifoldPrime(Step): + """Pump fluid through the wash manifold until the lines are full. + + Attributes: + buffer: Which buffer inlet to draw from. + volume: Volume to pump in µL. Stored at millilitre resolution. + flow_rate: How fast to pump. + prime_low_flow_path: Whether to prime the low flow path as well. + low_flow_path_volume: Volume to pump through the low flow path in µL, when it is primed. + Stored at millilitre resolution. + submerge: Whether to leave the tips in fluid afterwards, and for how long. + """ + + step_type: ClassVar[StepType] = StepType.MANIFOLD_PRIME + + buffer: Buffer = "A" + volume: int = 40_000 + flow_rate: int = 9 + prime_low_flow_path: bool = True + low_flow_path_volume: int = 5_000 + submerge: Submerge = field(default_factory=Submerge) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + return ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.buffer}|{self.volume // 1000}" + f"|{self.flow_rate}|{self.prime_low_flow_path}|{self.low_flow_path_volume // 1000}" + f"|{self.submerge.to_definition()}" + ) + + @classmethod + def from_definition(cls, text: str) -> ManifoldPrime: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout. + """ + ( + buffer, + volume, + flow_rate, + low_flow, + low_flow_volume, + submerge, + duration, + ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + if buffer not in definition.BUFFERS: + raise ValueError(f"unknown buffer: {buffer!r}") + return cls( + buffer=definition.BUFFERS[buffer], + volume=definition.number(volume, 16) * 1000, + flow_rate=definition.number(flow_rate, 8), + prime_low_flow_path=definition.flag(low_flow), + low_flow_path_volume=definition.number(low_flow_volume, 16) * 1000, + submerge=Submerge.from_definition(submerge, duration), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + Both optional volumes are sent as zero when they are switched off rather than left out, so the + payload has one layout. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + low_flow_volume = self.low_flow_path_volume if self.prime_low_flow_path else 0 + return pad( + u8(ord(self.buffer)) + + u16(self.volume // 1000) + + u8(self.flow_rate) + + u16(low_flow_volume // 1000) + + u16(self.submerge.wire_minutes), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py new file mode 100644 index 00000000000..3d6f4b7ea08 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py @@ -0,0 +1,162 @@ +"""Washing through the wash manifold.""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WASH_FORMAT_TO_BYTE, WashFormat +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Sectors, WashStages +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak + +_PAYLOAD_LENGTH = 101 +_DEFINITION_FIELDS = 9 +_DEFINITION_PARTS = 6 +_PART_SEPARATOR = "#" + + +@dataclass +class ManifoldWash(Step): + """Wash a plate: dispense, aspirate and the optional stages around them, repeated. + + A wash carries five complete steps of its own, each stored as a full definition of its own type. + Two of them do double duty and are named for what they are rather than for the stage that + switches them on: the bottom wash carries the pre-dispense used before washing starts, and the + dispense carries the one used between cycles. + + Attributes: + wash_format: Whether to wash the whole plate, selected sectors or selected strips. + sectors: Which sectors to wash, when the format selects sectors. + cycles: How many wash cycles to run. + stages: Which optional stages run. + bottom_wash: The dispense that washes the bottom of the well. + aspirate: The aspirate that empties the well at the start of each cycle. + dispense: The dispense that refills the well. + shake_soak: The pause after each dispense. + final_aspirate: The aspirate that empties the well after the last cycle. + """ + + step_type: ClassVar[StepType] = StepType.MANIFOLD_WASH + + wash_format: WashFormat = "Plate" + sectors: Sectors = field(default_factory=Sectors) + cycles: int = 3 + stages: WashStages = field(default_factory=WashStages) + bottom_wash: ManifoldDispense = field(default_factory=ManifoldDispense) + aspirate: ManifoldAspirate = field(default_factory=lambda: ManifoldAspirate(in_wash=True)) + dispense: ManifoldDispense = field(default_factory=ManifoldDispense) + shake_soak: ShakeSoak = field(default_factory=ShakeSoak) + final_aspirate: ManifoldAspirate = field(default_factory=lambda: ManifoldAspirate(in_wash=True)) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + The wash's own fields come first, then its five steps, all joined by ``#``. + + Args: + settings: What the instrument has fitted. + + Returns: + The definition. + """ + head = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.wash_format}" + f"|{self.sectors.to_definition()}|{self.cycles}|{self.stages.to_definition()}" + ) + return _PART_SEPARATOR.join( + [ + head, + self.bottom_wash.to_definition(settings), + self.aspirate.to_definition(settings), + self.dispense.to_definition(settings), + self.shake_soak.to_definition(settings), + self.final_aspirate.to_definition(settings), + ] + ) + + @classmethod + def from_definition(cls, text: str) -> ManifoldWash: + """Read the step back from its definition text. + + Args: + text: The definition, its six parts joined by ``#``. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have six parts, or a part does not have its step + type's layout, or the format is not one of the three. + """ + parts = [part for part in text.split(_PART_SEPARATOR) if part] + if len(parts) != _DEFINITION_PARTS: + raise ValueError(f"{cls.step_type.name} expects {_DEFINITION_PARTS} parts, got {len(parts)}") + head, bottom_wash, aspirate, dispense, shake_soak, final_aspirate = parts + wash_format, sectors, cycles, *stages = definition.own_fields( + head, cls.step_type, _DEFINITION_FIELDS + ) + if wash_format not in definition.WASH_FORMATS: + raise ValueError(f"unknown wash format: {wash_format!r}") + return cls( + wash_format=definition.WASH_FORMATS[wash_format], + sectors=Sectors.from_definition(sectors), + cycles=definition.number(cycles, 8), + stages=WashStages.from_definition(*stages), + bottom_wash=ManifoldDispense.from_definition(bottom_wash), + aspirate=ManifoldAspirate.from_definition(aspirate), + dispense=ManifoldDispense.from_definition(dispense), + shake_soak=ShakeSoak.from_definition(shake_soak), + final_aspirate=ManifoldAspirate.from_definition(final_aspirate), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + The five steps are sent in a different order from the one they are stored in: the final + aspirate comes second here and last there. Three of the wash's stage flags reach into its + steps, switching off a pre-dispense or the pause however those steps are configured. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + bottom_wash = dataclasses.replace( + self.bottom_wash, + pre_dispense=dataclasses.replace( + self.bottom_wash.pre_dispense, + enabled=self.bottom_wash.pre_dispense.enabled and self.stages.pre_dispense_before, + ), + ) + dispense = dataclasses.replace( + self.dispense, + pre_dispense=dataclasses.replace( + self.dispense.pre_dispense, + enabled=self.dispense.pre_dispense.enabled and self.stages.pre_dispense_between, + ), + ) + aspirate = dataclasses.replace(self.aspirate, in_wash=True) + final_aspirate = dataclasses.replace(self.final_aspirate, in_wash=True) + shake_soak = dataclasses.replace(self.shake_soak, enabled=self.stages.shake_soak_after_dispense) + return pad( + u8(1 if self.stages.bottom_wash else 0) + + u8(1 if self.stages.final_aspirate else 0) + + u8(WASH_FORMAT_TO_BYTE[self.wash_format]) + + u16(self.sectors.value) + + u8(self.cycles) + + bottom_wash.to_bytes(settings) + + final_aspirate.to_bytes(settings) + + aspirate.to_bytes(settings) + + dispense.to_bytes(settings) + + shake_soak.to_bytes(settings), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py new file mode 100644 index 00000000000..264320a837f --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py @@ -0,0 +1,210 @@ +"""Dispensing through a peristaltic pump.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import ( + CASSETTE_TYPE_TO_BYTE, + CassetteType, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import ( + PERI_FLOW_RATE_TO_BYTE, + PeriFlowRate, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE, PeriPump +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( + PreDispense, + RandomAccess, + WellVolumeMap, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_PAYLOAD_LENGTH = 23 +_RANDOM_ACCESS_PAYLOAD_LENGTH = 66 +_DEFINITION_FIELDS = 13 + +_NO_CASSETTE_REQUIREMENT = 255 +_NO_PUMP = 0 + +_BYTE_TO_CASSETTE_TYPE: dict[int, CassetteType] = { + value: key for key, value in CASSETTE_TYPE_TO_BYTE.items() +} +_BYTE_TO_PERI_PUMP: dict[int, PeriPump] = {value: key for key, value in PERI_PUMP_TO_BYTE.items()} +_PERI_FLOW_RATES: dict[str, PeriFlowRate] = {"Low": "Low", "Medium": "Medium", "High": "High"} + + +@dataclass +class PeriDispense(Step): + """Dispense a volume into every selected well from a peristaltic pump. + + Attributes: + volume: Volume per tube in µL. + flow_rate: How fast to dispense. + cassette_type: The cassette the step requires, or None to accept whatever is fitted. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, at what volume and how many times. + columns: Which columns to dispense into. + rows: Which rows to dispense into. + peri_pump: Which pump to drive, or None to leave the choice to the instrument. + random_access: Whether the step dispenses at random access, and with which head. + well_volumes: Per-well volumes, sent instead of the selections on a random-access dispense. + """ + + step_type: ClassVar[StepType] = StepType.PERI_DISPENSE + + volume: int = 10 + flow_rate: PeriFlowRate = "High" + cassette_type: CassetteType | None = "Any" + positioning: Positioning = field(default_factory=lambda: Positioning(z=336)) + pre_dispense: PreDispense = field( + default_factory=lambda: PreDispense(enabled=True, volume=10, count=2) + ) + columns: WellMask = field(default_factory=WellMask.all_columns) + rows: WellMask = field(default_factory=WellMask.all_rows) + peri_pump: PeriPump | None = "Primary" + random_access: RandomAccess = field(default_factory=RandomAccess) + well_volumes: WellVolumeMap = field(default_factory=WellVolumeMap) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + The pre-dispense flow rate is not stored by this step type. The random-access fields are + written only when the step uses random access and the instrument reads them. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + cassette = ( + _NO_CASSETTE_REQUIREMENT + if self.cassette_type is None + else CASSETTE_TYPE_TO_BYTE[self.cassette_type] + ) + pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + text = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.volume}|{self.flow_rate}" + f"|{cassette}|{self.positioning.to_definition()}|{self.pre_dispense.enabled}" + f"|{self.pre_dispense.volume}|{self.pre_dispense.count}" + f"|{self.columns.to_definition()}|{self.rows.to_definition()}|{pump}" + ) + if self.random_access.enabled and settings.supports_random_access_tail: + text += f"|{self.random_access.to_definition()}|{self.well_volumes.to_definition()}" + return text + + @classmethod + def from_definition(cls, text: str) -> PeriDispense: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a flow rate, + cassette or pump that does not exist. + """ + own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS) + ( + volume, + flow_rate, + cassette, + z, + x, + y, + pre_enabled, + pre_volume, + pre_count, + columns, + rows, + pump, + ) = own[:12] + tail = own[12:] + if flow_rate not in _PERI_FLOW_RATES: + raise ValueError(f"unknown peristaltic flow rate: {flow_rate!r}") + if int(cassette) != _NO_CASSETTE_REQUIREMENT and int(cassette) not in _BYTE_TO_CASSETTE_TYPE: + raise ValueError(f"unknown cassette type: {cassette!r}") + if int(pump) != _NO_PUMP and int(pump) not in _BYTE_TO_PERI_PUMP: + raise ValueError(f"unknown peristaltic pump: {pump!r}") + return cls( + volume=definition.number(volume, 16), + flow_rate=_PERI_FLOW_RATES[flow_rate], + cassette_type=_BYTE_TO_CASSETTE_TYPE.get(int(cassette)), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + ), + pre_dispense=PreDispense( + enabled=definition.flag(pre_enabled), + volume=definition.number(pre_volume, 16), + count=definition.number(pre_count, 8), + ), + columns=WellMask.from_definition(columns), + rows=WellMask.from_definition(rows), + peri_pump=_BYTE_TO_PERI_PUMP.get(int(pump)), + random_access=RandomAccess.from_definition(*tail[:2]) if tail else RandomAccess(), + well_volumes=(WellVolumeMap.from_definition(tail[2]) if len(tail) > 2 else WellVolumeMap()), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + There are two payloads. A random-access dispense carries the per-well volumes and no + selections; an ordinary one carries the selections and no volumes, with the row selection + inverted. With the wider dispense offsets fitted the ordinary payload drops the cassette + requirement and spends the two bytes on a wider X offset instead. + + An instrument that cannot store the random-access fields never learns the step uses random + access, so it gets the ordinary payload however the step is configured. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + head = u16(self.volume) + u8(PERI_FLOW_RATE_TO_BYTE[self.flow_rate]) + if self.random_access.enabled and settings.supports_random_access_tail: + return pad( + head + + i16(self.positioning.x) + + i8(self.positioning.y) + + i16(self.positioning.z) + + u16(self.pre_dispense.wire_volume) + + u8(self.pre_dispense.count) + + bytes(value for row in self.well_volumes.values for value in row) + + u8(pump), + _RANDOM_ACCESS_PAYLOAD_LENGTH, + ) + if settings.advanced_dispense_offsets: + offsets = i16(self.positioning.x) + else: + cassette = ( + _NO_CASSETTE_REQUIREMENT + if self.cassette_type is None + else CASSETTE_TYPE_TO_BYTE[self.cassette_type] + ) + offsets = u8(cassette) + i8(self.positioning.x) + return pad( + head + + offsets + + i8(self.positioning.y) + + i16(self.positioning.z) + + u16(self.pre_dispense.wire_volume) + + u8(self.pre_dispense.count) + + self.columns.to_bytes() + + self.rows.to_bytes_inverted() + + u8(pump), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py new file mode 100644 index 00000000000..6048cdff6bd --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py @@ -0,0 +1,151 @@ +"""Priming a peristaltic pump.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import ( + CASSETTE_TYPE_TO_BYTE, + CassetteType, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import ( + PERI_FLOW_RATE_TO_BYTE, + PeriFlowRate, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE, PeriPump +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import RandomAccess + +_PAYLOAD_LENGTH = 10 +_DEFINITION_FIELDS = 8 + +_NO_CASSETTE_REQUIREMENT = 255 +_NO_PUMP = 0 + +_BYTE_TO_CASSETTE_TYPE: dict[int, CassetteType] = { + value: key for key, value in CASSETTE_TYPE_TO_BYTE.items() +} +_BYTE_TO_PERI_PUMP: dict[int, PeriPump] = {value: key for key, value in PERI_PUMP_TO_BYTE.items()} +_PERI_FLOW_RATES: dict[str, PeriFlowRate] = {"Low": "Low", "Medium": "Medium", "High": "High"} + + +@dataclass +class PeriPrime(Step): + """Pump fluid through a peristaltic cassette until its tubing is full. + + Either a volume or a duration drives the step, and both are stored whichever is in use. + + Attributes: + fixed_volume: Whether the step runs to a volume rather than to a duration. + volume: Volume per tube in µL, used when the step runs to a volume. + duration: How long to pump in seconds, used when the step runs to a duration. + flow_rate: How fast to pump. + home_when_finished: Whether the carrier homes after the step. + cassette_type: The cassette the step requires, or None to accept whatever is fitted. + peri_pump: Which pump to drive, or None to leave the choice to the instrument. + random_access: Whether the step dispenses at random access, and with which head. + """ + + step_type: ClassVar[StepType] = StepType.PERI_PRIME + + fixed_volume: bool = True + volume: int = 300 + duration: int = 3 + flow_rate: PeriFlowRate = "High" + home_when_finished: bool = True + cassette_type: CassetteType | None = "Any" + peri_pump: PeriPump | None = "Primary" + random_access: RandomAccess = field(default_factory=RandomAccess) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + The random-access fields are written only when the step uses random access and the + instrument reads them; a model that predates random access cannot parse a definition + carrying them. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + cassette = ( + _NO_CASSETTE_REQUIREMENT + if self.cassette_type is None + else CASSETTE_TYPE_TO_BYTE[self.cassette_type] + ) + pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + text = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.fixed_volume}|{self.volume}" + f"|{self.duration}|{self.flow_rate}|{self.home_when_finished}|{cassette}|{pump}" + ) + if self.random_access.enabled and settings.supports_random_access_tail: + text += f"|{self.random_access.to_definition()}" + return text + + @classmethod + def from_definition(cls, text: str) -> PeriPrime: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a flow rate, + cassette or pump that does not exist. + """ + own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS) + fixed_volume, volume, duration, flow_rate, home, cassette, pump = own[:7] + tail = own[7:] + if flow_rate not in _PERI_FLOW_RATES: + raise ValueError(f"unknown peristaltic flow rate: {flow_rate!r}") + if int(cassette) != _NO_CASSETTE_REQUIREMENT and int(cassette) not in _BYTE_TO_CASSETTE_TYPE: + raise ValueError(f"unknown cassette type: {cassette!r}") + if int(pump) != _NO_PUMP and int(pump) not in _BYTE_TO_PERI_PUMP: + raise ValueError(f"unknown peristaltic pump: {pump!r}") + return cls( + fixed_volume=definition.flag(fixed_volume), + volume=definition.number(volume, 16), + duration=definition.number(duration, 16), + flow_rate=_PERI_FLOW_RATES[flow_rate], + home_when_finished=definition.flag(home), + cassette_type=_BYTE_TO_CASSETTE_TYPE.get(int(cassette)), + peri_pump=_BYTE_TO_PERI_PUMP.get(int(pump)), + random_access=RandomAccess.from_definition(*tail) if tail else RandomAccess(), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + Whichever of volume and duration does not drive the step is sent as zero. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + cassette = ( + _NO_CASSETTE_REQUIREMENT + if self.cassette_type is None + else CASSETTE_TYPE_TO_BYTE[self.cassette_type] + ) + pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + return pad( + u16(self.volume if self.fixed_volume else 0) + + u16(0 if self.fixed_volume else self.duration) + + u8(PERI_FLOW_RATE_TO_BYTE[self.flow_rate]) + + u8(1 if self.home_when_finished else 0) + + u8(cassette) + + u8(pump), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_purge.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_purge.py new file mode 100644 index 00000000000..9003f8ced8e --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_purge.py @@ -0,0 +1,20 @@ +"""Purging a peristaltic pump.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime + + +@dataclass +class PeriPurge(PeriPrime): + """Pump a cassette empty, running fluid to waste rather than to the plate. + + Parameters, stored layout and payload are the same as a peristaltic prime; only the operation + differs. + """ + + step_type: ClassVar[StepType] = StepType.PERI_PURGE diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py new file mode 100644 index 00000000000..27654c95a62 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py @@ -0,0 +1,119 @@ +"""Aspirating through the peristaltic wash manifold.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE, PeriPump +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_PAYLOAD_LENGTH = 22 +_DEFINITION_FIELDS = 9 + +_NO_PUMP = 0 +_BYTE_TO_PERI_PUMP: dict[int, PeriPump] = {value: key for key, value in PERI_PUMP_TO_BYTE.items()} + + +@dataclass +class PeriWashAspirate(Step): + """Draw spent medium off gently through a peristaltic wash manifold. + + Paired with a peristaltic wash dispense, this exchanges medium without disturbing what is + growing in the well, which an ordinary aspirate would draw out. It needs two peristaltic pumps + with wash cassettes and manifolds fitted. + + Attributes: + volume: Volume per tube in µL. + flow_rate: How fast to aspirate, as a position on the aspirate rate scale. + positioning: Where in the well to aspirate. + peri_pump: Which pump to drive. + columns: Which columns to aspirate. + rows: Which row sections to aspirate. + """ + + step_type: ClassVar[StepType] = StepType.PERI_WASH_ASPIRATE + + volume: int = 100 + flow_rate: int = 2 + positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + peri_pump: PeriPump | None = "Primary" + columns: WellMask = field(default_factory=WellMask.all_columns) + rows: WellMask = field(default_factory=WellMask.all_rows) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + return ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.volume}|{self.flow_rate}" + f"|{self.positioning.to_definition()}|{pump}" + f"|{self.columns.to_definition()}|{self.rows.to_definition()}" + ) + + @classmethod + def from_definition(cls, text: str) -> PeriWashAspirate: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a pump that + does not exist. + """ + volume, flow_rate, z, x, y, pump, columns, rows = definition.own_fields( + text, cls.step_type, _DEFINITION_FIELDS + ) + if int(pump) != _NO_PUMP and int(pump) not in _BYTE_TO_PERI_PUMP: + raise ValueError(f"unknown peristaltic pump: {pump!r}") + return cls( + volume=definition.number(volume, 16), + flow_rate=definition.number(flow_rate, 8), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + ), + peri_pump=_BYTE_TO_PERI_PUMP.get(int(pump)), + columns=WellMask.from_definition(columns), + rows=WellMask.from_definition(rows), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + The offsets are sent X, Y, Z, the reverse of the order they are stored in, and the row + selection is sent inverted. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + return pad( + u16(self.volume) + + u8(self.flow_rate) + + i16(self.positioning.x) + + i8(self.positioning.y) + + i16(self.positioning.z) + + self.columns.to_bytes() + + self.rows.to_bytes_inverted() + + u8(pump), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py new file mode 100644 index 00000000000..4c882c80ac3 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py @@ -0,0 +1,144 @@ +"""Dispensing through the peristaltic wash manifold.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE, PeriPump +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import PreDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_PAYLOAD_LENGTH = 26 +_DEFINITION_FIELDS = 12 + +_NO_PUMP = 0 +_BYTE_TO_PERI_PUMP: dict[int, PeriPump] = {value: key for key, value in PERI_PUMP_TO_BYTE.items()} + + +@dataclass +class PeriWashDispense(Step): + """Add fresh medium gently through a peristaltic wash manifold. + + Paired with a peristaltic wash aspirate. Pre-dispensing into the priming trough on every plate + makes up for what evaporates from the manifold tubing between plates. + + Attributes: + volume: Volume per tube in µL. + flow_rate: How fast to dispense, as a position on the dispense rate scale. + positioning: Where in the well to dispense. + peri_pump: Which pump to drive. + pre_dispense: Whether to pre-dispense first, at what volume and how many times. Both values + are checked by validation whether or not it is switched on. + columns: Which columns to dispense into. + rows: Which row sections to dispense into. + """ + + step_type: ClassVar[StepType] = StepType.PERI_WASH_DISPENSE + + volume: int = 100 + flow_rate: int = 2 + positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + peri_pump: PeriPump | None = "Primary" + pre_dispense: PreDispense = field( + default_factory=lambda: PreDispense(enabled=True, volume=25, count=2) + ) + columns: WellMask = field(default_factory=WellMask.all_columns) + rows: WellMask = field(default_factory=WellMask.all_rows) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + The pre-dispense volume is stored whether or not pre-dispensing is switched on. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + return ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.volume}|{self.flow_rate}" + f"|{self.positioning.to_definition()}|{pump}|{self.pre_dispense.enabled}" + f"|{self.pre_dispense.volume}|{self.pre_dispense.count}" + f"|{self.columns.to_definition()}|{self.rows.to_definition()}" + ) + + @classmethod + def from_definition(cls, text: str) -> PeriWashDispense: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a pump that + does not exist. + """ + ( + volume, + flow_rate, + z, + x, + y, + pump, + pre_enabled, + pre_volume, + pre_count, + columns, + rows, + ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + if int(pump) != _NO_PUMP and int(pump) not in _BYTE_TO_PERI_PUMP: + raise ValueError(f"unknown peristaltic pump: {pump!r}") + return cls( + volume=definition.number(volume, 16), + flow_rate=definition.number(flow_rate, 8), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + ), + peri_pump=_BYTE_TO_PERI_PUMP.get(int(pump)), + pre_dispense=PreDispense( + enabled=definition.flag(pre_enabled), + volume=definition.number(pre_volume, 16), + count=definition.number(pre_count, 8), + ), + columns=WellMask.from_definition(columns), + rows=WellMask.from_definition(rows), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + The layout is the aspirate's with the pre-dispense volume and count added after the offsets. + Only the volume is gated by the flag; the count is sent either way. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + return pad( + u16(self.volume) + + u8(self.flow_rate) + + i16(self.positioning.x) + + i8(self.positioning.y) + + i16(self.positioning.z) + + u16(self.pre_dispense.wire_volume) + + u8(self.pre_dispense.count) + + self.columns.to_bytes() + + self.rows.to_bytes_inverted() + + u8(pump), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py new file mode 100644 index 00000000000..5a1e2f912f9 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py @@ -0,0 +1,106 @@ +"""Shaking and soaking the plate.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import SHAKE_AXIS_TO_BYTE +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import SHAKE_INTENSITY_TO_BYTE +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Shake, Soak + +_PAYLOAD_LENGTH = 11 +_DEFINITION_FIELDS = 8 + + +@dataclass +class ShakeSoak(Step): + """Shake the plate, stand still, or both. + + The wash types reuse this step for the pause after each dispense, and switch it off through + ``enabled`` when that stage does not run. A step a caller builds is always enabled. + + Attributes: + enabled: Whether the step does anything at all. + move_carrier_home: Whether the carrier returns home afterwards. Required when shaking and + soaking together take longer than a minute. + shake: Whether to shake, for how long, along which axis and how vigorously. + soak: Whether to soak, and for how long. + """ + + step_type: ClassVar[StepType] = StepType.SHAKE_SOAK + + enabled: bool = True + move_carrier_home: bool = True + shake: Shake = field(default_factory=Shake) + soak: Soak = field(default_factory=Soak) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + ``enabled`` is not stored: a step read back from a protocol file is always enabled, and a wash + supplies the flag from its own stage selection. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + return ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.move_carrier_home}" + f"|{self.shake.to_definition()}|{self.soak.to_definition()}" + ) + + @classmethod + def from_definition(cls, text: str) -> ShakeSoak: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout. + """ + ( + move_carrier_home, + shake_enabled, + shake_duration, + axis, + intensity, + soak_enabled, + soak_duration, + ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + return cls( + move_carrier_home=definition.flag(move_carrier_home), + shake=Shake.from_definition(shake_enabled, shake_duration, axis, intensity), + soak=Soak.from_definition(soak_enabled, soak_duration), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + A disabled step keeps its length and sends zeroes, so it runs as a no-op. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + return pad( + u8(1 if (self.move_carrier_home and self.enabled) else 0) + + u16(self.shake.wire_duration if self.enabled else 0) + + u8(SHAKE_INTENSITY_TO_BYTE[self.shake.intensity]) + + u8(SHAKE_AXIS_TO_BYTE[self.shake.axis]) + + u16(self.soak.wire_duration if self.enabled else 0), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py new file mode 100644 index 00000000000..8eda1f01065 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py @@ -0,0 +1,130 @@ +"""Aspirating through the strip washer manifold.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.secondary_aspirate_pattern import ( + SECONDARY_ASPIRATE_PATTERN_TO_BYTE, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TRAVEL_RATE_TO_BYTE, TravelRate +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import SecondaryAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_PAYLOAD_LENGTH = 27 +_IN_WASH_PAYLOAD_LENGTH = 20 +_DEFINITION_FIELDS = 10 + + +@dataclass +class StripAspirate(Step): + """Draw the wells empty through the strip washer manifold. + + The strip washer reaches further than the plate washer in every axis and offers two travel rates + the plate washer does not. It has no vacuum filtration. + + Attributes: + travel_rate: How fast the tips descend into the well. + delay: How long to keep aspirating once the tips are down, in ms. + positioning: Where in the well to aspirate. + secondary: Whether to aspirate a second time, in what pattern and where. + columns: Which columns to aspirate. Only sent by a step that stands on its own. + rows: Which rows to aspirate. Only sent by a step that stands on its own. + in_wash: Whether the step belongs to a wash, which selects wells itself. Such a step stores + and sends no selections, and its payload is seven bytes shorter. + """ + + step_type: ClassVar[StepType] = StepType.STRIP_ASPIRATE + + travel_rate: TravelRate = "3" + delay: int = 0 + positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + secondary: SecondaryAspirate = field(default_factory=SecondaryAspirate) + columns: WellMask = field(default_factory=WellMask.all_columns) + rows: WellMask = field(default_factory=WellMask.all_rows) + in_wash: bool = False + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition, with the selections only when the step stands alone. + """ + text = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.travel_rate}|{self.delay}" + f"|{self.positioning.to_definition()}|{self.secondary.to_definition()}" + ) + if not self.in_wash: + text += f"|{self.columns.to_definition()}|{self.rows.to_definition()}" + return text + + @classmethod + def from_definition(cls, text: str) -> StripAspirate: + """Read the step back from its definition text. + + Whether the selections are present is what says whether the step belongs to a wash. This + layout keeps empty fields rather than dropping them. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a travel rate + that does not exist. + """ + own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS, empty_ok=True) + travel_rate, delay, z, x, y, pattern, secondary_z, secondary_x, secondary_y = own[:9] + masks = own[9:] + if travel_rate not in definition.TRAVEL_RATES: + raise ValueError(f"unknown travel rate: {travel_rate!r}") + return cls( + travel_rate=definition.TRAVEL_RATES[travel_rate], + delay=definition.number(delay, 16), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + ), + secondary=SecondaryAspirate.from_definition(pattern, secondary_z, secondary_x, secondary_y), + columns=WellMask.from_definition(masks[0]) if masks else WellMask.all_columns(), + rows=WellMask.from_definition(masks[1]) if len(masks) > 1 else WellMask.all_rows(), + in_wash=not masks, + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + Both offset groups are sent X, Y, Z, the reverse of the order they are stored in, with X two + bytes wide. The selections are packed one bit per entry, not sampled down. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + payload = ( + u16(self.delay) + + u8(TRAVEL_RATE_TO_BYTE[self.travel_rate]) + + i16(self.positioning.x) + + i8(self.positioning.y) + + i16(self.positioning.z) + + u8(SECONDARY_ASPIRATE_PATTERN_TO_BYTE[self.secondary.pattern]) + + i16(self.secondary.positioning.x) + + i8(self.secondary.positioning.y) + + i16(self.secondary.positioning.z) + ) + if self.in_wash: + return pad(payload, _IN_WASH_PAYLOAD_LENGTH) + return pad(payload + self.columns.to_bytes() + self.rows.to_bytes(), _PAYLOAD_LENGTH) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py new file mode 100644 index 00000000000..ad01ad3ac7c --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py @@ -0,0 +1,160 @@ +"""Dispensing through the strip washer manifold.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( + PreDispense, + VacuumDelay, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_PAYLOAD_LENGTH = 27 +_IN_WASH_PAYLOAD_LENGTH = 20 +_DEFINITION_FIELDS = 12 + + +@dataclass +class StripDispense(Step): + """Dispense a volume into every selected well through the strip washer manifold. + + The strip washer has no valve selection, so there is no buffer to choose. + + Attributes: + volume: Volume per well in µL. + flow_rate: How fast to dispense. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, at what volume, rate and how many times. + vacuum: Whether to hold the vacuum off until a volume has been dispensed. + columns: Which columns to dispense into. Only sent by a step that stands on its own. + rows: Which rows to dispense into. Only sent by a step that stands on its own. + in_wash: Whether the step belongs to a wash, which selects wells itself. Such a step stores + and sends no selections, and its payload is seven bytes shorter. + is_cycle_dispense: Whether a wash uses this step as its between-cycles dispense. Validation + checks a step a wash does not run less strictly. + is_bottom_wash: Whether a wash uses this step as its bottom wash. + force_pre_dispense: Whether the owning wash pre-dispenses whatever the step itself says. + """ + + step_type: ClassVar[StepType] = StepType.STRIP_DISPENSE + + volume: int = 50 + flow_rate: int = 5 + positioning: Positioning = field(default_factory=lambda: Positioning(z=336)) + pre_dispense: PreDispense = field( + default_factory=lambda: PreDispense(volume=50, flow_rate=5, count=2) + ) + vacuum: VacuumDelay = field(default_factory=VacuumDelay) + columns: WellMask = field(default_factory=WellMask.all_columns) + rows: WellMask = field(default_factory=WellMask.all_rows) + in_wash: bool = False + is_cycle_dispense: bool = False + is_bottom_wash: bool = False + force_pre_dispense: bool = False + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + The four flags describing the step's place in a wash are not stored; the owning wash sets them + again when it uses the step. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition, with the selections only when the step stands alone. + """ + text = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.volume}|{self.flow_rate}" + f"|{self.positioning.to_definition()}|{self.pre_dispense.enabled}" + f"|{self.pre_dispense.volume}|{self.pre_dispense.flow_rate}|{self.pre_dispense.count}" + f"|{self.vacuum.to_definition()}" + ) + if not self.in_wash: + text += f"|{self.columns.to_definition()}|{self.rows.to_definition()}" + return text + + @classmethod + def from_definition(cls, text: str) -> StripDispense: + """Read the step back from its definition text. + + Whether the selections are present is what says whether the step belongs to a wash. This + layout keeps empty fields rather than dropping them. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout. + """ + own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS, empty_ok=True) + ( + volume, + flow_rate, + z, + x, + y, + pre_enabled, + pre_volume, + pre_flow_rate, + pre_count, + vacuum_enabled, + vacuum_volume, + ) = own[:11] + masks = own[11:] + return cls( + volume=definition.number(volume, 16), + flow_rate=definition.number(flow_rate, 8), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + ), + pre_dispense=PreDispense( + enabled=definition.flag(pre_enabled), + volume=definition.number(pre_volume, 16), + flow_rate=definition.number(pre_flow_rate, 8), + count=definition.number(pre_count, 8), + ), + vacuum=VacuumDelay.from_definition(vacuum_enabled, vacuum_volume), + columns=WellMask.from_definition(masks[0]) if masks else WellMask.all_columns(), + rows=WellMask.from_definition(masks[1]) if len(masks) > 1 else WellMask.all_rows(), + in_wash=not masks, + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + A step inside a wash sends its pre-dispense flow rate where a standalone step sends its own, + and pre-dispenses when either it or the wash asks for it. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + pre_dispensing = self.pre_dispense.enabled or self.force_pre_dispense + payload = ( + u16(self.volume) + + u8(self.flow_rate) + + i16(self.positioning.x) + + i8(self.positioning.y) + + i16(self.positioning.z) + + u16(self.pre_dispense.volume if pre_dispensing else 0) + + u8(self.pre_dispense.flow_rate if self.in_wash else self.flow_rate) + + u8(self.pre_dispense.count) + + u16(self.vacuum.wire_volume) + ) + if self.in_wash: + return pad(payload, _IN_WASH_PAYLOAD_LENGTH) + return pad(payload + self.columns.to_bytes() + self.rows.to_bytes(), _PAYLOAD_LENGTH) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py new file mode 100644 index 00000000000..042b7fc2b90 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py @@ -0,0 +1,89 @@ +"""Priming the strip washer manifold.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Submerge + +_PAYLOAD_LENGTH = 12 +_DEFINITION_FIELDS = 6 + + +@dataclass +class StripPrime(Step): + """Pump fluid through the strip washer manifold until its lines are full. + + Attributes: + volume: Volume to pump in µL. What the manifold accepts depends on which one is fitted. + flow_rate: How fast to pump. + cycles: How many prime cycles to run. + submerge: Whether to leave the tips in fluid afterwards, and for how long. + """ + + step_type: ClassVar[StepType] = StepType.STRIP_PRIME + + volume: int = 5000 + flow_rate: int = 5 + cycles: int = 2 + submerge: Submerge = field(default_factory=Submerge) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + return ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.volume}|{self.flow_rate}" + f"|{self.cycles}|{self.submerge.to_definition()}" + ) + + @classmethod + def from_definition(cls, text: str) -> StripPrime: + """Read the step back from its definition text. + + This layout keeps empty fields rather than dropping them, so an empty field still holds its + place. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout. + """ + volume, flow_rate, cycles, submerge, duration = definition.own_fields( + text, cls.step_type, _DEFINITION_FIELDS, empty_ok=True + ) + return cls( + volume=definition.number(volume, 16), + flow_rate=definition.number(flow_rate, 8), + cycles=definition.number(cycles, 8), + submerge=Submerge.from_definition(submerge, duration), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + return pad( + u16(self.volume) + u8(self.flow_rate) + u8(self.cycles) + u16(self.submerge.wire_minutes), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py new file mode 100644 index 00000000000..6e9d1ebfeed --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py @@ -0,0 +1,164 @@ +"""Washing through the strip washer manifold.""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WASH_FORMAT_TO_BYTE, WashFormat +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import pad, u8 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import WashStages +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_aspirate import StripAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_dispense import StripDispense + +_PAYLOAD_LENGTH = 108 +_DEFINITION_FIELDS = 10 +_DEFINITION_PARTS = 6 +_PART_SEPARATOR = "#" + + +@dataclass +class StripWash(Step): + """Wash a plate through the strip washer manifold. + + Built like a manifold wash, with two differences: well selection belongs to the wash rather than + to its steps, and there are no sectors -- the selections do that job. + + Attributes: + wash_format: Whether to wash the whole plate, selected sectors or selected strips. + cycles: How many wash cycles to run. + stages: Which optional stages run. + columns: Which columns to wash. + rows: Which rows to wash. + bottom_wash: The dispense that washes the bottom of the well. + aspirate: The aspirate that empties the well at the start of each cycle. + dispense: The dispense that refills the well. + shake_soak: The pause after each dispense. + final_aspirate: The aspirate that empties the well after the last cycle. + """ + + step_type: ClassVar[StepType] = StepType.STRIP_WASH + + wash_format: WashFormat = "Plate" + cycles: int = 3 + stages: WashStages = field(default_factory=WashStages) + columns: WellMask = field(default_factory=WellMask.all_columns) + rows: WellMask = field(default_factory=WellMask.all_rows) + bottom_wash: StripDispense = field(default_factory=lambda: StripDispense(in_wash=True)) + aspirate: StripAspirate = field(default_factory=lambda: StripAspirate(in_wash=True)) + dispense: StripDispense = field(default_factory=lambda: StripDispense(in_wash=True)) + shake_soak: ShakeSoak = field(default_factory=ShakeSoak) + final_aspirate: StripAspirate = field(default_factory=lambda: StripAspirate(in_wash=True)) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Args: + settings: What the instrument has fitted. + + Returns: + The definition, its six parts joined by ``#``. + """ + head = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.wash_format}|{self.cycles}" + f"|{self.stages.to_definition()}|{self.columns.to_definition()}" + f"|{self.rows.to_definition()}" + ) + return _PART_SEPARATOR.join( + [ + head, + self.bottom_wash.to_definition(settings), + self.aspirate.to_definition(settings), + self.dispense.to_definition(settings), + self.shake_soak.to_definition(settings), + self.final_aspirate.to_definition(settings), + ] + ) + + @classmethod + def from_definition(cls, text: str) -> StripWash: + """Read the step back from its definition text. + + Args: + text: The definition, its six parts joined by ``#``. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have six parts, or a part does not have its step + type's layout, or the format is not one of the three. + """ + parts = text.split(_PART_SEPARATOR) + if len(parts) != _DEFINITION_PARTS: + raise ValueError(f"{cls.step_type.name} expects {_DEFINITION_PARTS} parts, got {len(parts)}") + head, bottom_wash, aspirate, dispense, shake_soak, final_aspirate = parts + own = definition.own_fields(head, cls.step_type, _DEFINITION_FIELDS, empty_ok=True) + wash_format, cycles = own[0], own[1] + if wash_format not in definition.WASH_FORMATS: + raise ValueError(f"unknown wash format: {wash_format!r}") + return cls( + wash_format=definition.WASH_FORMATS[wash_format], + cycles=definition.number(cycles, 8), + stages=WashStages.from_definition(*own[2:7]), + columns=WellMask.from_definition(own[7]), + rows=WellMask.from_definition(own[8]), + bottom_wash=StripDispense.from_definition(bottom_wash), + aspirate=StripAspirate.from_definition(aspirate), + dispense=StripDispense.from_definition(dispense), + shake_soak=ShakeSoak.from_definition(shake_soak), + final_aspirate=StripAspirate.from_definition(final_aspirate), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + The five steps are sent with the final aspirate second rather than last, and the wash's own + selections come after them. The two dispenses take their pre-dispense from different stage + flags: the bottom wash from the one before washing, the cycle dispense from the one between + cycles. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + bottom_wash = dataclasses.replace( + self.bottom_wash, + in_wash=True, + force_pre_dispense=False, + pre_dispense=dataclasses.replace( + self.bottom_wash.pre_dispense, enabled=self.stages.pre_dispense_before + ), + ) + dispense = dataclasses.replace( + self.dispense, + in_wash=True, + force_pre_dispense=self.stages.pre_dispense_between, + pre_dispense=dataclasses.replace(self.dispense.pre_dispense, enabled=False), + ) + aspirate = dataclasses.replace(self.aspirate, in_wash=True) + final_aspirate = dataclasses.replace(self.final_aspirate, in_wash=True) + shake_soak = dataclasses.replace(self.shake_soak, enabled=self.stages.shake_soak_after_dispense) + return pad( + u8(1 if self.stages.bottom_wash else 0) + + u8(1 if self.stages.final_aspirate else 0) + + u8(WASH_FORMAT_TO_BYTE[self.wash_format]) + + u8(self.cycles) + + bottom_wash.to_bytes(settings) + + final_aspirate.to_bytes(settings) + + aspirate.to_bytes(settings) + + dispense.to_bytes(settings) + + shake_soak.to_bytes(settings) + + self.columns.to_bytes() + + self.rows.to_bytes(), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py new file mode 100644 index 00000000000..879cf564a85 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py @@ -0,0 +1,172 @@ +"""Dispensing through the syringe dispenser.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe import SYRINGE_TO_BYTE, Syringe +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import ( + SYRINGE_BOTTLE_TO_BYTE, + SyringeBottle, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import PreDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning + +_PAYLOAD_LENGTH = 25 +_WIDE_OFFSET_PAYLOAD_LENGTH = 26 +_DEFINITION_FIELDS = 13 + +_BYTE_TO_SYRINGE: dict[int, Syringe] = {value: key for key, value in SYRINGE_TO_BYTE.items()} +_BYTE_TO_SYRINGE_BOTTLE: dict[int, SyringeBottle] = { + value: key for key, value in SYRINGE_BOTTLE_TO_BYTE.items() +} + + +@dataclass +class SyringeDispense(Step): + """Dispense a volume into every selected well from a syringe. + + Attributes: + syringe: Which syringe to dispense from. + volume: Volume per well in µL. + flow_rate: How fast to dispense. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, at what volume and how many times. + pump_delay: How long the pump waits between wells, in ms. + columns: Which columns to dispense into. + syringe_bottle: Which bottle to draw from. + rows: Which rows to dispense into. Only instruments that select rows store this. + selects_rows: Whether the instrument selects rows at all. + """ + + step_type: ClassVar[StepType] = StepType.SYRINGE_DISPENSE + + syringe: Syringe = "A" + volume: int = 50 + flow_rate: int = 2 + positioning: Positioning = field(default_factory=lambda: Positioning(z=336)) + pre_dispense: PreDispense = field(default_factory=lambda: PreDispense(volume=50, count=2)) + pump_delay: int = 0 + columns: WellMask = field(default_factory=WellMask.all_columns) + syringe_bottle: SyringeBottle = "A1" + rows: WellMask = field(default_factory=WellMask.all_rows) + selects_rows: bool = False + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + The pre-dispense flow rate is not stored by this step type. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition, with the row selection only on an instrument that selects + rows. + """ + text = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{SYRINGE_TO_BYTE[self.syringe]}" + f"|{self.volume}|{self.flow_rate}|{self.positioning.to_definition()}" + f"|{self.pre_dispense.enabled}|{self.pre_dispense.volume}|{self.pre_dispense.count}" + f"|{self.pump_delay}|{self.columns.to_definition()}" + f"|{SYRINGE_BOTTLE_TO_BYTE[self.syringe_bottle]}" + ) + if self.selects_rows: + text += f"|{self.rows.to_definition()}" + return text + + @classmethod + def from_definition(cls, text: str) -> SyringeDispense: + """Read the step back from its definition text. + + Whether the row selection is present is what says whether the instrument selects rows. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a syringe or + bottle that does not exist. + """ + own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS) + ( + syringe, + volume, + flow_rate, + z, + x, + y, + pre_enabled, + pre_volume, + pre_count, + pump_delay, + columns, + bottle, + ) = own[:12] + tail = own[12:] + if int(syringe) not in _BYTE_TO_SYRINGE: + raise ValueError(f"unknown syringe: {syringe!r}") + if int(bottle) not in _BYTE_TO_SYRINGE_BOTTLE: + raise ValueError(f"unknown syringe bottle: {bottle!r}") + return cls( + syringe=_BYTE_TO_SYRINGE[int(syringe)], + volume=definition.number(volume, 16), + flow_rate=definition.number(flow_rate, 8), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + ), + pre_dispense=PreDispense( + enabled=definition.flag(pre_enabled), + volume=definition.number(pre_volume, 16), + count=definition.number(pre_count, 8), + ), + pump_delay=definition.number(pump_delay, 16), + columns=WellMask.from_definition(columns), + syringe_bottle=_BYTE_TO_SYRINGE_BOTTLE[int(bottle)], + rows=WellMask.from_definition(tail[0]) if tail else WellMask.all_rows(), + selects_rows=bool(tail), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + With the wider dispense offsets fitted the X offset takes two bytes instead of one, which + makes this the one step whose payload changes length. The row selection is sent inverted, so + a selected row contributes a zero bit. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + if settings.advanced_dispense_offsets: + offset_x, length = i16(self.positioning.x), _WIDE_OFFSET_PAYLOAD_LENGTH + else: + offset_x, length = i8(self.positioning.x), _PAYLOAD_LENGTH + payload = ( + u8(SYRINGE_TO_BYTE[self.syringe] - 1) + + u16(self.volume) + + u8(self.flow_rate) + + offset_x + + i8(self.positioning.y) + + i16(self.positioning.z) + + u16(self.pump_delay) + + u16(self.pre_dispense.wire_volume) + + u8(self.pre_dispense.count) + + self.columns.to_bytes() + + u8(SYRINGE_BOTTLE_TO_BYTE[self.syringe_bottle] - 1) + ) + if self.selects_rows: + payload += self.rows.to_bytes_inverted() + return pad(payload, length) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py new file mode 100644 index 00000000000..2d20d4bc9b7 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py @@ -0,0 +1,133 @@ +"""Priming a syringe.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe import SYRINGE_TO_BYTE, Syringe +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import ( + SYRINGE_BOTTLE_TO_BYTE, + SyringeBottle, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Submerge + +_PAYLOAD_LENGTH = 12 +_DEFINITION_FIELDS = 10 + +_BYTE_TO_SYRINGE: dict[int, Syringe] = {value: key for key, value in SYRINGE_TO_BYTE.items()} +_BYTE_TO_SYRINGE_BOTTLE: dict[int, SyringeBottle] = { + value: key for key, value in SYRINGE_BOTTLE_TO_BYTE.items() +} + + +@dataclass +class SyringePrime(Step): + """Draw fluid through a syringe until its lines are full. + + Attributes: + syringe: Which syringe to prime. + volume: Volume to draw in µL. + flow_rate: How fast to draw. + cycles: How many prime cycles to run. + pump_delay: How long the pump waits between cycles, in ms. + reserved_flag: A flag the payload carries that no setting varies. Always set. + submerge: Whether to leave the tips in fluid afterwards, and for how long. + syringe_bottle: Which bottle to draw from. + """ + + step_type: ClassVar[StepType] = StepType.SYRINGE_PRIME + + syringe: Syringe = "A" + volume: int = 5000 + flow_rate: int = 5 + cycles: int = 2 + pump_delay: int = 0 + reserved_flag: bool = True + submerge: Submerge = field(default_factory=Submerge) + syringe_bottle: SyringeBottle = "A1" + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + Args: + settings: What the instrument has fitted. + + Returns: + The ``|``-separated definition. + """ + return ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{SYRINGE_TO_BYTE[self.syringe]}" + f"|{self.volume}|{self.flow_rate}|{self.cycles}|{self.pump_delay}" + f"|{self.reserved_flag}|{self.submerge.to_definition()}" + f"|{SYRINGE_BOTTLE_TO_BYTE[self.syringe_bottle]}" + ) + + @classmethod + def from_definition(cls, text: str) -> SyringePrime: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a syringe or + bottle that does not exist. + """ + ( + syringe, + volume, + flow_rate, + cycles, + pump_delay, + reserved_flag, + submerge, + duration, + bottle, + ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + if int(syringe) not in _BYTE_TO_SYRINGE: + raise ValueError(f"unknown syringe: {syringe!r}") + if int(bottle) not in _BYTE_TO_SYRINGE_BOTTLE: + raise ValueError(f"unknown syringe bottle: {bottle!r}") + return cls( + syringe=_BYTE_TO_SYRINGE[int(syringe)], + volume=definition.number(volume, 16), + flow_rate=definition.number(flow_rate, 8), + cycles=definition.number(cycles, 8), + pump_delay=definition.number(pump_delay, 16), + reserved_flag=definition.flag(reserved_flag), + submerge=Submerge.from_definition(submerge, duration), + syringe_bottle=_BYTE_TO_SYRINGE_BOTTLE[int(bottle)], + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + Both selections are sent one lower than they are stored, so the first syringe and the first + bottle are zero. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + return pad( + u8(SYRINGE_TO_BYTE[self.syringe] - 1) + + u16(self.volume) + + u8(self.flow_rate) + + u8(self.cycles) + + u16(self.pump_delay) + + u8(1 if self.reserved_flag else 0) + + u16(self.submerge.wire_minutes) + + u8(SYRINGE_BOTTLE_TO_BYTE[self.syringe_bottle] - 1), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py new file mode 100644 index 00000000000..b7dcfa20a29 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py @@ -0,0 +1,184 @@ +"""Washing a 1536-well plate.""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TRAVEL_RATE_TO_BYTE +from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WASH_FORMAT_TO_BYTE, WashFormat +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import PreDispense, WashStages +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense + +_PAYLOAD_LENGTH = 67 +_DEFINITION_FIELDS = 9 +_DEFINITION_PARTS = 5 +_PART_SEPARATOR = "#" + + +@dataclass +class Wash1536(Step): + """Wash a 1536-well plate, dispensing from a syringe rather than from the wash manifold. + + There is no bottom wash, and well selection lives on the syringe dispense. The volume and count + used to pre-dispense before washing belong to the wash itself rather than to one of its steps. + + Attributes: + wash_format: Whether to wash the whole plate, selected sectors or selected strips. + cycles: How many wash cycles to run. + stages: Which optional stages run. The bottom wash flag is unused here. + pre_dispense_before_volume: Volume per well in µL to pre-dispense before washing starts. + pre_dispense_before_count: How many times to pre-dispense before washing starts. + aspirate: The aspirate that empties the well at the start of each cycle. + dispense: The syringe dispense that refills the well. + shake_soak: The pause after each dispense. + final_aspirate: The aspirate that empties the well after the last cycle. + """ + + step_type: ClassVar[StepType] = StepType.WASH_1536 + + wash_format: WashFormat = "Plate" + cycles: int = 3 + stages: WashStages = field(default_factory=WashStages) + pre_dispense_before_volume: int = 10 + pre_dispense_before_count: int = 2 + aspirate: ManifoldAspirate = field(default_factory=lambda: ManifoldAspirate(in_wash=True)) + dispense: SyringeDispense = field( + default_factory=lambda: SyringeDispense( + pre_dispense=PreDispense(enabled=True, volume=50, count=2) + ) + ) + shake_soak: ShakeSoak = field(default_factory=ShakeSoak) + final_aspirate: ManifoldAspirate = field(default_factory=lambda: ManifoldAspirate(in_wash=True)) + + def to_definition(self, settings: InstrumentSettings) -> str: + """Write the step as the text a protocol file stores. + + The stage flags are not contiguous here: the volume and count used before washing sit between + the first two. + + Args: + settings: What the instrument has fitted. + + Returns: + The definition, its five parts joined by ``#``. + """ + head = ( + f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.wash_format}|{self.cycles}" + f"|{self.stages.pre_dispense_before}|{self.pre_dispense_before_volume}" + f"|{self.pre_dispense_before_count}|{self.stages.pre_dispense_between}" + f"|{self.stages.final_aspirate}|{self.stages.shake_soak_after_dispense}" + ) + return _PART_SEPARATOR.join( + [ + head, + self.aspirate.to_definition(settings), + self.dispense.to_definition(settings), + self.shake_soak.to_definition(settings), + self.final_aspirate.to_definition(settings), + ] + ) + + @classmethod + def from_definition(cls, text: str) -> Wash1536: + """Read the step back from its definition text. + + Args: + text: The definition, its five parts joined by ``#``. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have five parts, or a part does not have its step + type's layout, or the format is not one of the three. + """ + parts = [part for part in text.split(_PART_SEPARATOR) if part] + if len(parts) != _DEFINITION_PARTS: + raise ValueError(f"{cls.step_type.name} expects {_DEFINITION_PARTS} parts, got {len(parts)}") + head, aspirate, dispense, shake_soak, final_aspirate = parts + ( + wash_format, + cycles, + before, + before_volume, + before_count, + between, + final, + shake_after, + ) = definition.own_fields(head, cls.step_type, _DEFINITION_FIELDS) + if wash_format not in definition.WASH_FORMATS: + raise ValueError(f"unknown wash format: {wash_format!r}") + return cls( + wash_format=definition.WASH_FORMATS[wash_format], + cycles=definition.number(cycles, 8), + stages=WashStages( + pre_dispense_before=definition.flag(before), + pre_dispense_between=definition.flag(between), + final_aspirate=definition.flag(final), + shake_soak_after_dispense=definition.flag(shake_after), + ), + pre_dispense_before_volume=definition.number(before_volume, 16), + pre_dispense_before_count=definition.number(before_count, 8), + aspirate=ManifoldAspirate.from_definition(aspirate), + dispense=SyringeDispense.from_definition(dispense), + shake_soak=ShakeSoak.from_definition(shake_soak), + final_aspirate=ManifoldAspirate.from_definition(final_aspirate), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + The two aspirates contribute seven bytes each -- delay, travel rate and the three offsets -- + rather than their whole payload, so nothing else they carry is sent. The payload grows by a + byte when the syringe dispense sends a wider X offset. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + shake_soak = dataclasses.replace(self.shake_soak, enabled=self.stages.shake_soak_after_dispense) + length = _PAYLOAD_LENGTH + (1 if settings.advanced_dispense_offsets else 0) + return pad( + u8(1 if self.stages.pre_dispense_before else 0) + + u8(1 if self.stages.shake_soak_after_dispense else 0) + + u8(1 if self.stages.pre_dispense_between else 0) + + u8(1 if self.stages.final_aspirate else 0) + + u16(self.pre_dispense_before_volume) + + u8(self.pre_dispense_before_count) + + u8(WASH_FORMAT_TO_BYTE[self.wash_format]) + + u8(self.cycles) + + _aspirate_extract(self.aspirate) + + _aspirate_extract(self.final_aspirate) + + self.dispense.to_bytes(settings) + + shake_soak.to_bytes(settings), + length, + ) + + +def _aspirate_extract(aspirate: ManifoldAspirate) -> bytes: + """The seven bytes of an aspirate that this wash sends. + + Args: + aspirate: The aspirate to take them from. + + Returns: + Its delay, travel rate and offsets, in the order the payload carries them. + """ + return ( + u16(aspirate.delay) + + u8(TRAVEL_RATE_TO_BYTE[aspirate.travel_rate]) + + i8(aspirate.positioning.x) + + i8(aspirate.positioning.y) + + i16(aspirate.positioning.z) + ) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/__init__.py b/pylabrobot/agilent/biotek/lhc/serialization/__init__.py new file mode 100644 index 00000000000..88341c44a6f --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/__init__.py @@ -0,0 +1,27 @@ +"""The wire frame and the command vocabulary.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.serialization.command import Command +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import ( + STEP_TYPE_TO_COMMAND, + CommandNumber, + command_for_step, +) +from pylabrobot.agilent.biotek.lhc.serialization.frame import ( + HEADER_LENGTH, + STATUS_LENGTH, + Header, + checksum, +) + +__all__ = [ + "HEADER_LENGTH", + "STATUS_LENGTH", + "STEP_TYPE_TO_COMMAND", + "Command", + "CommandNumber", + "Header", + "checksum", + "command_for_step", +] diff --git a/pylabrobot/agilent/biotek/lhc/serialization/command.py b/pylabrobot/agilent/biotek/lhc/serialization/command.py new file mode 100644 index 00000000000..4c55424586a --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/command.py @@ -0,0 +1,69 @@ +"""What a command is: a framed request and the reply it expects.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pylabrobot.agilent.biotek.lhc.serialization.frame import STATUS_LENGTH, Header, checksum + + +@dataclass +class Command: + """One request and its reply. + + Attributes: + number: Which command this is. + payload: The payload sent with it. + reserved: The header's reserved field. + answer_length: How many bytes of the reply are the answer, or 0 to take all of them. + timeout: How long to wait for the reply, in seconds, or None for the transport's default. + Commands the instrument answers only once it has finished moving set their own. + """ + + number: int + payload: bytes = b"" + reserved: int = 0 + answer_length: int = 0 + timeout: float | None = None + + def to_bytes(self) -> bytes: + """Frame the command for sending. + + Returns: + The header followed by the payload. + """ + header = Header( + number=int(self.number), reserved=self.reserved, payload_length=len(self.payload) + ) + header.check = checksum(header.to_bytes(), self.payload) + return header.to_bytes() + self.payload + + def reply_is_intact(self, header: Header, payload: bytes) -> bool: + """Whether a reply's checksum matches its contents. + + Args: + header: The reply header. + payload: The reply payload. + + Returns: + True if the reply arrived intact. + """ + return header.check == checksum(header.to_bytes(), payload) + + def parse_reply(self, payload: bytes) -> tuple[int, bytes]: + """Split the instrument's status off a reply payload. + + Args: + payload: The reply payload. + + Returns: + The status and the answer. A status of 0 means the instrument is reporting success and the + answer is meaningful; anything else is an error code and the answer is empty. + """ + status = int.from_bytes(payload[:STATUS_LENGTH], "little") + if status != 0: + return status, b"" + answer = payload[STATUS_LENGTH:] + if self.answer_length: + answer = answer[: self.answer_length] + return 0, answer diff --git a/pylabrobot/agilent/biotek/lhc/serialization/command_numbers.py b/pylabrobot/agilent/biotek/lhc/serialization/command_numbers.py new file mode 100644 index 00000000000..b7945296a87 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/command_numbers.py @@ -0,0 +1,145 @@ +"""Which number identifies each command, and which command runs each step type.""" + +from __future__ import annotations + +import enum + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense + + +class CommandNumber(enum.IntEnum): + """The number a command carries in its header.""" + + # Identity and liveness. + GET_SERIAL_NUMBER = 256 + PING = 115 + GET_BASECODE_VERSION = 160 + GET_PROTOCOL_STATUS = 146 + + # Diagnostics and hardware configuration. + RESET_INSTRUMENT = 112 + RUN_SELF_CHECK = 149 + HOME_VERIFY_MOTORS = 200 + GET_SENSOR_ENABLED = 210 + SET_SENSOR_ENABLED = 211 + SET_WASHER_MANIFOLD_INSTALLED = 217 + GET_PLATE_RESTRICTION = 258 + GET_CARRIER_TYPE = 266 + + # Peristaltic cassettes and pump state. + GET_CASSETTE_MODE = 223 + GET_SELECTED_PERI_STATE = 236 + GET_SELECTED_PERI_CASSETTE_TYPE = 264 + SET_SELECTED_PERI_CASSETTE_TYPE = 265 + GET_EXT_PERI_CASSETTE_HEAD = 380 + SET_EXT_PERI_CASSETTE_HEAD = 381 + + # Run control. + EXIT_PROTOCOL = 140 + INIT_PROTOCOL = 141 + ABORT_STEP = 137 + PAUSE_STEP = 138 + RESUME_STEP = 139 + + # Fitted options. + GET_SYRINGE_MANIFOLD_INSTALLED = 187 + GET_EXT_VALVE_MODULE_INSTALLED = 191 + GET_VACUUM_FILTRATION_INSTALLED = 193 + GET_WASHER_MANIFOLD_INSTALLED = 216 + GET_ULTRASONIC_CLEANER_INSTALLED = 242 + GET_CELL_WASHING_INSTALLED = 244 + GET_SYRINGE_BOX_INFO = 246 + GET_SELECTED_PERI_INSTALLED = 260 + GET_Y_AXIS_INSTALLED = 295 + GET_IS_PERI_HALF_UL_SUPPORTED = 340 + IS_STRIP_WASHER_BOX_CONNECTED = 354 + GET_STRIP_WASHER_MANIFOLD_TYPE = 355 + GET_STRIP_WASHER_HW_INSTALLED = 363 + GET_SINGLE_WELL_DISPENSER_INSTALLED = 369 + GET_FLUID_TRACKING_ENABLED = 382 + GET_WHICH_BASECODE_IS_INSTALLED = 398 + + # Step execution. + PERI_DISPENSE = 143 + PERI_DISPENSE_RANDOM_ACCESS = 375 + PERI_PRIME = 144 + PERI_PURGE = 145 + SYRINGE_DISPENSE = 161 + SYRINGE_PRIME = 162 + SHAKE_SOAK = 163 + MANIFOLD_WASH = 164 + MANIFOLD_ASPIRATE = 165 + MANIFOLD_DISPENSE = 166 + MANIFOLD_PRIME = 167 + MANIFOLD_AUTO_CLEAN = 168 + PERI_WASH_ASPIRATE = 178 + PERI_WASH_DISPENSE = 179 + WASH_1536 = 177 + STRIP_WASH = 350 + STRIP_ASPIRATE = 351 + STRIP_DISPENSE = 352 + STRIP_PRIME = 353 + + # On-board protocols and file transfer. + GET_FLASH_PROGRAM_COUNT = 321 + IS_FILE_TRANSFER_SUPPORTED = 320 + GET_FILE_BEGIN = 323 + GET_FILE_END = 325 + SET_FILE_BEGIN = 326 + SET_FILE_END = 328 + + +STEP_TYPE_TO_COMMAND: dict[StepType, CommandNumber] = { + StepType.PERI_DISPENSE: CommandNumber.PERI_DISPENSE, + StepType.PERI_PRIME: CommandNumber.PERI_PRIME, + StepType.PERI_PURGE: CommandNumber.PERI_PURGE, + StepType.SYRINGE_DISPENSE: CommandNumber.SYRINGE_DISPENSE, + StepType.SYRINGE_PRIME: CommandNumber.SYRINGE_PRIME, + StepType.SHAKE_SOAK: CommandNumber.SHAKE_SOAK, + StepType.MANIFOLD_WASH: CommandNumber.MANIFOLD_WASH, + StepType.MANIFOLD_ASPIRATE: CommandNumber.MANIFOLD_ASPIRATE, + StepType.MANIFOLD_DISPENSE: CommandNumber.MANIFOLD_DISPENSE, + StepType.MANIFOLD_PRIME: CommandNumber.MANIFOLD_PRIME, + StepType.MANIFOLD_AUTO_CLEAN: CommandNumber.MANIFOLD_AUTO_CLEAN, + StepType.WASH_1536: CommandNumber.WASH_1536, + StepType.STRIP_WASH: CommandNumber.STRIP_WASH, + StepType.STRIP_ASPIRATE: CommandNumber.STRIP_ASPIRATE, + StepType.STRIP_DISPENSE: CommandNumber.STRIP_DISPENSE, + StepType.STRIP_PRIME: CommandNumber.STRIP_PRIME, + StepType.PERI_WASH_ASPIRATE: CommandNumber.PERI_WASH_ASPIRATE, + StepType.PERI_WASH_DISPENSE: CommandNumber.PERI_WASH_DISPENSE, +} +"""Which command runs each step type. + +A peristaltic dispense is the one step type whose command depends on how the step is configured; +:func:`command_for_step` is what applies that. +""" + + +def command_for_step(step: Step, settings: InstrumentSettings) -> CommandNumber: + """Which command runs a particular step. + + A peristaltic dispense at random access carries a different payload and is sent as a different + command. Every other step type is decided by its type alone. + + Args: + step: The step to send. + settings: What the instrument has fitted, which decides whether it can run the step at random + access at all. + + Returns: + The command number. + + Raises: + KeyError: If no command runs this step type. + """ + if ( + isinstance(step, PeriDispense) + and step.random_access.enabled + and settings.supports_random_access_tail + ): + return CommandNumber.PERI_DISPENSE_RANDOM_ACCESS + return STEP_TYPE_TO_COMMAND[step.step_type] diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/__init__.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/__init__.py new file mode 100644 index 00000000000..73cec19ad12 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/__init__.py @@ -0,0 +1,61 @@ +"""One class per command, grouped by what the command is for.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.serialization.commands.configuration import ( + ByteQuery, + ByteWrite, + FlagQuery, + GetSyringeBoxInfo, + SelectorQuery, + SelectorWrite, + SyringeBox, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import ( + HomeVerifyMotors, + ResetInstrument, + RunSelfCheck, + SetSensorEnabled, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( + FirmwareVersion, + GetFirmwareVersion, + GetSerialNumber, + Ping, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import ( + AbortStep, + ExitProtocol, + GetProtocolStatus, + InitProtocol, + PauseStep, + ResumeStep, + RunStatus, + RunStep, +) + +__all__ = [ + "AbortStep", + "ByteQuery", + "ByteWrite", + "ExitProtocol", + "FirmwareVersion", + "FlagQuery", + "GetFirmwareVersion", + "GetProtocolStatus", + "GetSerialNumber", + "GetSyringeBoxInfo", + "HomeVerifyMotors", + "InitProtocol", + "PauseStep", + "Ping", + "ResetInstrument", + "ResumeStep", + "RunSelfCheck", + "RunStatus", + "RunStep", + "SelectorQuery", + "SelectorWrite", + "SetSensorEnabled", + "SyringeBox", +] diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py new file mode 100644 index 00000000000..5107781f7a8 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py @@ -0,0 +1,149 @@ +"""Commands that read and write what the instrument has fitted. + +Most of them share one of four wire shapes, so the shapes are classes in their own right and each +fitted option is one of them with a different command number. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pylabrobot.agilent.biotek.lhc.serialization.command import Command +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber + + +class ByteQuery(Command): + """A command that sends nothing and reads back one byte.""" + + def __init__(self, number: CommandNumber) -> None: + """Build the command. + + Args: + number: Which command to send. + """ + super().__init__(number=number) + + def parse(self, answer: bytes) -> int: + """Read the byte out of a reply. + + Args: + answer: The reply answer. + + Returns: + The byte. + """ + return answer[0] + + +class FlagQuery(ByteQuery): + """A command that sends nothing and reads back one byte meaning yes or no.""" + + def parse_flag(self, answer: bytes) -> bool: + """Read the byte out of a reply as a flag. + + Args: + answer: The reply answer. + + Returns: + Whether the byte is set. + """ + return bool(answer[0]) + + +class SelectorQuery(Command): + """A command that sends one byte selecting what to ask about and reads back one byte.""" + + def __init__(self, number: CommandNumber, selector: int) -> None: + """Build the command. + + Args: + number: Which command to send. + selector: What to ask about -- a pump, a sensor, or whatever the command selects. + """ + super().__init__(number=number, payload=bytes([selector])) + + def parse(self, answer: bytes) -> int: + """Read the byte out of a reply. + + Args: + answer: The reply answer. + + Returns: + The byte. + """ + return answer[0] + + def parse_flag(self, answer: bytes) -> bool: + """Read the byte out of a reply as a flag. + + Args: + answer: The reply answer. + + Returns: + Whether the byte is set. + """ + return bool(answer[0]) + + +class ByteWrite(Command): + """A command that sends one byte and reads back nothing but the status.""" + + def __init__(self, number: CommandNumber, value: int) -> None: + """Build the command. + + Args: + number: Which command to send. + value: The byte to send. + """ + super().__init__(number=number, payload=bytes([value])) + + +class SelectorWrite(Command): + """A command that sends a selector byte and a value byte, and reads back only the status.""" + + def __init__(self, number: CommandNumber, selector: int, value: int) -> None: + """Build the command. + + Args: + number: Which command to send. + selector: What to write to. + value: The byte to write. + """ + super().__init__(number=number, payload=bytes([selector, value])) + + +@dataclass +class SyringeBox: + """What syringe box is fitted. + + Attributes: + box_type: Which kind of box it is. + box_size: How many bottles it holds. + """ + + box_type: int + box_size: int + + +class GetSyringeBoxInfo(Command): + """Read which syringe box is fitted.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.GET_SYRINGE_BOX_INFO) + + def parse(self, answer: bytes) -> SyringeBox: + """Read the two bytes out of a reply. + + Args: + answer: The reply answer. + + Returns: + The box type and size. + + Raises: + ValueError: If the reply is too short. + """ + if len(answer) < 2: + raise ValueError(f"syringe box reply is {len(answer)} bytes, expected 2") + return SyringeBox(box_type=answer[0], box_size=answer[1]) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/diagnostics.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/diagnostics.py new file mode 100644 index 00000000000..52c0d3c99f9 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/diagnostics.py @@ -0,0 +1,66 @@ +"""Commands that exercise the instrument rather than read from it. + +Each one answers only when it has finished, so each carries a timeout long enough for the motion +it performs. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.serialization.command import Command +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber + +_RESET_TIMEOUT = 30.0 +_SELF_CHECK_TIMEOUT = 90.0 +_HOME_TIMEOUT = 32.0 + + +class ResetInstrument(Command): + """Reset the instrument.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.RESET_INSTRUMENT, timeout=_RESET_TIMEOUT) + + +class RunSelfCheck(Command): + """Run the instrument's self-check. + + The result is the status rather than an answer: a passing check reports success, and a failing + one reports the fault as its status code. + """ + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.RUN_SELF_CHECK, timeout=_SELF_CHECK_TIMEOUT) + + +class HomeVerifyMotors(Command): + """Home a motor and verify that it reached its home position.""" + + def __init__(self, home_type: int, motor: int) -> None: + """Build the command. + + Args: + home_type: Which homing routine to run. + motor: Which motor to home. + """ + super().__init__( + number=CommandNumber.HOME_VERIFY_MOTORS, + payload=bytes([home_type, motor]), + timeout=_HOME_TIMEOUT, + ) + + +class SetSensorEnabled(Command): + """Switch a sensor on or off.""" + + def __init__(self, sensor: int, enabled: bool) -> None: + """Build the command. + + Args: + sensor: Which sensor to switch. + enabled: Whether it should be on. + """ + super().__init__( + number=CommandNumber.SET_SENSOR_ENABLED, payload=bytes([sensor, 1 if enabled else 0]) + ) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/queries.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/queries.py new file mode 100644 index 00000000000..d7a6e8bed7f --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/queries.py @@ -0,0 +1,110 @@ +"""Commands that read what the instrument is and what it is doing.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pylabrobot.agilent.biotek.lhc.serialization.command import Command +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber + +_SERIAL_NUMBER_LENGTH = 24 +_VERSION_RECORD_LENGTH = 46 +_PING_TIMEOUT = 5.0 + + +class GetSerialNumber(Command): + """Read the instrument's serial number.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.GET_SERIAL_NUMBER, answer_length=_SERIAL_NUMBER_LENGTH) + + def parse(self, answer: bytes) -> str: + """Read the serial number out of a reply. + + Args: + answer: The reply answer. + + Returns: + The serial number, without trailing padding. + """ + return answer.decode("latin-1").strip() + + +class Ping(Command): + """Ask whether anything is listening. + + Only the status matters. The timeout is short so that an absent instrument fails quickly, and a + reply proves something is on the line but not what it is -- reading the firmware version is what + proves that. + """ + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.PING, timeout=_PING_TIMEOUT) + + +@dataclass +class FirmwareVersion: + """The firmware version record, whose halves are the instrument's two processors. + + Attributes: + part_number: The firmware part number, which says which instrument this build is for. + software_version: The firmware version. + ui_checksum: Checksum of the user-interface processor's firmware. + mc_checksum: Checksum of the motor controller's firmware. + data_version: Version of the instrument's settings data. + ui_version: Version of the user-interface processor's firmware. + mc_version: Version of the motor controller's firmware. + """ + + part_number: str + software_version: str + ui_checksum: str + mc_checksum: str + data_version: str + ui_version: str + mc_version: str + + +class GetFirmwareVersion(Command): + """Read the firmware version record.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__( + number=CommandNumber.GET_BASECODE_VERSION, answer_length=_VERSION_RECORD_LENGTH + ) + + def parse(self, answer: bytes) -> FirmwareVersion: + """Read the record out of a reply. + + Args: + answer: The reply answer. + + Returns: + The record, whose fields are fixed-width and run together. + + Raises: + ValueError: If the reply is too short to hold the record. + """ + if len(answer) < _VERSION_RECORD_LENGTH: + raise ValueError( + f"firmware version record is {len(answer)} bytes, expected {_VERSION_RECORD_LENGTH}" + ) + text = answer.decode("latin-1") + widths = (7, 8, 4, 4, 5, 3, 3) + fields = [] + at = 0 + for width in widths: + fields.append(text[at : at + width]) + at += width + return FirmwareVersion( + part_number=fields[0], + software_version=fields[1], + ui_checksum="0x" + fields[2], + mc_checksum="0x" + fields[3], + data_version=fields[4], + ui_version=fields[5], + mc_version=fields[6], + ) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/run_control.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/run_control.py new file mode 100644 index 00000000000..2550b462e12 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/run_control.py @@ -0,0 +1,129 @@ +"""Commands that open a batch, run steps inside it and report on them.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.status.activity import Activity +from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState +from pylabrobot.agilent.biotek.lhc.serialization.command import Command +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber + +_INIT_TIMEOUT = 32.0 +_EXIT_TIMEOUT = 60.0 +_STATUS_LENGTH = 7 + + +class InitProtocol(Command): + """Open a batch, telling the instrument which plate it is working with. + + The instrument homes its motors before answering, so this takes a while. A successful call is + what puts the instrument in a state where steps may be sent. + """ + + def __init__(self, plate_type: PlateType) -> None: + """Build the command. + + Args: + plate_type: The plate the batch runs on. + """ + super().__init__( + number=CommandNumber.INIT_PROTOCOL, + payload=bytes([int(plate_type)]), + timeout=_INIT_TIMEOUT, + ) + + +class ExitProtocol(Command): + """Close a batch.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.EXIT_PROTOCOL, timeout=_EXIT_TIMEOUT) + + +class AbortStep(Command): + """Stop the running step.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.ABORT_STEP) + + +class PauseStep(Command): + """Pause the running step.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.PAUSE_STEP) + + +class ResumeStep(Command): + """Resume a paused step.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.RESUME_STEP) + + +class RunStep(Command): + """Run one step. + + The frame is the same for every step type; only the command number and the payload differ. The + reply comes as soon as the instrument has accepted the step, so a status poll is what says when + it has finished. + """ + + def __init__(self, number: CommandNumber, plate_type: PlateType, payload: bytes) -> None: + """Build the command. + + Args: + number: The command that runs this step type. + plate_type: The plate the batch runs on. + payload: The step's own encoded parameters. + """ + super().__init__(number=number, payload=bytes([int(plate_type)]) + payload) + + +@dataclass +class RunStatus: + """What a status poll reports. + + Attributes: + state: What the instrument is doing. + remaining: Seconds left in the current phase, or 0 when nothing is counting down. + activity: Which timed phase the step is in. + """ + + state: RunState = RunState.READY + remaining: int = 0 + activity: Activity = Activity.NONE + + +class GetProtocolStatus(Command): + """Ask whether the running step has finished.""" + + def __init__(self) -> None: + """Build the command.""" + super().__init__(number=CommandNumber.GET_PROTOCOL_STATUS) + + def parse(self, answer: bytes) -> RunStatus: + """Read the status out of a reply. + + Args: + answer: The reply answer: two bytes of state, four of remaining seconds, one of activity. + + Returns: + The status. + + Raises: + ValueError: If the reply is too short, or reports a state or activity that does not exist. + """ + if len(answer) < _STATUS_LENGTH: + raise ValueError(f"status reply is {len(answer)} bytes, expected {_STATUS_LENGTH}") + return RunStatus( + state=RunState(int.from_bytes(answer[0:2], "little", signed=True)), + remaining=int.from_bytes(answer[2:6], "little"), + activity=Activity(answer[6]), + ) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/frame.py b/pylabrobot/agilent/biotek/lhc/serialization/frame.py new file mode 100644 index 00000000000..b563d85d786 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/serialization/frame.py @@ -0,0 +1,105 @@ +"""The frame every command is sent in. + +An eleven-byte header followed by a payload:: + + [0] start marker + [1] version marker + [2-3] command number + [4] constant + [5-6] reserved + [7-8] payload length + [9-10] checksum + [11..] payload + +Every multi-byte field is little-endian, and the checksum covers the first nine header bytes plus +the whole payload. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +HEADER_LENGTH = 11 +START_MARKER = 0x01 +VERSION_MARKER = 0x02 +HEADER_CONSTANT = 0x01 + +STATUS_LENGTH = 2 +"""How many bytes of a reply payload are the instrument's status word.""" + + +def checksum(header: bytes, payload: bytes = b"") -> int: + """The frame checksum. + + Args: + header: The header, whose first nine bytes are covered. + payload: The payload, all of which is covered. + + Returns: + The two's complement of the sum, truncated to sixteen bits. + """ + return -(sum(header[:9]) + sum(payload)) & 0xFFFF + + +@dataclass +class Header: + """The eleven bytes in front of a payload. + + Attributes: + number: Which command this is. + payload_length: How many bytes follow the header. + check: The frame checksum. + reserved: Reserved, zero in every frame seen so far. + start: The start marker. + version: The version marker. + constant: A byte that is the same in every frame. + """ + + number: int = 0 + payload_length: int = 0 + check: int = 0 + reserved: int = 0 + start: int = START_MARKER + version: int = VERSION_MARKER + constant: int = HEADER_CONSTANT + + def to_bytes(self) -> bytes: + """Pack the header. + + Returns: + Eleven bytes. + """ + raw = bytearray(HEADER_LENGTH) + raw[0] = self.start + raw[1] = self.version + raw[2:4] = self.number.to_bytes(2, "little") + raw[4] = self.constant + raw[5:7] = self.reserved.to_bytes(2, "little") + raw[7:9] = self.payload_length.to_bytes(2, "little") + raw[9:11] = self.check.to_bytes(2, "little") + return bytes(raw) + + @classmethod + def from_bytes(cls, raw: bytes) -> Header: + """Unpack a header. + + Args: + raw: Eleven bytes. + + Returns: + The header. + """ + return cls( + start=raw[0], + version=raw[1], + number=int.from_bytes(raw[2:4], "little"), + constant=raw[4], + reserved=int.from_bytes(raw[5:7], "little"), + payload_length=int.from_bytes(raw[7:9], "little"), + check=int.from_bytes(raw[9:11], "little"), + ) + + @property + def is_valid(self) -> bool: + """Whether this looks like a reply header at all, which is what its first byte says.""" + return self.start in (START_MARKER, VERSION_MARKER) From 2343eb07ed5f3b8074f639163569205e2ff453d5 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Sun, 30 Aug 2026 10:20:41 +0200 Subject: [PATCH 03/19] splitted peri dispense on whether RAD exists or not --- .../biotek/lhc/protocols/steps/__init__.py | 13 +- .../lhc/protocols/steps/step_interface.py | 8 +- .../lhc/protocols/steps/steps/__init__.py | 40 ++++- .../steps/steps/manifold_aspirate.py | 5 +- .../steps/steps/manifold_auto_clean.py | 5 +- .../steps/steps/manifold_dispense.py | 5 +- .../protocols/steps/steps/manifold_prime.py | 5 +- .../protocols/steps/steps/manifold_wash.py | 15 +- .../protocols/steps/steps/peri_dispense.py | 98 ++++------- .../lhc/protocols/steps/steps/peri_prime.py | 13 +- .../steps/peri_random_access_dispense.py | 159 ++++++++++++++++++ .../steps/steps/peri_wash_aspirate.py | 5 +- .../steps/steps/peri_wash_dispense.py | 5 +- .../lhc/protocols/steps/steps/shake_soak.py | 5 +- .../protocols/steps/steps/strip_aspirate.py | 5 +- .../protocols/steps/steps/strip_dispense.py | 5 +- .../lhc/protocols/steps/steps/strip_prime.py | 5 +- .../lhc/protocols/steps/steps/strip_wash.py | 15 +- .../protocols/steps/steps/syringe_dispense.py | 5 +- .../protocols/steps/steps/syringe_prime.py | 5 +- .../lhc/protocols/steps/steps/wash_1536.py | 13 +- .../biotek/lhc/serialization/__init__.py | 2 + .../lhc/serialization/command_numbers.py | 37 ++-- 23 files changed, 291 insertions(+), 182 deletions(-) create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/__init__.py index d75337b2328..b028755f6c5 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/__init__.py @@ -2,16 +2,13 @@ from __future__ import annotations -from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType -from pylabrobot.agilent.biotek.lhc.protocols.steps import definition from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step def step_from_definition(text: str) -> Step: """Read a step of any type back from its definition text. - Which type it is comes from the definition itself. A composite step's parts are separated - before the step type is read, so the type of the whole is what decides. + Which class it needs comes from the definition itself. Args: text: The ``|``-separated definition, or the ``#``-joined parts of a composite one. @@ -22,13 +19,9 @@ def step_from_definition(text: str) -> Step: Raises: ValueError: If the definition names no known step type, or does not have that type's layout. """ - from pylabrobot.agilent.biotek.lhc.protocols.steps.steps import STEP_CLASSES + from pylabrobot.agilent.biotek.lhc.protocols.steps.steps import step_class_for_definition - found, start = definition.fields(text.split("#")[0]) - step_type = StepType(int(found[start])) - if step_type not in STEP_CLASSES: - raise ValueError(f"no step class for {step_type.name}") - return STEP_CLASSES[step_type].from_definition(text) + return step_class_for_definition(text).from_definition(text) __all__ = ["Step", "step_from_definition"] diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py index c5b2978a7c5..a487c702465 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py @@ -25,15 +25,9 @@ class Step(abc.ABC): step_type: ClassVar[StepType] @abc.abstractmethod - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Most step types ignore the settings; the peristaltic ones use them to decide whether the - instrument reads the random-access fields at all. - - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition, opening with the format marker and the step type. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/__init__.py index 8c84f4fdf06..c0937896707 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/__init__.py @@ -6,7 +6,9 @@ from __future__ import annotations from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps import peri_dispense from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_auto_clean import ( ManifoldAutoClean, @@ -17,6 +19,9 @@ from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_purge import PeriPurge +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_random_access_dispense import ( + PeriRandomAccessDispense, +) from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_aspirate import PeriWashAspirate from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_dispense import PeriWashDispense from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak @@ -48,7 +53,38 @@ StepType.PERI_WASH_ASPIRATE: PeriWashAspirate, StepType.PERI_WASH_DISPENSE: PeriWashDispense, } -"""Which class implements each step type.""" +"""Which class implements each step type. + +A peristaltic dispense is stored as two different layouts under one step type; which class a +particular definition needs is :func:`step_class_for_definition`. +""" + + +def step_class_for_definition(text: str) -> type[Step]: + """Which class reads a particular definition. + + The step type decides, except for a peristaltic dispense: dispensing at random access is stored + as an ordinary dispense with three more fields, so the field count is what tells the two apart. + + Args: + text: The ``|``-separated definition, or the ``#``-joined parts of a composite one. + + Returns: + The class to read it with. + + Raises: + ValueError: If the definition names no known step type, or no class implements it. + """ + found, start = definition.fields(text.split(_PART_SEPARATOR)[0]) + step_type = StepType(int(found[start])) + if step_type not in STEP_CLASSES: + raise ValueError(f"no step class for {step_type.name}") + if step_type is StepType.PERI_DISPENSE and len(found) - start > peri_dispense.DEFINITION_FIELDS: + return PeriRandomAccessDispense + return STEP_CLASSES[step_type] + + +_PART_SEPARATOR = "#" __all__ = [ "STEP_CLASSES", @@ -60,6 +96,7 @@ "PeriDispense", "PeriPrime", "PeriPurge", + "PeriRandomAccessDispense", "PeriWashAspirate", "PeriWashDispense", "ShakeSoak", @@ -70,4 +107,5 @@ "SyringeDispense", "SyringePrime", "Wash1536", + "step_class_for_definition", ] diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py index f8f6b625283..ce5ea428096 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py @@ -52,12 +52,9 @@ class ManifoldAspirate(Step): columns: WellMask = field(default_factory=WellMask.all_columns) in_wash: bool = False - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition, with the column selection only when the step stands alone. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py index 839b6d3c334..2e2de8b0a75 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py @@ -34,12 +34,9 @@ class ManifoldAutoClean(Step): buffer: Buffer = "A" duration: int = 3600 - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py index 7f279a62ac6..81d49722d84 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py @@ -50,15 +50,12 @@ class ManifoldDispense(Step): check_buffer: bool = True check_volume: bool = True - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. The pre-dispense count is not stored by this step type, and neither validation flag is stored at all: a step read back from a protocol file checks both. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py index 32d527463c3..8f65d62dda2 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py @@ -40,12 +40,9 @@ class ManifoldPrime(Step): low_flow_path_volume: int = 5_000 submerge: Submerge = field(default_factory=Submerge) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py index 3d6f4b7ea08..1a87ab7a884 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py @@ -56,14 +56,11 @@ class ManifoldWash(Step): shake_soak: ShakeSoak = field(default_factory=ShakeSoak) final_aspirate: ManifoldAspirate = field(default_factory=lambda: ManifoldAspirate(in_wash=True)) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. The wash's own fields come first, then its five steps, all joined by ``#``. - Args: - settings: What the instrument has fitted. - Returns: The definition. """ @@ -74,11 +71,11 @@ def to_definition(self, settings: InstrumentSettings) -> str: return _PART_SEPARATOR.join( [ head, - self.bottom_wash.to_definition(settings), - self.aspirate.to_definition(settings), - self.dispense.to_definition(settings), - self.shake_soak.to_definition(settings), - self.final_aspirate.to_definition(settings), + self.bottom_wash.to_definition(), + self.aspirate.to_definition(), + self.dispense.to_definition(), + self.shake_soak.to_definition(), + self.final_aspirate.to_definition(), ] ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py index 264320a837f..2084bcbf2d2 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py @@ -19,32 +19,30 @@ from pylabrobot.agilent.biotek.lhc.protocols.steps import definition from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step -from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( - PreDispense, - RandomAccess, - WellVolumeMap, -) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import PreDispense from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning -_PAYLOAD_LENGTH = 23 -_RANDOM_ACCESS_PAYLOAD_LENGTH = 66 -_DEFINITION_FIELDS = 13 +PAYLOAD_LENGTH = 23 +DEFINITION_FIELDS = 13 -_NO_CASSETTE_REQUIREMENT = 255 -_NO_PUMP = 0 +NO_CASSETTE_REQUIREMENT = 255 +NO_PUMP = 0 -_BYTE_TO_CASSETTE_TYPE: dict[int, CassetteType] = { +BYTE_TO_CASSETTE_TYPE: dict[int, CassetteType] = { value: key for key, value in CASSETTE_TYPE_TO_BYTE.items() } -_BYTE_TO_PERI_PUMP: dict[int, PeriPump] = {value: key for key, value in PERI_PUMP_TO_BYTE.items()} -_PERI_FLOW_RATES: dict[str, PeriFlowRate] = {"Low": "Low", "Medium": "Medium", "High": "High"} +BYTE_TO_PERI_PUMP: dict[int, PeriPump] = {value: key for key, value in PERI_PUMP_TO_BYTE.items()} +PERI_FLOW_RATES: dict[str, PeriFlowRate] = {"Low": "Low", "Medium": "Medium", "High": "High"} @dataclass class PeriDispense(Step): """Dispense a volume into every selected well from a peristaltic pump. + Dispensing into individually chosen wells instead is a different step, with a different payload + and a different command: :class:`~.peri_random_access_dispense.PeriRandomAccessDispense`. + Attributes: volume: Volume per tube in µL. flow_rate: How fast to dispense. @@ -54,8 +52,6 @@ class PeriDispense(Step): columns: Which columns to dispense into. rows: Which rows to dispense into. peri_pump: Which pump to drive, or None to leave the choice to the instrument. - random_access: Whether the step dispenses at random access, and with which head. - well_volumes: Per-well volumes, sent instead of the selections on a random-access dispense. """ step_type: ClassVar[StepType] = StepType.PERI_DISPENSE @@ -70,36 +66,27 @@ class PeriDispense(Step): columns: WellMask = field(default_factory=WellMask.all_columns) rows: WellMask = field(default_factory=WellMask.all_rows) peri_pump: PeriPump | None = "Primary" - random_access: RandomAccess = field(default_factory=RandomAccess) - well_volumes: WellVolumeMap = field(default_factory=WellVolumeMap) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - The pre-dispense flow rate is not stored by this step type. The random-access fields are - written only when the step uses random access and the instrument reads them. - - Args: - settings: What the instrument has fitted. + The pre-dispense flow rate is not stored by this step type. Returns: The ``|``-separated definition. """ cassette = ( - _NO_CASSETTE_REQUIREMENT + NO_CASSETTE_REQUIREMENT if self.cassette_type is None else CASSETTE_TYPE_TO_BYTE[self.cassette_type] ) - pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] - text = ( + pump = NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + return ( f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.volume}|{self.flow_rate}" f"|{cassette}|{self.positioning.to_definition()}|{self.pre_dispense.enabled}" f"|{self.pre_dispense.volume}|{self.pre_dispense.count}" f"|{self.columns.to_definition()}|{self.rows.to_definition()}|{pump}" ) - if self.random_access.enabled and settings.supports_random_access_tail: - text += f"|{self.random_access.to_definition()}|{self.well_volumes.to_definition()}" - return text @classmethod def from_definition(cls, text: str) -> PeriDispense: @@ -113,9 +100,9 @@ def from_definition(cls, text: str) -> PeriDispense: Raises: ValueError: If the definition does not have this step type's layout, or names a flow rate, - cassette or pump that does not exist. + cassette or pump that does not exist. A definition carrying random-access fields is a + different step type's and is rejected here. """ - own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS) ( volume, flow_rate, @@ -129,18 +116,17 @@ def from_definition(cls, text: str) -> PeriDispense: columns, rows, pump, - ) = own[:12] - tail = own[12:] - if flow_rate not in _PERI_FLOW_RATES: + ) = definition.own_fields(text, cls.step_type, DEFINITION_FIELDS) + if flow_rate not in PERI_FLOW_RATES: raise ValueError(f"unknown peristaltic flow rate: {flow_rate!r}") - if int(cassette) != _NO_CASSETTE_REQUIREMENT and int(cassette) not in _BYTE_TO_CASSETTE_TYPE: + if int(cassette) != NO_CASSETTE_REQUIREMENT and int(cassette) not in BYTE_TO_CASSETTE_TYPE: raise ValueError(f"unknown cassette type: {cassette!r}") - if int(pump) != _NO_PUMP and int(pump) not in _BYTE_TO_PERI_PUMP: + if int(pump) != NO_PUMP and int(pump) not in BYTE_TO_PERI_PUMP: raise ValueError(f"unknown peristaltic pump: {pump!r}") return cls( volume=definition.number(volume, 16), - flow_rate=_PERI_FLOW_RATES[flow_rate], - cassette_type=_BYTE_TO_CASSETTE_TYPE.get(int(cassette)), + flow_rate=PERI_FLOW_RATES[flow_rate], + cassette_type=BYTE_TO_CASSETTE_TYPE.get(int(cassette)), positioning=Positioning( z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) ), @@ -151,21 +137,15 @@ def from_definition(cls, text: str) -> PeriDispense: ), columns=WellMask.from_definition(columns), rows=WellMask.from_definition(rows), - peri_pump=_BYTE_TO_PERI_PUMP.get(int(pump)), - random_access=RandomAccess.from_definition(*tail[:2]) if tail else RandomAccess(), - well_volumes=(WellVolumeMap.from_definition(tail[2]) if len(tail) > 2 else WellVolumeMap()), + peri_pump=BYTE_TO_PERI_PUMP.get(int(pump)), ) def to_bytes(self, settings: InstrumentSettings) -> bytes: """Encode the step as the payload of the command that runs it. - There are two payloads. A random-access dispense carries the per-well volumes and no - selections; an ordinary one carries the selections and no volumes, with the row selection - inverted. With the wider dispense offsets fitted the ordinary payload drops the cassette - requirement and spends the two bytes on a wider X offset instead. - - An instrument that cannot store the random-access fields never learns the step uses random - access, so it gets the ordinary payload however the step is configured. + With the wider dispense offsets fitted the payload drops the cassette requirement and spends + the two bytes on a wider X offset instead. The row selection is sent inverted, so a selected + row contributes a zero bit. Args: settings: What the instrument has fitted. @@ -173,31 +153,19 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: Returns: The payload. """ - pump = _NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] - head = u16(self.volume) + u8(PERI_FLOW_RATE_TO_BYTE[self.flow_rate]) - if self.random_access.enabled and settings.supports_random_access_tail: - return pad( - head - + i16(self.positioning.x) - + i8(self.positioning.y) - + i16(self.positioning.z) - + u16(self.pre_dispense.wire_volume) - + u8(self.pre_dispense.count) - + bytes(value for row in self.well_volumes.values for value in row) - + u8(pump), - _RANDOM_ACCESS_PAYLOAD_LENGTH, - ) + pump = NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] if settings.advanced_dispense_offsets: offsets = i16(self.positioning.x) else: cassette = ( - _NO_CASSETTE_REQUIREMENT + NO_CASSETTE_REQUIREMENT if self.cassette_type is None else CASSETTE_TYPE_TO_BYTE[self.cassette_type] ) offsets = u8(cassette) + i8(self.positioning.x) return pad( - head + u16(self.volume) + + u8(PERI_FLOW_RATE_TO_BYTE[self.flow_rate]) + offsets + i8(self.positioning.y) + i16(self.positioning.z) @@ -206,5 +174,5 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: + self.columns.to_bytes() + self.rows.to_bytes_inverted() + u8(pump), - _PAYLOAD_LENGTH, + PAYLOAD_LENGTH, ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py index 6048cdff6bd..9c976851715 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py @@ -62,15 +62,12 @@ class PeriPrime(Step): peri_pump: PeriPump | None = "Primary" random_access: RandomAccess = field(default_factory=RandomAccess) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - The random-access fields are written only when the step uses random access and the - instrument reads them; a model that predates random access cannot parse a definition - carrying them. - - Args: - settings: What the instrument has fitted. + The random-access fields are written only when the step uses random access. A model old + enough not to read them cannot run such a step at all, which is validation's to reject rather + than something to paper over here by dropping the fields. Returns: The ``|``-separated definition. @@ -85,7 +82,7 @@ def to_definition(self, settings: InstrumentSettings) -> str: f"{definition.FORMAT_MARKER}|{self.step_type.value}|{self.fixed_volume}|{self.volume}" f"|{self.duration}|{self.flow_rate}|{self.home_when_finished}|{cassette}|{pump}" ) - if self.random_access.enabled and settings.supports_random_access_tail: + if self.random_access.enabled: text += f"|{self.random_access.to_definition()}" return text diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py new file mode 100644 index 00000000000..433aaa41f25 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py @@ -0,0 +1,159 @@ +"""Dispensing into individually chosen wells through a peristaltic pump.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_head import ( + CASSETTE_HEAD_TO_BYTE, + CassetteHead, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import PERI_FLOW_RATE_TO_BYTE +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.packing import i8, i16, pad, u8, u16 +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( + PreDispense, + WellVolumeMap, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import ( + BYTE_TO_CASSETTE_TYPE, + BYTE_TO_PERI_PUMP, + DEFINITION_FIELDS, + NO_CASSETTE_REQUIREMENT, + NO_PUMP, + PERI_FLOW_RATES, + PeriDispense, +) + +_PAYLOAD_LENGTH = 66 +_TAIL_FIELDS = 3 +_NO_CASSETTE_HEAD = 255 + +_BYTE_TO_CASSETTE_HEAD: dict[int, CassetteHead] = { + value: key for key, value in CASSETTE_HEAD_TO_BYTE.items() +} + + +@dataclass +class PeriRandomAccessDispense(PeriDispense): + """Dispense a volume of its own into each of sixteen chosen wells from a peristaltic pump. + + Stored as an ordinary peristaltic dispense with the head and the per-well volumes appended, so + the column and row selections are stored too even though the instrument is sent the per-well + volumes instead. It runs as a different command. + + A model old enough not to read the extra fields cannot run this step; validation rejects it + rather than quietly running it as an ordinary dispense. + + Attributes: + cassette_head: How the fitted cassette's tubes map onto wells, or None when the step names no + head. + well_volumes: The volume for each well, three tubes deep for each of sixteen wells. + """ + + step_type: ClassVar[StepType] = StepType.PERI_DISPENSE + + cassette_head: CassetteHead | None = None + well_volumes: WellVolumeMap = field(default_factory=WellVolumeMap) + + def to_definition(self) -> str: + """Write the step as the text a protocol file stores. + + Returns: + The ``|``-separated definition: an ordinary dispense followed by the head and the per-well + volumes. + """ + head = ( + _NO_CASSETTE_HEAD if self.cassette_head is None else CASSETTE_HEAD_TO_BYTE[self.cassette_head] + ) + return f"{super().to_definition()}|True|{head}|{self.well_volumes.to_definition()}" + + @classmethod + def from_definition(cls, text: str) -> PeriRandomAccessDispense: + """Read the step back from its definition text. + + Args: + text: The ``|``-separated definition. + + Returns: + The step. + + Raises: + ValueError: If the definition does not have this step type's layout, or names a flow rate, + cassette or pump that does not exist. + """ + own = definition.own_fields(text, cls.step_type, DEFINITION_FIELDS + _TAIL_FIELDS) + ( + volume, + flow_rate, + cassette, + z, + x, + y, + pre_enabled, + pre_volume, + pre_count, + columns, + rows, + pump, + _random_access, + head, + well_volumes, + ) = own + if flow_rate not in PERI_FLOW_RATES: + raise ValueError(f"unknown peristaltic flow rate: {flow_rate!r}") + if int(cassette) != NO_CASSETTE_REQUIREMENT and int(cassette) not in BYTE_TO_CASSETTE_TYPE: + raise ValueError(f"unknown cassette type: {cassette!r}") + if int(pump) != NO_PUMP and int(pump) not in BYTE_TO_PERI_PUMP: + raise ValueError(f"unknown peristaltic pump: {pump!r}") + return cls( + volume=definition.number(volume, 16), + flow_rate=PERI_FLOW_RATES[flow_rate], + cassette_type=BYTE_TO_CASSETTE_TYPE.get(int(cassette)), + positioning=Positioning( + z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + ), + pre_dispense=PreDispense( + enabled=definition.flag(pre_enabled), + volume=definition.number(pre_volume, 16), + count=definition.number(pre_count, 8), + ), + columns=WellMask.from_definition(columns), + rows=WellMask.from_definition(rows), + peri_pump=BYTE_TO_PERI_PUMP.get(int(pump)), + cassette_head=_BYTE_TO_CASSETTE_HEAD.get(int(head)), + well_volumes=WellVolumeMap.from_definition(well_volumes), + ) + + def to_bytes(self, settings: InstrumentSettings) -> bytes: + """Encode the step as the payload of the command that runs it. + + The payload carries the per-well volumes where an ordinary dispense carries its selections, + and has one layout: the wider dispense offsets make no difference to it, since the X offset is + two bytes wide here either way. + + Args: + settings: What the instrument has fitted. + + Returns: + The payload. + """ + pump = NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] + return pad( + u16(self.volume) + + u8(PERI_FLOW_RATE_TO_BYTE[self.flow_rate]) + + i16(self.positioning.x) + + i8(self.positioning.y) + + i16(self.positioning.z) + + u16(self.pre_dispense.wire_volume) + + u8(self.pre_dispense.count) + + bytes(value for row in self.well_volumes.values for value in row) + + u8(pump), + _PAYLOAD_LENGTH, + ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py index 27654c95a62..e910a469cce 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py @@ -47,12 +47,9 @@ class PeriWashAspirate(Step): columns: WellMask = field(default_factory=WellMask.all_columns) rows: WellMask = field(default_factory=WellMask.all_rows) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py index 4c882c80ac3..7554dba1241 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py @@ -52,14 +52,11 @@ class PeriWashDispense(Step): columns: WellMask = field(default_factory=WellMask.all_columns) rows: WellMask = field(default_factory=WellMask.all_rows) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. The pre-dispense volume is stored whether or not pre-dispensing is switched on. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py index 5a1e2f912f9..f5d333a24a1 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py @@ -40,15 +40,12 @@ class ShakeSoak(Step): shake: Shake = field(default_factory=Shake) soak: Soak = field(default_factory=Soak) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. ``enabled`` is not stored: a step read back from a protocol file is always enabled, and a wash supplies the flag from its own stage selection. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py index 8eda1f01065..a951c6e2a11 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py @@ -51,12 +51,9 @@ class StripAspirate(Step): rows: WellMask = field(default_factory=WellMask.all_rows) in_wash: bool = False - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition, with the selections only when the step stands alone. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py index ad01ad3ac7c..0f49ab12f34 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py @@ -60,15 +60,12 @@ class StripDispense(Step): is_bottom_wash: bool = False force_pre_dispense: bool = False - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. The four flags describing the step's place in a wash are not stored; the owning wash sets them again when it uses the step. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition, with the selections only when the step stands alone. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py index 042b7fc2b90..2082b9b49dc 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py @@ -34,12 +34,9 @@ class StripPrime(Step): cycles: int = 2 submerge: Submerge = field(default_factory=Submerge) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py index 6e9d1ebfeed..9fae1d00fdd 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py @@ -57,12 +57,9 @@ class StripWash(Step): shake_soak: ShakeSoak = field(default_factory=ShakeSoak) final_aspirate: StripAspirate = field(default_factory=lambda: StripAspirate(in_wash=True)) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Args: - settings: What the instrument has fitted. - Returns: The definition, its six parts joined by ``#``. """ @@ -74,11 +71,11 @@ def to_definition(self, settings: InstrumentSettings) -> str: return _PART_SEPARATOR.join( [ head, - self.bottom_wash.to_definition(settings), - self.aspirate.to_definition(settings), - self.dispense.to_definition(settings), - self.shake_soak.to_definition(settings), - self.final_aspirate.to_definition(settings), + self.bottom_wash.to_definition(), + self.aspirate.to_definition(), + self.dispense.to_definition(), + self.shake_soak.to_definition(), + self.final_aspirate.to_definition(), ] ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py index 879cf564a85..3807bc523d4 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py @@ -59,14 +59,11 @@ class SyringeDispense(Step): rows: WellMask = field(default_factory=WellMask.all_rows) selects_rows: bool = False - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. The pre-dispense flow rate is not stored by this step type. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition, with the row selection only on an instrument that selects rows. diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py index 2d20d4bc9b7..4b1666b33ad 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py @@ -52,12 +52,9 @@ class SyringePrime(Step): submerge: Submerge = field(default_factory=Submerge) syringe_bottle: SyringeBottle = "A1" - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. - Args: - settings: What the instrument has fitted. - Returns: The ``|``-separated definition. """ diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py index b7dcfa20a29..761cfa4ab1a 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py @@ -59,15 +59,12 @@ class Wash1536(Step): shake_soak: ShakeSoak = field(default_factory=ShakeSoak) final_aspirate: ManifoldAspirate = field(default_factory=lambda: ManifoldAspirate(in_wash=True)) - def to_definition(self, settings: InstrumentSettings) -> str: + def to_definition(self) -> str: """Write the step as the text a protocol file stores. The stage flags are not contiguous here: the volume and count used before washing sit between the first two. - Args: - settings: What the instrument has fitted. - Returns: The definition, its five parts joined by ``#``. """ @@ -80,10 +77,10 @@ def to_definition(self, settings: InstrumentSettings) -> str: return _PART_SEPARATOR.join( [ head, - self.aspirate.to_definition(settings), - self.dispense.to_definition(settings), - self.shake_soak.to_definition(settings), - self.final_aspirate.to_definition(settings), + self.aspirate.to_definition(), + self.dispense.to_definition(), + self.shake_soak.to_definition(), + self.final_aspirate.to_definition(), ] ) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/__init__.py b/pylabrobot/agilent/biotek/lhc/serialization/__init__.py index 88341c44a6f..b33eb6ef5b6 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/__init__.py @@ -4,6 +4,7 @@ from pylabrobot.agilent.biotek.lhc.serialization.command import Command from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import ( + COMMAND_BY_STEP_CLASS, STEP_TYPE_TO_COMMAND, CommandNumber, command_for_step, @@ -16,6 +17,7 @@ ) __all__ = [ + "COMMAND_BY_STEP_CLASS", "HEADER_LENGTH", "STATUS_LENGTH", "STEP_TYPE_TO_COMMAND", diff --git a/pylabrobot/agilent/biotek/lhc/serialization/command_numbers.py b/pylabrobot/agilent/biotek/lhc/serialization/command_numbers.py index b7945296a87..a9d1ed86a05 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/command_numbers.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/command_numbers.py @@ -4,10 +4,11 @@ import enum -from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step -from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_random_access_dispense import ( + PeriRandomAccessDispense, +) class CommandNumber(enum.IntEnum): @@ -114,32 +115,34 @@ class CommandNumber(enum.IntEnum): } """Which command runs each step type. -A peristaltic dispense is the one step type whose command depends on how the step is configured; -:func:`command_for_step` is what applies that. +Two step classes share the peristaltic dispense step type; :data:`COMMAND_BY_STEP_CLASS` is what +separates them. """ -def command_for_step(step: Step, settings: InstrumentSettings) -> CommandNumber: - """Which command runs a particular step. +COMMAND_BY_STEP_CLASS: dict[type[Step], CommandNumber] = { + PeriRandomAccessDispense: CommandNumber.PERI_DISPENSE_RANDOM_ACCESS, +} +"""Which command runs a step class whose type alone does not decide it. + +A peristaltic dispense at random access shares its step type with an ordinary one but carries a +different payload and runs as a different command. +""" + - A peristaltic dispense at random access carries a different payload and is sent as a different - command. Every other step type is decided by its type alone. +def command_for_step(step: Step) -> CommandNumber: + """Which command runs a particular step. Args: step: The step to send. - settings: What the instrument has fitted, which decides whether it can run the step at random - access at all. Returns: The command number. Raises: - KeyError: If no command runs this step type. + KeyError: If no command runs this step. """ - if ( - isinstance(step, PeriDispense) - and step.random_access.enabled - and settings.supports_random_access_tail - ): - return CommandNumber.PERI_DISPENSE_RANDOM_ACCESS + by_class = COMMAND_BY_STEP_CLASS.get(type(step)) + if by_class is not None: + return by_class return STEP_TYPE_TO_COMMAND[step.step_type] From 0f53a4098004ec393733be2fab9bd44f7831ab76 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Mon, 31 Aug 2026 10:47:32 +0200 Subject: [PATCH 04/19] added validation and error handling --- mypy.ini | 3 + .../agilent/biotek/lhc/comm/ftdi_transport.py | 14 +- .../agilent/biotek/lhc/devices/build_rules.py | 110 +++ .../lhc/enums/plates/plate_restriction.py | 15 + .../biotek/lhc/error_handling/__init__.py | 61 ++ .../biotek/lhc/error_handling/error_codes.py | 582 +++++++++++++ .../biotek/lhc/error_handling/errors.py | 354 ++++++++ .../biotek/lhc/plate_geometry/__init__.py | 13 + .../biotek/lhc/plate_geometry/plate_record.py | 40 + .../biotek/lhc/plate_geometry/plates.py | 106 +++ .../agilent/biotek/lhc/protocols/protocol.py | 117 +++ .../read_write_utilities/__init__.py | 14 + .../read_write_utilities/encryption.py | 75 ++ .../read_write_utilities/protocol_file.py | 225 +++++ .../lhc/protocols/validation/__init__.py | 13 + .../biotek/lhc/protocols/validation/checks.py | 593 +++++++++++++ .../lhc/protocols/validation/configuration.py | 334 ++++++++ .../lhc/protocols/validation/plate_rules.py | 69 ++ .../lhc/protocols/validation/protocol_pass.py | 190 +++++ .../biotek/lhc/protocols/validation/report.py | 99 +++ .../lhc/protocols/validation/reservations.py | 286 +++++++ .../lhc/protocols/validation/step_checks.py | 795 ++++++++++++++++++ 22 files changed, 4106 insertions(+), 2 deletions(-) create mode 100644 pylabrobot/agilent/biotek/lhc/devices/build_rules.py create mode 100644 pylabrobot/agilent/biotek/lhc/enums/plates/plate_restriction.py create mode 100644 pylabrobot/agilent/biotek/lhc/error_handling/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/error_handling/error_codes.py create mode 100644 pylabrobot/agilent/biotek/lhc/error_handling/errors.py create mode 100644 pylabrobot/agilent/biotek/lhc/plate_geometry/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/plate_geometry/plate_record.py create mode 100644 pylabrobot/agilent/biotek/lhc/plate_geometry/plates.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/protocol.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/encryption.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/protocol_file.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/validation/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/validation/checks.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/validation/configuration.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/validation/plate_rules.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/validation/protocol_pass.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/validation/report.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/validation/reservations.py create mode 100644 pylabrobot/agilent/biotek/lhc/protocols/validation/step_checks.py diff --git a/mypy.ini b/mypy.ini index 614e3886553..38ea1bbf955 100644 --- a/mypy.ini +++ b/mypy.ini @@ -21,3 +21,6 @@ ignore_missing_imports = True [mypy-opentrons_shared_data.*] ignore_missing_imports = True + +[mypy-Crypto.*] +ignore_missing_imports = True diff --git a/pylabrobot/agilent/biotek/lhc/comm/ftdi_transport.py b/pylabrobot/agilent/biotek/lhc/comm/ftdi_transport.py index 7096c1c8de4..46c7772e4b0 100644 --- a/pylabrobot/agilent/biotek/lhc/comm/ftdi_transport.py +++ b/pylabrobot/agilent/biotek/lhc/comm/ftdi_transport.py @@ -14,6 +14,11 @@ import logging +from pylabrobot.agilent.biotek.lhc.error_handling.errors import ( + WRITE_FAILED, + for_info, + info_for, +) from pylabrobot.io.ftdi import FTDI from .transport import BAUDRATE, DATA_BITS, DEFAULT_READ_TIMEOUT, STOP_BITS, Transport @@ -108,11 +113,16 @@ async def write(self, data: bytes) -> None: data: The bytes to write. Raises: - RuntimeError: If the bridge accepted fewer bytes than it was given. + LinkError: If the bridge accepted fewer bytes than it was given. """ written = await self.io.write(data) if written is not None and written != len(data): - raise RuntimeError(f"[{self.port}] wrote {written} of {len(data)} bytes") + raise for_info( + info_for( + WRITE_FAILED, + operation=f"write to {self.port}: {written} of {len(data)} bytes accepted", + ) + ) async def read(self, num_bytes: int = 1) -> bytes: """Read whatever has already arrived, up to ``num_bytes`` bytes. diff --git a/pylabrobot/agilent/biotek/lhc/devices/build_rules.py b/pylabrobot/agilent/biotek/lhc/devices/build_rules.py new file mode 100644 index 00000000000..aeab768bb52 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/build_rules.py @@ -0,0 +1,110 @@ +"""Which validation rules each instrument model runs. + +The models do not check a protocol identically: newer firmware adds rules that older firmware does +not have, and one model has rules of its own. What is common to all of them lives in +``protocols.validation``; what differs is here, because it is a per-model fact, and validation +reads it rather than owning it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType + + +@dataclass(frozen=True) +class BuildRules: + """What a model checks over and under the common rule set. + + Attributes: + basecode_step_types: Whether the firmware variant limits which step types may run. + peri_pump_exclusivity: Whether an ordinary peristaltic step and a peristaltic wash step are + forbidden from sharing a pump. + absent_checks: Rules this model does not have, named by the code they would report. Each is + skipped, and validation carries on to the next rule. + """ + + basecode_step_types: bool = False + peri_pump_exclusivity: bool = False + absent_checks: frozenset[int] = field(default_factory=frozenset) + + +COMMON = BuildRules() +"""The rules every model runs.""" + +MULTIFLO = BuildRules( + absent_checks=frozenset( + {24834, 24928, 24929, 24930, 24933, 24934, 24935, 24936, 24937, 24944, 24945} + ) +) +"""The oldest firmware, which predates three features and so lacks eleven rules. + +They cover single-well dispensing, the strip washer, the small-plate syringe manifolds and the +mini-tube carrier. Most are unreachable on that model anyway, since it offers neither strip steps +nor random access; the single-well one is not, and an ordinary peristaltic dispense reaches it. +""" + +MULTIFLO_FX = BuildRules(basecode_step_types=True, peri_pump_exclusivity=True) +"""The newest firmware, which adds two rules of its own.""" + +_BY_FAMILY: dict[InstrumentFamily, BuildRules] = { + InstrumentFamily.MULTIFLO: MULTIFLO, + InstrumentFamily.MULTIFLO_FX: MULTIFLO_FX, +} + +_PERISTALTIC = frozenset( + {StepType.PERI_DISPENSE, StepType.PERI_PRIME, StepType.PERI_PURGE, StepType.SHAKE_SOAK} +) +_STRIP = frozenset( + {StepType.STRIP_WASH, StepType.STRIP_ASPIRATE, StepType.STRIP_DISPENSE, StepType.STRIP_PRIME} +) +_SYRINGE = frozenset({StepType.SYRINGE_DISPENSE, StepType.SYRINGE_PRIME}) +_PERI_WASH = frozenset({StepType.PERI_WASH_ASPIRATE, StepType.PERI_WASH_DISPENSE}) + +BASECODE_STEP_TYPES: dict[Basecode, frozenset[StepType]] = { + Basecode.BASIC: _PERISTALTIC | _STRIP | _SYRINGE, + Basecode.RANDOM_ACCESS: _PERISTALTIC | _SYRINGE, + Basecode.PERI_WASH: _PERISTALTIC | _PERI_WASH, +} +"""Which step types each firmware variant offers. + +The peristaltic steps and the pause run on every variant and the plate-washer steps on none, since +a model with a firmware variant has a strip washer rather than a plate washer. A step type that is +not listed cannot run. +""" + + +def rules_for(family: InstrumentFamily) -> BuildRules: + """The rules a model runs. + + Args: + family: Which instrument model this is. + + Returns: + Its rules, or the common set for a model whose firmware has not been examined. + """ + return _BY_FAMILY.get(family, COMMON) + + +def basecode_for(settings: InstrumentSettings) -> Basecode: + """Which firmware variant an instrument is running. + + This follows from what the instrument reports as fitted rather than being asked for directly. + Note the order: an instrument reporting both single-well dispensing and peristaltic washing is + taken to be running the random-access variant. + + Args: + settings: What the instrument has fitted. + + Returns: + The variant. + """ + if settings.single_well_enabled: + return Basecode.RANDOM_ACCESS + if settings.peri_wash_enabled: + return Basecode.PERI_WASH + return Basecode.BASIC diff --git a/pylabrobot/agilent/biotek/lhc/enums/plates/plate_restriction.py b/pylabrobot/agilent/biotek/lhc/enums/plates/plate_restriction.py new file mode 100644 index 00000000000..f61fd96fe97 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/enums/plates/plate_restriction.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import enum + + +class PlateRestriction(enum.IntEnum): + """Which plates an instrument has been configured to accept. + + Set on the instrument rather than by a protocol. A step naming a plate the instrument does not + accept is rejected before it runs. + """ + + ALLOW_ALL = 0 + ALLOW_96_WELL_ONLY = 1 + ALLOW_1536_WELL_ONLY = 2 diff --git a/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py b/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py new file mode 100644 index 00000000000..e527ece3ba1 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py @@ -0,0 +1,61 @@ +"""What an error code means, and the exceptions raised for one.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.error_handling.errors import ( + NO_ERROR, + NOT_ACKNOWLEDGED, + PORT_WOULD_NOT_OPEN, + REPLY_TIMED_OUT, + UNKNOWN_CODE, + WRITE_FAILED, + AbortedError, + BiotekError, + ErrorInfo, + ErrorKind, + FirmwareError, + FluidicsError, + LinkError, + MotorError, + RejectedError, + SensorError, + ServiceTestError, + StorageError, + classify, + describe, + error_message, + fail, + for_info, + info_for, + normalize, + raise_for_status, +) + +__all__ = [ + "NO_ERROR", + "NOT_ACKNOWLEDGED", + "PORT_WOULD_NOT_OPEN", + "REPLY_TIMED_OUT", + "UNKNOWN_CODE", + "WRITE_FAILED", + "AbortedError", + "BiotekError", + "ErrorInfo", + "ErrorKind", + "FirmwareError", + "FluidicsError", + "LinkError", + "MotorError", + "RejectedError", + "SensorError", + "ServiceTestError", + "StorageError", + "classify", + "describe", + "error_message", + "fail", + "for_info", + "info_for", + "normalize", + "raise_for_status", +] diff --git a/pylabrobot/agilent/biotek/lhc/error_handling/error_codes.py b/pylabrobot/agilent/biotek/lhc/error_handling/error_codes.py new file mode 100644 index 00000000000..540fc4152e7 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/error_handling/error_codes.py @@ -0,0 +1,582 @@ +"""The instrument's error vocabulary: what each code means, in the instrument's own words. + +Data only; the decoding over it lives in :mod:`.errors`. + +A code is not a flat index. Three layouts share the space: + +* On the ``0x02xx``-``0x07xx`` motion faults the low nibble is a motor number, and which motor it + names depends on the instrument family -- number 3 is the washer head on an EL406 and peri-pump 2 + on a MultiFlo. Those messages carry a ``{motor}`` placeholder for :data:`MOTOR_NAMES` to fill, + and :data:`FAMILY_MESSAGES` holds the few whose whole sentence differs per family. +* On the ``0x81xx`` link faults the high nibble names the link and the low nibble the failure on + it, so ``0x8104`` and ``0x8114`` are the same checksum error on two different links. Those come + from :data:`LINK_NAMES` and :data:`LINK_FAULTS` rather than from :data:`MESSAGES`. +* Everything else is a plain code with one message. + +Serial-link statuses share this space with the faults the instrument reports, so they are rows in +:data:`MESSAGES` like any other: ``0x6045`` a failed write, ``0x6048`` an unacknowledged message, +``0x6053`` a reply that never came, ``0x6058`` a port that would not open. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily + +# The motors each family has, indexed by the motor number a motion fault carries. +MOTOR_NAMES: dict[InstrumentFamily, tuple[str, ...]] = { + InstrumentFamily.EL406: ( + "Carrier Motor X", + "Carrier Motor Y", + "Dispense Head Motor", + "Washer Head Motor", + "Syringe A Motor", + "Syringe B Motor", + "Peri-pump 1 Motor", + ), + InstrumentFamily.MULTIFLO: ( + "Carrier Motor X", + "Carrier Motor Y", + "Dispense Head Motor", + "Peri-pump 2 Motor", + "Syringe A Motor", + "Syringe B Motor", + "Peri-pump 1 Motor", + ), + InstrumentFamily.MODEL_405_TS: ( + "Carrier Motor X", + "Carrier Motor Y", + "Washer Head Motor", + "Level Sense Y Motor", + ), + InstrumentFamily.MULTIFLO_FX: ( + "Carrier Motor X", + "Carrier Motor Y", + "Dispense Head Motor", + "Peri-pump 2 Motor", + "Syringe A Motor", + "Syringe B Motor", + "Peri-pump 1 Motor", + "Washer Syringe Motor", + "Washer Aspirate/Peri-pump RAD Head Motor", + "Peri-pump RAD Motor Y", + ), +} + +# Filled in for a motor number the instrument family does not have. +UNNAMED_MOTOR = "" + + +MESSAGES: dict[int, str] = { + # no error + 0x0000: "No instrument error.", + # the task was stopped + 0x0100: "Task was aborted.", + # motion: sensor transitions and calibration jigs + 0x0200: "Carrier Motor X didn’t find opto sensor transition.", + 0x0201: "Carrier Motor Y didn’t find opto sensor transition.", + 0x0202: "{motor} didn’t find opto sensor transition.", + 0x0203: "{motor} didn’t find opto sensor transition.", + 0x0204: "{motor} didn’t find opto sensor transition.", + 0x0205: "{motor} didn’t find opto sensor transition.", + 0x0206: "{motor} didn’t find opto sensor transition.", + 0x0207: "{motor} didn’t find opto sensor transition.", + 0x0208: "{motor} didn’t find opto sensor transition.", + 0x0209: "{motor} didn’t find opto sensor transition.", + 0x020A: " didn’t find opto sensor transition.", + 0x020B: " didn’t find opto sensor transition.", + 0x020C: " didn’t find opto sensor transition.", + 0x020D: " didn’t find opto sensor transition.", + 0x020E: " didn’t find opto sensor transition.", + 0x020F: " didn’t find opto sensor transition.", + 0x0210: "Carrier Motor X didn’t find opto sensor transition.", + 0x0211: "Carrier Motor Y didn’t find opto sensor transition.", + 0x0212: "{motor} didn’t find opto sensor transition.", + 0x0213: "{motor} didn’t find opto sensor transition.", + 0x0214: "{motor} didn’t find opto sensor transition.", + 0x0215: "{motor} didn’t find opto sensor transition.", + 0x0216: "{motor} didn’t find opto sensor transition.", + 0x0217: "{motor} didn’t find opto sensor transition.", + 0x0218: "{motor} didn’t find opto sensor transition.", + 0x0219: "{motor} didn’t find opto sensor transition.", + 0x021A: " didn’t find opto sensor transition.", + 0x021B: " didn’t find opto sensor transition.", + 0x021C: " didn’t find opto sensor transition.", + 0x021D: " didn’t find opto sensor transition.", + 0x021E: " didn’t find opto sensor transition.", + 0x021F: " didn’t find opto sensor transition.", + 0x0220: "Carrier Motor X didn’t find autocal jig.", + 0x0221: "Carrier Motor Y didn’t find autocal jig.", + 0x0222: "{motor} didn’t find autocal jig.", + 0x0224: "{motor} didn’t find autocal jig.", + 0x0225: "{motor} didn’t find autocal jig.", + 0x0226: "{motor} didn’t find autocal jig.", + 0x0227: "{motor} didn’t find autocal jig.", + 0x0228: "{motor} didn’t find autocal jig.", + 0x0229: "{motor} didn’t find autocal jig.", + 0x022A: " didn’t find autocal jig.", + 0x022B: " didn’t find autocal jig.", + 0x022C: " didn’t find autocal jig.", + 0x022D: " didn’t find autocal jig.", + 0x022E: " didn’t find autocal jig.", + 0x022F: " didn’t find autocal jig.", + # motion: interlocks and covers + 0x0300: "Carrier Motor X interlock safety switch open.", + 0x0301: "Carrier Motor Y interlock safety switch open.", + 0x0302: "{motor} interlock safety switch open.", + 0x0304: "{motor} interlock safety switch open.", + 0x0305: "{motor} interlock safety switch open.", + 0x0307: "{motor} interlock safety switch open.", + 0x0308: "{motor} interlock safety switch open.", + 0x0309: "{motor} interlock safety switch open.", + 0x030A: " interlock safety switch open.", + 0x030B: " interlock safety switch open.", + 0x030C: " interlock safety switch open.", + 0x030D: " interlock safety switch open.", + 0x030E: " interlock safety switch open.", + 0x030F: " interlock safety switch open.", + # motion: moves that did not complete + 0x0400: "Carrier Motor X failed positional verify.", + 0x0401: "Carrier Motor Y failed positional verify.", + 0x0402: "{motor} failed positional verify.", + 0x0403: "{motor} failed positional verify.", + 0x0404: "{motor} failed positional verify.", + 0x0405: "{motor} failed positional verify.", + 0x0406: "{motor} failed positional verify.", + 0x0407: "{motor} failed positional verify.", + 0x0408: "{motor} failed positional verify.", + 0x0409: "{motor} failed positional verify.", + 0x040A: " failed positional verify.", + 0x040B: " failed positional verify.", + 0x040C: " failed positional verify.", + 0x040D: " failed positional verify.", + 0x040E: " failed positional verify.", + 0x040F: " failed positional verify.", + # motion: positions not reached or not held + 0x0500: "Carrier Motor X not homed successfully.", + 0x0501: "Carrier Motor Y not homed successfully.", + 0x0502: "{motor} not homed successfully.", + 0x0503: "{motor} not homed successfully.", + 0x0504: "{motor} not homed successfully.", + 0x0505: "{motor} not homed successfully.", + 0x0506: "{motor} not homed successfully.", + 0x0507: "{motor} not homed successfully.", + 0x0508: "{motor} not homed successfully.", + 0x0509: "{motor} not homed successfully.", + 0x050A: " not homed successfully.", + 0x050B: " not homed successfully.", + 0x050C: " not homed successfully.", + 0x050D: " not homed successfully.", + 0x050E: " not homed successfully.", + 0x050F: " not homed successfully.", + # motion: profiles and step limits + 0x0600: "Carrier Motor X currently in use.", + 0x0601: "Carrier Motor Y currently in use.", + 0x0602: "{motor} currently in use.", + 0x0603: "{motor} currently in use.", + 0x0604: "{motor} currently in use.", + 0x0605: "{motor} currently in use.", + 0x0606: "{motor} currently in use.", + 0x0607: "{motor} currently in use.", + 0x0608: "{motor} currently in use.", + 0x0609: "{motor} currently in use.", + 0x060A: " currently in use.", + 0x060B: " currently in use.", + 0x060C: " currently in use.", + 0x060D: " currently in use.", + 0x060E: " currently in use.", + 0x060F: " currently in use.", + # motion: motor drive + 0x0700: " doesn't exist.", + 0x0701: " doesn't exist.", + 0x0702: " doesn't exist.", + 0x0703: " doesn't exist.", + 0x0704: " doesn't exist.", + 0x0705: " doesn't exist.", + 0x0706: " doesn't exist.", + 0x0707: " doesn't exist.", + 0x0708: " doesn't exist.", + 0x0709: " doesn't exist.", + 0x070A: " doesn't exist.", + 0x070B: " doesn't exist.", + 0x070C: " doesn't exist.", + 0x070D: " doesn't exist.", + 0x070E: " doesn't exist.", + 0x070F: " doesn't exist.", + # level sensing + 0x0900: "Calibration failed due to limit or process errors.", + # requests refused by the instrument + 0x0A00: "Invalid plate type selected.", + # calibration data + 0x0C01: "Requested config/autocal data absent.", + 0x0C02: "Calculated checksum didn't match checksum saved.", + 0x0C03: "Config parameter out of range.", + # bootcode and processors + 0x1001: "Bootcode checksum error at powerup.", + 0x1002: "Bootcode error unknown.", + 0x1003: "Bootcode page program error.", + 0x1004: "Bootcode block size error.", + 0x1005: "Bootcode invalid processor signature.", + 0x1006: "Bootcode memory exceeded.", + 0x1007: "Bootcode invalid slave port.", + 0x1008: "Bootcode invalid slave response.", + 0x1009: "Bootcode invalid processor detected.", + 0x1010: "Bootcode download checksum error.", + 0x1250: "UI Processor internal RAM failure.", + 0x1251: "MC Processor internal RAM failure.", + # fluid path: syringes, pumps, valves and bottles + 0x1300: "Invalid syringe", + 0x1301: "Syringe is not connected", + 0x1302: "Unable to initialize syringe", + 0x1303: "Unable to initialize syringe sensor clear", + 0x1304: "Syringe dispense volume out of calibration range", + 0x1305: "Invalid syringe operation", + 0x1306: "Syringe A FMEA check error", + 0x1307: "Syringe B FMEA check error", + 0x1355: "The Peri-pump module is not configured", + 0x1356: "Invalid Peri-pump dispense position", + 0x1357: "The second Peri-pump module is required", + 0x1358: "This instrument does not support 0.5 µL Peri-pump dispense volume", + 0x1400: "No vacuum pressure detected after turning on the vacuum pump", + 0x1401: "The waste bottles must be emptied before continuing", + 0x1402: "The valve to be cycle is invalid", + 0x1403: "The magnet adapter height is out of range", + 0x1404: "Use of the selected plate type is restricted", + 0x1405: "Z Axis height error", + 0x1406: "Invalid Plate type", + 0x1407: "Invalid Step type", + 0x1408: "Invalid plate geometry", + 0x1409: "Invalid carrier type", + 0x1410: "Incompatible hardware configuration", + 0x1411: "Invalid carrier specified", + 0x1412: "Plate clearance error", + 0x1413: "AutoPrime in progress.\r\nPlease wait until AutoPrime completes.", + 0x1414: "AutoPrime is cleaning up.\r\nPlease wait until AutoPrime cleanup completes.", + 0x1415: "An AutoPrime value is out-of-range.", + 0x1416: "Vacuum pressure incorrectly detected prior to starting the vacuum pump.", + 0x1417: "The autocal sensor was not detected in the back of the instrument.", + 0x1430: "Strip washer syringe FMEA check error.", + 0x1431: "Strip washer aspirate head not installed.", + 0x1432: "Strip washer syringe box not connected.", + 0x1433: "Bad step type pointer passed in when finding plate heights.", + 0x1500: ( + "There was no buffer fluid present at the start of a manifold-based protocol or at " + "the start of an individual step." + ), + 0x1501: "There was no buffer fluid present immediately before the manifold dispense sequence.", + 0x1502: "The buffer valve selection is invalid", + 0x1503: ( + "The requested volume to be dispensed through the manifold is smaller than the " + "minimum volume that will be dispensed by the time the DC dispense pump turns on " + "and the dispense valve is opened." + ), + 0x1504: ( + "There was no buffer fluid detected flowing through the manifold tubing during a " + "manifold dispense/prime operation." + ), + 0x1505: ( + "There was no buffer fluid present at the end of a manifold-based protocol or at " + "the end of an individual step." + ), + 0x1506: "The requested carrier Y-axis position is out of range.", + 0x1514: "The Ultrasonic Advantage hardware is not configured.", + 0x1515: "The low-flow cell-wash hardware is not configured", + 0x1516: "Vacuum pressure issue for vacuum filtration", + 0x1517: "The software could not read the vacuum filter hardware consistently.", + # on-board protocol storage + 0x1600: "Ran out of on-board storage space", + 0x1601: "Ran out of on-board storage space for P-Dispense steps", + 0x1602: "Ran out of on-board storage space for P-Prime steps", + 0x1603: "Ran out of on-board storage space for P-Purge steps", + 0x1604: "Ran out of on-board storage space for S-Dispense steps", + 0x1605: "Ran out of on-board storage space for S-Prime steps", + 0x1606: "Ran out of on-board storage space for W-Wash steps", + 0x1607: "Ran out of on-board storage space for W-Aspirate steps", + 0x1608: "Ran out of on-board storage space for W-Dispense steps", + 0x1609: "Ran out of on-board storage space for W-Prime steps", + 0x160A: "Ran out of on-board storage space for W-AutoClean steps", + 0x160B: "Ran out of on-board storage space for Shake/Soak steps", + 0x160C: "Ran out of on-board storage space for 1536 Wash steps", + 0x160D: "Invalid Step Type encountered", + 0x1610: "Protocol transfer failed.", + # sensors and their calibration + 0x1700: "Level sensor not installed.", + 0x1701: "Level sensor framing error.", + 0x1702: "Level sensor timing error.", + 0x1703: "Level sensor unknown command.", + 0x1704: "Level sensor parameter error.", + 0x1705: "Level sensor address error.", + 0x1706: "Level sensor error detected but not classified.", + 0x1707: "Level sensor response cmd char != request cmd char.", + 0x1708: "Level sensor command response not long enough.", + 0x1709: "Level sensor command response address not equal to '0'.", + 0x170A: "Level sensor command response checksum error.", + 0x170B: "Level sensor timeout while looking for SOF char.", + 0x170C: "Level sensor RX error - framing error.", + 0x170D: "Level sensor RX error in Mode parameter.", + 0x170E: "Level sensor RX error in Format parameter.", + 0x170F: "Level sensor RX error in Sensitivity parameter.", + 0x1710: "Level sensor RX error in Average parameter.", + 0x1711: "Level sensor RX error in Temp Comp parameter.", + 0x1712: "Level sensor RX error in SDC parameter.", + 0x1713: "Level sensor RX error in SDE parameter.", + 0x1714: "Level sensor RX error in setting configuration.", + 0x1715: "Level sensor error in converting a read to a level.", + 0x1717: "Level sensor echo range error.", + 0x1718: "Level sensor echo width error.", + 0x171A: "Level sensor - motor axis incorrect in FindAxisCenter().", + 0x171C: "In FindAxisCenter() initial read not > threshold.", + 0x171E: "Level sensor - no well edge found - reached step limit.", + 0x171F: "Level sensor - repeated FindAxisCenter() did not converge.", + 0x1720: "Level sensor corner cal memory checksum error.", + 0x1721: "Level sensor A1 cal memory checksum error.", + 0x1722: "Level sensor - carrier height wrong - plate test > 30mm.", + 0x1723: "A plate read was started but not finished successfully.", + 0x1724: "7 reads did not come up with at least 3 good ones.", + 0x1725: "The range of the smallest 3 reads (of 7) was > 0.5mm.", + 0x1726: "Input to McReqLvlSnsZPosn() out of range.", + 0x1727: "The correction factor is out of range.", + 0x1729: "FindLsyParkPosn() could not find the park position.", + 0x172A: "Read Plate or Read One command to MC - invalid Read Type.", + 0x172B: "Row or column was 0 - must start at 1.", + 0x172C: "Well test error - previous config not loaded.", + 0x172D: "Well test error - wrong well.", + 0x1732: "Level sensor - config memory checksum error.", + 0x1733: "Well positions have not been calculated.", + 0x1734: "Level sense correction factor not been calculated.", + 0x1735: "Doing a Carrier Test - no previous Z-Axis cal data in EEPROM.", + 0x1736: "Attempted a Z-axis wash head move with Sensor Y not at park posn.", + 0x1737: "Plate test did not find a plate.", + 0x1738: "Level sensor - config memory checksum error.", + 0x1739: "MC Not all level sensor cal and config data has been loaded.", + 0x173A: "Level sensor transmission buffer should be empty before sending a command.", + 0x173B: "Level sensor - Z-Cal, Z=0, current to cal > +/-0.75mm.", + 0x173C: "Level sensor - Z-Cal, Z=0, factory cal < 23mm or > 29mm.", + 0x173D: "Level sensor - Z-Cal, Z=0, post to pre > +/-0.3mm.", + 0x173E: "Level sensor - Z-Cal, Z=0, < 15.0mm.", + 0x173F: "7 reads did not produce at least 6 good ones.", + # plate handling + 0x2400: "Parameter limit exceeded.", + # on-board protocol files + 0x4000: "Program locked so operation denied.", + 0x4010: "Program non erasable so delete denied.", + 0x4020: "Bad checksum when reading program from eeprom.", + 0x4030: "Program not found.", + 0x4040: "Can't save program because no space is available.", + 0x4050: "Program run cancelled by user.", + # the serial link, and requests this instrument cannot run + 0x6000: "General communication error during download.", + 0x6001: "COM port created by USB converter no longer active.", + 0x6002: "Invalid basecode part number; this is not the correct instrument", + 0x6003: "Invalid Basecode Data Version; basecode needs to be updated.", + 0x6004: "No rows are selected for the specified plate type.", + 0x6005: "Invalid row selection value (must be 0 or 1).", + 0x6006: "This instrument can only process 96-well plates.", + 0x6007: "This instrument can only process 1536-well plates.", + 0x6008: "The 8-tube Syringe Manifold can only be used with 96 and 384-well plates.", + 0x6009: "The 96-tube fixed Washer Manifold can only be used with 96-well plates.", + 0x6010: "The data is invalid or out-of-range.", + 0x6011: "This step type can not be downloaded.", + 0x6012: ( + "Illegal characters in protocol name; valid characters are letters, numbers, " "spaces, or _-%&" + ), + 0x6013: "The protocol name length must be 16 characters or less.", + 0x6015: "The specified volume exceeds the cassette maximum limit.", + 0x6016: "Volume is out-of-range.", + 0x6017: "Invalid Flow rate.", + 0x6018: "Invalid number of pre-dispenses.", + 0x6019: "Invalid Horizontal dispense position.", + 0x6020: "Invalid dispense height.", + 0x6021: "Invalid plate clear height.", + 0x6022: "Invalid column selection value (must be 0 or 1).", + 0x6023: "Invalid protocol step type.", + 0x6024: "The Definition String contains invalid data.", + 0x6025: "Manifold conflict between protocol requirements and instrument configuration.", + 0x6026: "Buffer Switching module is required because protocol steps specify different buffers.", + 0x6027: "The Syringe Dispenser is required because the protocol contains Syringe steps.", + 0x6028: ( + "The vacuum filtration carrier must be installed to run this protocol. Make sure " + "the plate carrier setting is correct." + ), + 0x6029: "Required cassette does not match other required or installed cassette.", + 0x6030: "Invalid cassette type was specified.", + 0x6031: "The 96-tube Washer Manifold is required for 96 well plates.", + 0x6032: "'Transfer Protocols' is not supported in this version of the software.", + 0x6033: "This step is not supported for 1536 well plates.", + 0x6034: "The 16-tube Syringe Manifold can not be used for this plate type.", + 0x6035: "The 32-tube Syringe Manifold can only be used for 1536 well plates.", + 0x6036: "The 128-tube Washer Manifold is required for 1536 well plates.", + 0x6037: "The 128-tube Washer Manifold can only be used for 1536 well plates.", + 0x6038: "This step only applies to 1536 well plates.", + 0x6039: "Conflicting Column Selection.", + 0x6040: "Invalid baud rate", + 0x6041: "Invalid data bits selection", + 0x6042: "Invalid stop bits selection", + 0x6043: "Invalid parity selection", + 0x6044: "Serial port error", + 0x6045: "Serial write error on selected COM Port", + 0x6046: "Serial read error on selected COM Port", + 0x6047: "Checksum error", + 0x6048: "Serial NAK error", + 0x6049: "Excess data, or not enough data, received", + 0x6050: "Invalid message header", + 0x6051: "Invalid message object", + 0x6052: "Invalid message body size", + 0x6053: ( + "Serial message timeout. \r\n" + "The most common reasons for this are:\r\n" + "- a previous hardware error has occurred. Please re-boot.\r\n" + "- two communication cables are plugged in. Unplug one.\r\n" + "- the BioStack is not responding. Re-boot the BioStack.\r\n" + "- the basecode software is in an unknown state." + ), + 0x6054: "Port handle error", + 0x6055: "Read timeout value is invalid", + 0x6056: "Unauthorized to open the COM port", + 0x6057: "Out of range parameter for the open port function", + 0x6058: "Unable to open the COM port", + 0x6059: "Unable to clear the transmission buffer", + 0x6060: "Unable to close the port", + 0x6061: "Port is no longer available", + 0x6062: "Unhandled exception while transmitting message.", + 0x6063: "The selected plate type is not allowed with this protocol step", + 0x6064: "The protocol specifies a peri-pump that is not available", + 0x6065: "Too few data bytes received from the instrument", + 0x6066: "Ultrasonic cleaning assembly is not installed", + 0x6067: "The Syringe Box is not compatible with the Syringe Manifold", + 0x6070: "Invalid Syringe specified", + 0x6071: "Invalid number of syringe prime cycles", + 0x6072: "Invalid syringe Aspirate Delay value", + 0x6073: "Invalid X-axis offset value", + 0x6074: "Invalid Y-axis offset value", + 0x6075: "Invalid Z-axis offset value", + 0x6076: "Vacuum Filtration not allowed with 1536 well plates", + 0x6080: "Invalid Peri-pump prime duration", + 0x6085: "Invalid minutes:seconds value", + 0x6086: "Invalid hours:minutes value", + 0x6087: "'Move carrier home' is required if the total Shake/Soak durations exceed 1 minute", + 0x6088: "Invalid Shake/Soak options selected", + 0x6089: "Invalid Shake Intensity selected", + 0x6090: "Invalid Washer buffer selected", + 0x6091: "Invalid Washer Aspirate Delay value", + 0x6092: "Invalid Washer Aspirate Travel Rate value", + 0x6093: "Invalid Wash Cycles value", + 0x6094: "Invalid Wash format selected", + 0x6095: "Invalid 'Sectors to Wash'", + 0x6096: "Delay start of Vacuum is required", + 0x6097: ( + "Syringe Dispense Volume must be an integer.\r\n" + "(decimals allowed only for the 128-pin Syringe manifold)" + ), + 0x6098: "The Peri-pump cannot run with the pump cover open", + 0x6099: "The Peri-pump assembly is not installed", + 0x6101: "The 405 TS does not support downloading basecode from the host.", + 0x6102: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", + # manifold verification results + 0x6110: "The Verify Manifold Test input parameters file was not found.", + 0x6111: "The user data file for the Verify Manifold Test could not be read in.", + 0x6112: "The Verify Manifold Test was stopped by user.", + 0x6113: "The Verify Manifold Test is not supported.", + 0x6120: "The carrier is not level.", + 0x6121: "The test had an aspirate scan error.", + 0x6122: "The test had an dispense scan error.", + 0x6123: "Center of well not found where expected for Verify test plate.", + 0x6124: "Incorrect plate installed for Verify test.", + 0x6125: "The well volume following an aspirate indicates insufficient aspiration.", + 0x6126: "The well volume following a dispense indicates insufficient dispense.", + 0x6127: "Scan data could not be returned from the instrument.", + 0x6128: "Invalid well specified.", + 0x6129: "This Verify Manifold Test step was not performed.", + 0x6150: "The mean Dispense Volume is out of range.", + 0x6151: "The Dispense CV % exceeds the maximum threshold.", + 0x6152: "The Aspirate Rate is below the minimum threshold.", + 0x6160: "This step requires Washer components to be installed and connected.", + 0x6161: "The Strip Washer Manifold and the Plate Type are incompatible.", + 0x6162: "The Strip Washer does not support this Plate Type", + 0x6165: "This Peri-pump does not support single well dispensing.", + 0x6166: "The instrument does not support single well dispensing.", + 0x6167: "The Syringe Manifold can only be used with 6-well plates", + 0x6168: "The Syringe Manifold can only be used with 12-well plates", + 0x6169: "The Syringe Manifold can only be used with 24-well plates", + 0x6170: "The Syringe Manifold can only be used with 48-well plates", + 0x6171: "The Cassette for single well dispensing does not support this plate type", + # firmware, power supplies and internal resources + 0xA000: "Task control block not available.", + 0xA100: " not available.", + 0xA101: " not available.", + 0xA102: " not available.", + 0xA103: " not available.", + 0xA104: " not available.", + 0xA200: "Version strings for multiple uProcessors do not match.", + 0xA300: " power supply level error.", + 0xA301: "+5v logic power supply level error.", + 0xA302: "+24v system/motor power supply level error.", + 0xA303: "Internal +42v PeriPump power supply level error.", + 0xA304: "Internal reference voltage error.", + 0xA305: "External +42v PeriPump power supply level error.", + 0xA400: "malloc failed.", + 0xA500: "multiple tasks attempted to use display simultaneously.", + 0xA600: "Serial eeprom access error.", + 0xA700: "Motor move steps are being truncated based on the profile.", +} + + +# The codes whose sentence differs by family rather than by motor name alone. +FAMILY_MESSAGES: dict[tuple[InstrumentFamily, int], str] = { + (InstrumentFamily.EL406, 0x0223): "Washer Head Motor didn’t find autocal jig.", + (InstrumentFamily.MULTIFLO, 0x0223): "Peri-pump 2 Motor didn’t find autocal jig.", + ( + InstrumentFamily.MODEL_405_TS, + 0x0223, + ): "Level Sense Y Motor didn’t find find park opto sensor transition.", + (InstrumentFamily.MULTIFLO_FX, 0x0223): "Peri-pump 2 Motor didn’t find autocal jig.", + (InstrumentFamily.EL406, 0x0303): "Washer Head Motor interlock safety switch open.", + ( + InstrumentFamily.MULTIFLO, + 0x0303, + ): "Peri-pump 2 Pump Cover is open. Close the Pump Cover and re-run protocol.", + (InstrumentFamily.MODEL_405_TS, 0x0303): "Level Sense Y Motor interlock safety switch open.", + ( + InstrumentFamily.MULTIFLO_FX, + 0x0303, + ): "Peri-pump 2 Pump Cover is open. Close the Pump Cover and re-run protocol.", + ( + InstrumentFamily.EL406, + 0x0306, + ): "Peri-pump 1 Pump Cover is open. Close the Pump Cover and re-run protocol.", + ( + InstrumentFamily.MULTIFLO, + 0x0306, + ): "Peri-pump 1 Pump Cover is open. Close the Pump Cover and re-run protocol.", + (InstrumentFamily.MODEL_405_TS, 0x0306): " interlock safety switch open.", + ( + InstrumentFamily.MULTIFLO_FX, + 0x0306, + ): "Peri-pump 1 Pump Cover is open. Close the Pump Cover and re-run protocol.", +} + + +# The high nibble of a 0x81xx code. A link with no name here contributes no prefix. +LINK_NAMES: dict[int, str] = { + 0x00: "Error communicating with instrument software.", + 0x10: "UI processor to MC processor internal communications error.", + 0x20: "Instrument to BioStack communications error.", +} + +# The low nibble of a 0x81xx code. +LINK_FAULTS: dict[int, str] = { + 0x00: "Message not acknowledged (NAK).", + 0x01: "Timeout while waiting for serial message data.", + 0x02: "Instrument busy and unable to process message.", + 0x03: "Receive buffer overflow error.", + 0x04: "Communication checksum error.", + 0x05: "Invalid structure type in byMsgStructure header field.", + 0x06: "Invalid destination in byMsgDestination header field.", + 0x07: "Message sent to instrument is not supported.", + 0x08: "Message body size exceeds max limit.", + 0x09: "Max number of requests currently running and cannot run the latest request.", + 0x0A: "No request running when response request issued.", + 0x0C: "Response for outstanding request not ready yet.", + 0x0D: "To communicate, the instrument must be at the Main Menu.", + 0x0E: "One or more request parameters are not valid.", + 0x0F: "Command not valid in current state.", +} diff --git a/pylabrobot/agilent/biotek/lhc/error_handling/errors.py b/pylabrobot/agilent/biotek/lhc/error_handling/errors.py new file mode 100644 index 00000000000..96627cb996d --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/error_handling/errors.py @@ -0,0 +1,354 @@ +"""Turning an error code into something a caller can act on. + +An instrument reports failure as a number. :func:`raise_for_status` turns that number into an +exception, so a failure cannot be dropped by not looking at a return value, and the exception's +class says what kind of thing went wrong -- which is what decides the caller's next move. A +:class:`LinkError` is worth retrying, a :class:`MotorError` needs a person, and a +:class:`RejectedError` means the request asks for something this instrument cannot do and will +fail again identically until the request or the instrument's configuration changes. + +Zero is success: it is the instrument's own "no instrument error". +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass + +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.error_handling.error_codes import ( + FAMILY_MESSAGES, + LINK_FAULTS, + LINK_NAMES, + MESSAGES, + MOTOR_NAMES, + UNNAMED_MOTOR, +) + +NO_ERROR = 0x0000 +UNKNOWN_CODE = "No description available." + +# The link failures this package detects on the host side, named so that a caller raising one does +# not have to carry a number of its own. +WRITE_FAILED = 0x6045 +NOT_ACKNOWLEDGED = 0x6048 +REPLY_TIMED_OUT = 0x6053 +PORT_WOULD_NOT_OPEN = 0x6058 + +_LINK_FAULT_RANGE = (0x8100, 0x81FF) +_CODE_MASK = 0xFFFF +_HEX_FROM = 256 + + +class ErrorKind(enum.Enum): + """What kind of thing went wrong. + + The code space is laid out by subsystem, so the code itself says which one failed. See + :data:`KIND_RANGES` for the mapping. + """ + + NONE = "no error" + ABORTED = "the task was stopped" + LINK = "the message did not get through" + MOTOR = "a motor or axis fault" + SENSOR = "a sensor or calibration-data fault" + FLUIDICS = "a syringe, pump, valve or buffer fault" + STORAGE = "on-board protocol storage" + FIRMWARE = "bootcode, processor or power supply" + REJECTED = "the request was refused as invalid" + SERVICE_TEST = "a manifold verification result" + UNKNOWN = "not in the table" + + +# (first, last, kind), inclusive, in the order :func:`classify` tries them. The 0x60xx block is the +# one that mixes concerns: 0x6040-0x6062 and 0x6065 are the serial link, while the rest of the +# block is request validation -- an out-of-range field and "this needs hardware that is not +# fitted" side by side. +KIND_RANGES: tuple[tuple[int, int, ErrorKind], ...] = ( + (0x0000, 0x0000, ErrorKind.NONE), + (0x0100, 0x0100, ErrorKind.ABORTED), + (0x0200, 0x07FF, ErrorKind.MOTOR), + (0x0900, 0x0900, ErrorKind.SENSOR), + (0x0A00, 0x0A00, ErrorKind.REJECTED), + (0x0C00, 0x0CFF, ErrorKind.SENSOR), + (0x1000, 0x12FF, ErrorKind.FIRMWARE), + (0x1300, 0x15FF, ErrorKind.FLUIDICS), + (0x1600, 0x16FF, ErrorKind.STORAGE), + (0x1700, 0x17FF, ErrorKind.SENSOR), + (0x2400, 0x2400, ErrorKind.REJECTED), + (0x4000, 0x40FF, ErrorKind.STORAGE), + (0x6000, 0x6001, ErrorKind.LINK), + (0x6002, 0x6003, ErrorKind.FIRMWARE), + (0x6004, 0x603F, ErrorKind.REJECTED), + (0x6040, 0x6062, ErrorKind.LINK), + (0x6065, 0x6065, ErrorKind.LINK), + (0x6063, 0x610F, ErrorKind.REJECTED), + (0x6110, 0x615F, ErrorKind.SERVICE_TEST), + (0x6160, 0x61FF, ErrorKind.REJECTED), + (0x8100, 0x81FF, ErrorKind.LINK), + (0xA000, 0xA7FF, ErrorKind.FIRMWARE), +) + + +def normalize(code: int) -> int: + """The code as an unsigned 16-bit value. + + Args: + code: A code, which may have been read as a signed 16-bit integer. + + Returns: + The same code, unsigned. + """ + return code & _CODE_MASK if code < 0 else code + + +def classify(code: int) -> ErrorKind: + """Which subsystem a code belongs to. + + Args: + code: The code to classify. + + Returns: + The kind of failure, or :attr:`ErrorKind.UNKNOWN` for a code in no known range. + """ + code = normalize(code) + for first, last, kind in KIND_RANGES: + if first <= code <= last: + return kind + return ErrorKind.UNKNOWN + + +def error_message(code: int, family: InstrumentFamily = InstrumentFamily.EL406) -> str: + """The instrument's sentence for a code. + + The family matters because a motion fault's low nibble is a motor number, and the motors differ + per family. + + Args: + code: The code to look up. + family: The family of the instrument that reported it. + + Returns: + The message, or an empty string for a code with none. + """ + code = normalize(code) + first, last = _LINK_FAULT_RANGE + if first <= code <= last: + fault = LINK_FAULTS.get(code & 0x0F) + if fault is None: + return "" + link = LINK_NAMES.get(code & 0xF0) + return f"{link}\r\n{fault}" if link else fault + override = FAMILY_MESSAGES.get((family, code)) + if override is not None: + return override + message = MESSAGES.get(code) + if message is None: + return "" + names = MOTOR_NAMES[family] + number = code & 0x0F + motor = names[number] if number < len(names) else UNNAMED_MOTOR + return message.replace("{motor}", motor) + + +def describe( + code: int, family: InstrumentFamily = InstrumentFamily.EL406, context: str = "" +) -> str: + """A code and its message as one block of text. + + Args: + code: The code to describe. + family: The family of the instrument that reported it. + context: What was being attempted, appended when given. + + Returns: + The code, its message, and the context. Codes are written in hex above 255 and decimal below, + since the larger ones are bit fields and only read as hex. + """ + number = f"{code:X}" if code >= _HEX_FROM else str(code) + message = error_message(code, family) or UNKNOWN_CODE + return f"Error code: {number}\r\n{message}" + (f"\r\n{context}" if context else "") + + +@dataclass(frozen=True) +class ErrorInfo: + """Everything known about one failure. + + Attributes: + code: The code reported, unsigned. Zero when this package found the failure itself and has no + code behind it. + kind: Which subsystem failed. + message: The instrument's sentence for the code, or an empty string when it has none. + operation: What was being attempted, for a caller that supplied it. + """ + + code: int + kind: ErrorKind + message: str + operation: str = "" + + def __str__(self) -> str: + """The failure as one line. + + Returns: + What was being attempted, what went wrong, and the kind and code it came from. + """ + where = f"{self.operation}: " if self.operation else "" + detail = self.message or UNKNOWN_CODE + return f"{where}{detail} [{self.kind.value}, code {self.code:#06x}]" + + +class BiotekError(RuntimeError): + """A failure the instrument or this package reported. + + Args: + info: What is known about the failure. + """ + + def __init__(self, info: ErrorInfo): + super().__init__(str(info)) + self.info = info + + @property + def code(self) -> int: + """The code reported, unsigned.""" + return self.info.code + + @property + def kind(self) -> ErrorKind: + """Which subsystem failed.""" + return self.info.kind + + @property + def message(self) -> str: + """The instrument's sentence for the code.""" + return self.info.message + + @property + def operation(self) -> str: + """What was being attempted.""" + return self.info.operation + + +class LinkError(BiotekError): + """The message did not reach the instrument, or its reply did not come back intact. + + Whether the instrument acted on the message is unknown, which makes this the one kind where + repeating the same request is reasonable. + """ + + +class AbortedError(BiotekError): + """The task was stopped, either by a request to stop it or from the instrument's keypad.""" + + +class MotorError(BiotekError): + """A motor did not reach or hold a position. Needs a person, not a retry.""" + + +class SensorError(BiotekError): + """A sensor did not read, or its calibration data is missing or out of range.""" + + +class FluidicsError(BiotekError): + """A syringe, pump, valve or buffer fault. + + Includes the conditions an operator can clear, such as waste bottles that must be emptied or an + absent buffer: they are faults of the fluid path rather than of the instrument. + """ + + +class StorageError(BiotekError): + """On-board protocol storage: out of space, locked, not found, or a bad checksum.""" + + +class FirmwareError(BiotekError): + """Bootcode, processor, power supply, or basecode that is the wrong part or too old.""" + + +class RejectedError(BiotekError): + """The instrument refused the request as invalid. + + Either a field is out of range or the request needs hardware that is not fitted. This is the one + kind that is a fault of the request rather than a condition of the instrument, so it will fail + again identically until the request or the instrument's configuration changes. + """ + + +class ServiceTestError(BiotekError): + """A manifold verification test did not pass, or could not be run.""" + + +EXCEPTIONS: dict[ErrorKind, type[BiotekError]] = { + ErrorKind.ABORTED: AbortedError, + ErrorKind.LINK: LinkError, + ErrorKind.MOTOR: MotorError, + ErrorKind.SENSOR: SensorError, + ErrorKind.FLUIDICS: FluidicsError, + ErrorKind.STORAGE: StorageError, + ErrorKind.FIRMWARE: FirmwareError, + ErrorKind.REJECTED: RejectedError, + ErrorKind.SERVICE_TEST: ServiceTestError, +} + + +def info_for( + code: int, family: InstrumentFamily = InstrumentFamily.EL406, operation: str = "" +) -> ErrorInfo: + """Everything known about a code. + + Args: + code: The code reported. + family: The family of the instrument that reported it. + operation: What was being attempted. + + Returns: + The assembled :class:`ErrorInfo`. + """ + code = normalize(code) + return ErrorInfo(code, classify(code), error_message(code, family), operation) + + +def for_info(info: ErrorInfo) -> BiotekError: + """The exception that goes with a failure. + + Args: + info: What is known about the failure. + + Returns: + An exception of the class matching ``info.kind``, already carrying ``info``. Not raised. + """ + return EXCEPTIONS.get(info.kind, BiotekError)(info) + + +def raise_for_status( + code: int, family: InstrumentFamily = InstrumentFamily.EL406, operation: str = "" +) -> None: + """Raise unless a code means success. + + Args: + code: The code reported. + family: The family of the instrument that reported it. + operation: What was being attempted, included in the message. + + Raises: + BiotekError: A subclass matching what failed, unless ``code`` is zero. + """ + if normalize(code) == NO_ERROR: + return + raise for_info(info_for(code, family, operation)) + + +def fail(kind: ErrorKind, message: str, operation: str = "", code: int = NO_ERROR) -> BiotekError: + """The exception for a failure this package found itself. + + Args: + kind: Which subsystem failed. + message: What went wrong. + operation: What was being attempted. + code: The code behind it, when there is one. Zero means there is none. + + Returns: + An exception of the class matching ``kind``. Not raised. + """ + return for_info(ErrorInfo(code, kind, message, operation)) diff --git a/pylabrobot/agilent/biotek/lhc/plate_geometry/__init__.py b/pylabrobot/agilent/biotek/lhc/plate_geometry/__init__.py new file mode 100644 index 00000000000..2db5c95caf2 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/plate_geometry/__init__.py @@ -0,0 +1,13 @@ +"""The plates an instrument works with, and the geometry it works them by.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.plate_geometry.plates import ( + DISPENSER_PLATES, + WASHER_PLATES, + find, + plates_for, +) + +__all__ = ["DISPENSER_PLATES", "WASHER_PLATES", "PlateRecord", "find", "plates_for"] diff --git a/pylabrobot/agilent/biotek/lhc/plate_geometry/plate_record.py b/pylabrobot/agilent/biotek/lhc/plate_geometry/plate_record.py new file mode 100644 index 00000000000..38a9eb32db2 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/plate_geometry/plate_record.py @@ -0,0 +1,40 @@ +"""What the instrument knows about a plate.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType + + +@dataclass(frozen=True) +class PlateRecord: + """The geometry an instrument works a plate with. + + The three heights are in motor steps and are the nominal positions a step's own offsets are + measured from: a dispenser measures from :attr:`dispenser_height`, a wash manifold from + :attr:`manifold_dispense_height` when dispensing and :attr:`manifold_aspirate_height` when + aspirating. + + Attributes: + plate_type: Which plate this is. + label: The plate's name. + dispenser_height: Nominal dispensing height for a dispenser. + manifold_dispense_height: Nominal dispensing height for a wash manifold. + manifold_aspirate_height: Nominal aspirating height for a wash manifold. + columns: How many columns the plate has. + rows: How many rows the plate has. + """ + + plate_type: PlateType + label: str + dispenser_height: int + manifold_dispense_height: int + manifold_aspirate_height: int + columns: int + rows: int + + @property + def wells(self) -> int: + """How many wells the plate has.""" + return self.columns * self.rows diff --git a/pylabrobot/agilent/biotek/lhc/plate_geometry/plates.py b/pylabrobot/agilent/biotek/lhc/plate_geometry/plates.py new file mode 100644 index 00000000000..71766c768f0 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/plate_geometry/plates.py @@ -0,0 +1,106 @@ +"""Which plates each instrument family works with, and how it works them. + +The same plates appear twice, with different heights. An instrument with a wash manifold measures +a dispense and an aspirate from different positions; a dispenser-only instrument has no wash +manifold, so its two dispensing heights are the same number. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord + +WASHER_PLATES: tuple[PlateRecord, ...] = ( + PlateRecord(PlateType.PLATE_1536_WELL, "1536 Well Plate", 250, 94, 42, 48, 32), + PlateRecord(PlateType.PLATE_1536_FLANGE, "1536 Flanged Plate", 196, 93, 13, 48, 32), + PlateRecord(PlateType.PLATE_384_WELL, "384 Well Plate", 333, 120, 22, 24, 16), + PlateRecord(PlateType.PLATE_384_WELL_PCR, "384 Well PCR Plate", 230, 83, 2, 24, 16), + PlateRecord(PlateType.PLATE_384_DEEP_WELL, "384 Deep Well Plate", 986, 355, 20, 24, 16), + PlateRecord(PlateType.PLATE_96_WELL, "96 Well Plate", 336, 121, 29, 12, 8), + PlateRecord(PlateType.PLATE_96_DEEP_WELL, "96 Deep Well Plate", 929, 335, 26, 12, 8), + PlateRecord(PlateType.PLATE_96_HALF_WELL, "96 Half Well Plate", 332, 120, 26, 12, 8), + PlateRecord(PlateType.PLATE_96_MINI_TUBES, "96 Mini Tubes", 1105, 398, 30, 12, 8), + PlateRecord(PlateType.PLATE_48_WELL, "48 Well Plate", 461, 166, 21, 8, 6), + PlateRecord(PlateType.PLATE_24_WELL, "24 Well Plate", 470, 169, 23, 6, 4), + PlateRecord(PlateType.PLATE_12_WELL, "12 Well Plate", 464, 167, 30, 4, 3), + PlateRecord(PlateType.PLATE_6_WELL, "6 Well Plate", 464, 167, 22, 3, 2), + PlateRecord(PlateType.TEST_PLATE_96_WELL_MB, "96 Well MB Test Plate", 327, 121, 19, 12, 8), + PlateRecord(PlateType.TEST_PLATE_96_WELL_BD, "96 Well BD Test Plate", 336, 121, 24, 12, 8), +) +"""The plates an instrument with a wash manifold works with.""" + +DISPENSER_PLATES: tuple[PlateRecord, ...] = ( + PlateRecord(PlateType.PLATE_1536_WELL, "1536 Well Plate", 250, 250, 131, 48, 32), + PlateRecord(PlateType.PLATE_1536_FLANGE, "1536 Flanged Plate", 196, 196, 41, 48, 32), + PlateRecord(PlateType.PLATE_384_WELL, "384 Well Plate", 333, 333, 64, 24, 16), + PlateRecord(PlateType.PLATE_384_WELL_PCR, "384 Well PCR Plate", 230, 230, 6, 24, 16), + PlateRecord(PlateType.PLATE_384_DEEP_WELL, "384 Deep Well Plate", 986, 986, 64, 24, 16), + PlateRecord(PlateType.PLATE_96_WELL, "96 Well Plate", 336, 336, 84, 12, 8), + PlateRecord(PlateType.PLATE_96_DEEP_WELL, "96 Deep Well Plate", 929, 929, 84, 12, 8), + PlateRecord(PlateType.PLATE_96_HALF_WELL, "96 Half Well Plate", 332, 332, 84, 12, 8), + PlateRecord(PlateType.PLATE_96_MINI_TUBES, "96 Mini Tubes", 1105, 1105, 84, 12, 8), + PlateRecord(PlateType.PLATE_48_WELL, "48 Well Plate", 460, 460, 51, 8, 6), + PlateRecord(PlateType.PLATE_24_WELL, "24 Well Plate", 452, 452, 51, 6, 4), + PlateRecord(PlateType.PLATE_12_WELL, "12 Well Plate", 460, 460, 51, 4, 3), + PlateRecord(PlateType.PLATE_6_WELL, "6 Well Plate", 465, 465, 51, 3, 2), +) +"""The plates a dispenser-only instrument works with.""" + +_OFFERED: dict[InstrumentFamily, tuple[PlateType, ...]] = { + InstrumentFamily.EL406: ( + PlateType.PLATE_96_WELL, + PlateType.PLATE_384_WELL, + PlateType.PLATE_384_WELL_PCR, + PlateType.PLATE_1536_WELL, + PlateType.PLATE_1536_FLANGE, + ), + InstrumentFamily.MODEL_405_TS: ( + PlateType.PLATE_96_WELL, + PlateType.PLATE_384_WELL, + PlateType.PLATE_384_WELL_PCR, + ), + InstrumentFamily.MULTIFLO: ( + PlateType.PLATE_1536_WELL, + PlateType.PLATE_1536_FLANGE, + PlateType.PLATE_384_WELL, + PlateType.PLATE_384_WELL_PCR, + PlateType.PLATE_384_DEEP_WELL, + PlateType.PLATE_96_WELL, + PlateType.PLATE_96_DEEP_WELL, + PlateType.PLATE_96_HALF_WELL, + PlateType.PLATE_96_MINI_TUBES, + ), + InstrumentFamily.MULTIFLO_FX: tuple(record.plate_type for record in DISPENSER_PLATES), +} +"""Which plates each family offers, in the order it offers them.""" + + +def plates_for(family: InstrumentFamily) -> list[PlateRecord]: + """The plates an instrument family works with. + + Args: + family: Which instrument model this is. + + Returns: + The records, in the order the instrument offers them. + """ + records = DISPENSER_PLATES if family is InstrumentFamily.MULTIFLO_FX else WASHER_PLATES + by_type = {record.plate_type: record for record in records} + return [by_type[plate_type] for plate_type in _OFFERED[family]] + + +def find(plate_type: PlateType, family: InstrumentFamily) -> PlateRecord | None: + """The record an instrument family uses for a plate. + + Args: + plate_type: The plate to look up. + family: Which instrument model this is. + + Returns: + The record, or None when the family does not offer that plate. + """ + for record in plates_for(family): + if record.plate_type is plate_type: + return record + return None diff --git a/pylabrobot/agilent/biotek/lhc/protocols/protocol.py b/pylabrobot/agilent/biotek/lhc/protocols/protocol.py new file mode 100644 index 00000000000..99edb324d53 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/protocol.py @@ -0,0 +1,117 @@ +"""A protocol: the steps to run, and everything a protocol file stores alongside them.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import Step, step_from_definition + + +@dataclass +class ProtocolEntry: + """One entry of a protocol as a file stores it. + + Only an entry whose action is :attr:`StepAction.CUSTOM` operates the instrument. The rest + sequence a run -- delays, loops, remarks, plate transfers -- and their definition text is in a + form of their own rather than a step definition. + + Attributes: + action: What this entry does. + step_type: Which operation it performs, for an entry that operates the instrument. + definition: The entry's parameters as text. + """ + + action: StepAction = StepAction.UNDEFINED + step_type: StepType = StepType.UNDEFINED + definition: str = "" + + @property + def is_device_step(self) -> bool: + """Whether this entry operates the instrument rather than sequencing the run.""" + return self.action is StepAction.CUSTOM + + +@dataclass +class Protocol: + """What a protocol file holds. + + A protocol keeps both what it will run and what it was read from: :attr:`steps` are the steps to + send, :attr:`entries` the file's own records. Reading a file fills the entries and leaves the + steps alone, so a file whose steps this package cannot read is still readable, writable and + inspectable; :meth:`build_steps` is what turns the entries into steps and reports which one + cannot be read. + + Attributes: + steps: The steps to run. + entries: The file's own records, including the ones that sequence the run rather than + operating the instrument. + instrument_settings_xml: What the instrument had fitted when the protocol was written, as the + nested document the file carries. Interpreting it belongs to the device. + instrument_settings: The settings summary the file carries alongside that document. + lhc_version: Which version of the vendor's software wrote the file. + instrument_name: The instrument the protocol was written for. + com_port: The port that instrument was on, which is informational only. + protocol_name: The protocol's name. + protocol_version: The protocol's version. + plate_type: The plate the protocol runs on, by name. + plate_type_number: The same plate as a number, or -1 when the file names none. + comments: Free text stored with the protocol. + read_only: Whether the vendor's editor treats the file as read-only. + stacker_enabled: Whether a plate stacker is configured. + use_stacker: Whether the run takes plates from the stacker. + entire_stack: Whether the run processes the whole stack. + stacker_plate_count: How many plates to process when it does not. + """ + + steps: list[Step] = field(default_factory=list) + entries: list[ProtocolEntry] = field(default_factory=list) + instrument_settings_xml: str = "" + instrument_settings: str = "" + lhc_version: str = "" + instrument_name: str = "" + com_port: str = "" + protocol_name: str = "" + protocol_version: str = "" + plate_type: str = "" + plate_type_number: int = -1 + comments: str = "" + read_only: bool = False + stacker_enabled: bool = False + use_stacker: bool = False + entire_stack: bool = False + stacker_plate_count: int = 10 + + @property + def device_entries(self) -> list[ProtocolEntry]: + """The entries that operate the instrument, in file order.""" + return [entry for entry in self.entries if entry.is_device_step] + + def build_steps(self) -> list[Step]: + """Read every entry that operates the instrument into a step, and keep the result. + + Entries that sequence the run are dropped rather than modelled, so a protocol using loops or + delays runs its device steps in file order and nothing else. The entries themselves are still + there to inspect. + + Returns: + The steps, which are also stored on the protocol. + + Raises: + ValueError: If an entry will not read, naming which one. That is what a protocol from + another instrument family or an older release of the vendor's software looks like. + """ + built = [] + for index, entry in enumerate(self.entries): + if not entry.is_device_step: + continue + try: + built.append(step_from_definition(entry.definition)) + except ValueError as error: + raise ValueError( + f"step {index} ({entry.step_type.name}) of {self.protocol_name or 'protocol'} " + f"will not read: {error}" + ) from error + self.steps = built + return built diff --git a/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/__init__.py new file mode 100644 index 00000000000..95b6c23bb3f --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/__init__.py @@ -0,0 +1,14 @@ +"""Reading and writing protocol files.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.protocols.read_write_utilities.encryption import decrypt, encrypt +from pylabrobot.agilent.biotek.lhc.protocols.read_write_utilities.protocol_file import ( + entries_for, + from_xml, + read, + to_xml, + write, +) + +__all__ = ["decrypt", "encrypt", "entries_for", "from_xml", "read", "to_xml", "write"] diff --git a/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/encryption.py b/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/encryption.py new file mode 100644 index 00000000000..2078b2933a8 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/encryption.py @@ -0,0 +1,75 @@ +"""Encrypting and decrypting protocol files. + +A protocol file is a DES-encrypted XML document. The cipher is CBC with a fixed key that is also +used as the initialisation vector, and PKCS7 padding. + +DES is not in the standard library, so reading or writing a protocol file needs +``pycryptodome`` installed. +""" + +from __future__ import annotations + +from typing import cast + +try: + from Crypto.Cipher import DES + from Crypto.Util.Padding import pad, unpad + + HAS_PYCRYPTODOME = True + _IMPORT_ERROR = "" +except ImportError as error: # pragma: no cover - depends on the environment + HAS_PYCRYPTODOME = False + _IMPORT_ERROR = str(error) + +_KEY = b"%?#\x14?i`?" +_BLOCK_SIZE = 8 +_ENCODING = "utf-8" + + +def _require_pycryptodome() -> None: + """Check that the cipher is available. + + Raises: + RuntimeError: If pycryptodome is not installed. + """ + if not HAS_PYCRYPTODOME: + raise RuntimeError( + "To enable decryption of .LHC files install pycryptodome: pip install pycryptodome. " + f"Import error: {_IMPORT_ERROR}" + ) + + +def decrypt(data: bytes) -> str: + """Decrypt the contents of a protocol file. + + Args: + data: The file's bytes. + + Returns: + The XML document inside it. + + Raises: + RuntimeError: If pycryptodome is not installed. + ValueError: If the data is not a whole number of blocks, or its padding is wrong, which is + what a file that is not a protocol looks like. + """ + _require_pycryptodome() + cipher = DES.new(_KEY, DES.MODE_CBC, _KEY) + return cast(str, unpad(cipher.decrypt(data), _BLOCK_SIZE).decode(_ENCODING)) + + +def encrypt(document: str) -> bytes: + """Encrypt an XML document into the contents of a protocol file. + + Args: + document: The document to encrypt. + + Returns: + The bytes to write. + + Raises: + RuntimeError: If pycryptodome is not installed. + """ + _require_pycryptodome() + cipher = DES.new(_KEY, DES.MODE_CBC, _KEY) + return cast(bytes, cipher.encrypt(pad(document.encode(_ENCODING), _BLOCK_SIZE))) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/protocol_file.py b/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/protocol_file.py new file mode 100644 index 00000000000..befe78ff8fd --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/protocol_file.py @@ -0,0 +1,225 @@ +"""Reading and writing protocol files. + +A protocol file is an encrypted XML document whose element order, indentation and line endings are +what the vendor's software expects to read back, so the writer reproduces them exactly. + +What a written file does not carry: the audit trail and revision counter, which record who saved a +protocol and when and would be fabricated by writing them; and four newer elements covering +stacker lids and a plate height override, whose meaning is not established. A file read and +written again therefore loses those, and is not byte-identical. Everything else survives, +including entries that sequence the run rather than operating the instrument. +""" + +from __future__ import annotations + +from pathlib import Path +from xml.etree import ElementTree +from xml.sax.saxutils import escape + +from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol, ProtocolEntry +from pylabrobot.agilent.biotek.lhc.protocols.read_write_utilities.encryption import decrypt, encrypt +from pylabrobot.agilent.biotek.lhc.protocols.steps import Step + +_DECLARATION = '' +_NAMESPACES = ( + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"' +) +_ROOT = "ProgramProtocol" +_NEWLINE = "\r\n" +_INDENT = " " +_NO_PLATE_TYPE = -1 +_NO_STEP_TYPE = -1 +_UNDEFINED_ACTION = "eStepActionUndefined" + + +def read(path: str | Path) -> Protocol: + """Read a protocol file. + + The steps themselves are not read yet: call :meth:`Protocol.build_steps` for that. A file whose + steps this package cannot read is still readable this far. + + Args: + path: The file to read. + + Returns: + The protocol. + + Raises: + RuntimeError: If pycryptodome is not installed. + ValueError: If the file is not a protocol file, or holds no protocol document. + """ + return from_xml(decrypt(Path(path).read_bytes())) + + +def write(path: str | Path, protocol: Protocol) -> None: + """Write a protocol file. + + Args: + path: The file to write. + protocol: The protocol to write. + + Raises: + RuntimeError: If pycryptodome is not installed. + """ + Path(path).write_bytes(encrypt(to_xml(protocol))) + + +def from_xml(document: str) -> Protocol: + """Read a protocol out of the document a protocol file holds. + + The instrument settings are handed over as the nested document they are stored as; making sense + of them belongs to the device, which can also read them off the instrument itself. + + Args: + document: The XML document. + + Returns: + The protocol. + + Raises: + ValueError: If the document does not parse, or an entry names an action or step type that + does not exist. + """ + try: + root = ElementTree.fromstring(document.strip()) + except ElementTree.ParseError as error: + raise ValueError(f"not a protocol document: {error}") from error + + def text(tag: str, default: str = "") -> str: + element = root.find(tag) + return default if element is None or element.text is None else element.text + + protocol = Protocol( + instrument_settings_xml=text("InstrumentSettingsXML"), + instrument_settings=text("InstrumentSettings"), + lhc_version=text("LHCVersion"), + instrument_name=text("InstrumentName"), + com_port=text("ComPort"), + protocol_name=text("ProtocolName"), + protocol_version=text("ProtocolVersion"), + plate_type=text("PlateType"), + plate_type_number=int(text("PlateTypeEnum", str(_NO_PLATE_TYPE))), + comments=text("Comments"), + read_only=text("ReadOnly") == "true", + stacker_enabled=text("BioStackEnabled") == "true", + use_stacker=text("UseBioStack") == "true", + entire_stack=text("EntireStack") == "true", + stacker_plate_count=int(text("BioStackPlateCount", "10")), + ) + for element in root.findall("Step"): + action = element.findtext("Action") or _UNDEFINED_ACTION + step_type = int(element.findtext("Type") or _NO_STEP_TYPE) + if action not in _ACTIONS: + raise ValueError(f"unknown step action: {action!r}") + protocol.entries.append( + ProtocolEntry( + action=_ACTIONS[action], + step_type=StepType(step_type) if step_type >= 0 else StepType.UNDEFINED, + definition=element.findtext("Definition") or "", + ) + ) + return protocol + + +def to_xml(protocol: Protocol) -> str: + """Write a protocol as the document a protocol file holds. + + The entries are written when the protocol has them, so a file that was read keeps its own text + and the entries that sequence the run survive being written again. Otherwise they are built from + the protocol's steps, which are all instrument operations. + + Args: + protocol: The protocol to write. + + Returns: + The document. + """ + entries = protocol.entries or entries_for(protocol.steps) + lines = [_DECLARATION, f"<{_ROOT}{_NAMESPACES}>"] + for tag, value in ( + ("LHCVersion", protocol.lhc_version), + ("InstrumentName", protocol.instrument_name), + ("ComPort", protocol.com_port), + ("InstrumentSettings", protocol.instrument_settings), + ("InstrumentSettingsXML", protocol.instrument_settings_xml), + ("ProtocolName", protocol.protocol_name), + ("ProtocolVersion", protocol.protocol_version), + ("PlateType", protocol.plate_type), + ("PlateTypeEnum", protocol.plate_type_number), + ("Comments", protocol.comments), + ("ReadOnly", protocol.read_only), + ("BioStackEnabled", protocol.stacker_enabled), + ("UseBioStack", protocol.use_stacker), + ("EntireStack", protocol.entire_stack), + ("BioStackPlateCount", protocol.stacker_plate_count), + ): + lines.append(_INDENT + _element(tag, value)) + for entry in entries: + lines.append(_INDENT + "") + lines.append(_INDENT * 2 + _element("Action", _ACTION_NAMES[entry.action])) + lines.append( + _INDENT * 2 + + _element("Type", entry.step_type.value if entry.is_device_step else _NO_STEP_TYPE) + ) + lines.append(_INDENT * 2 + _element("Definition", entry.definition)) + lines.append(_INDENT + "") + lines.append(f"") + return _NEWLINE.join(lines) + + +def entries_for(steps: list[Step]) -> list[ProtocolEntry]: + """Build file entries for steps that have none yet. + + Args: + steps: The steps to write. + + Returns: + One entry per step, each an instrument operation, in order. + """ + return [ + ProtocolEntry( + action=StepAction.CUSTOM, step_type=step.step_type, definition=step.to_definition() + ) + for step in steps + ] + + +_ACTIONS: dict[str, StepAction] = { + "eStepActionUndefined": StepAction.UNDEFINED, + "eStepActionEndOfList": StepAction.END_OF_LIST, + "eStepActionCustom": StepAction.CUSTOM, + "eStepActionDelay": StepAction.DELAY, + "eStepActionRemark": StepAction.REMARK, + "eStepActionLoopStart": StepAction.LOOP_START, + "eStepActionLoopEnd": StepAction.LOOP_END, + "eStepActionDeliverPlate": StepAction.DELIVER_PLATE, + "eStepActionNthPlate": StepAction.NTH_PLATE, + "eStepActionNthPlateEnd": StepAction.NTH_PLATE_END, + "eStepActionRetrievePlate": StepAction.RETRIEVE_PLATE, + "eStepActionRestack": StepAction.RESTACK, + "eStepActionDelayStartTimer": StepAction.DELAY_START_TIMER, +} +"""How a protocol file spells each action.""" + +_ACTION_NAMES: dict[StepAction, str] = {value: key for key, value in _ACTIONS.items()} +"""How to spell each action in a protocol file.""" + + +def _element(tag: str, value: object) -> str: + """One element of the document. + + Args: + tag: The element name. + value: Its value. Booleans are written as ``true`` and ``false``, and an empty value as an + empty element. + + Returns: + The element. + """ + if isinstance(value, bool): + value = "true" if value else "false" + text = escape(str(value)) + return f"<{tag}>{text}" if text else f"<{tag} />" diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/__init__.py new file mode 100644 index 00000000000..3b4b599d962 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/__init__.py @@ -0,0 +1,13 @@ +"""Whether a protocol can run, and what it requires of the instrument if it does.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.protocols.validation.protocol_pass import validate +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ( + Rejection, + StepReport, + ValidationReport, +) +from pylabrobot.agilent.biotek.lhc.protocols.validation.reservations import Reservations + +__all__ = ["Rejection", "Reservations", "StepReport", "ValidationReport", "validate"] diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/checks.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/checks.py new file mode 100644 index 00000000000..f3f242fee75 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/checks.py @@ -0,0 +1,593 @@ +"""The field checks every step's rules are built from. + +Each check answers with a rejection or with None, so a step's rules read as a chain: the first +check that fails is the answer, and the rest are not run. The codes are the instrument's own. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import StripWasherManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold +from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import BUFFERS, Buffer +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import CassetteType +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import PeriFlowRate +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe import Syringe +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TravelRate +from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WashFormat +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import Rejection + +VOLUME = 24598 +FLOW_RATE = 24599 +COUNT = 24600 +COLUMN_SELECTION = 24610 +ROW_SELECTION = 24580 +CASSETTE = 24624 +SYRINGE = 24688 +PRIME_CYCLES = 24689 +PUMP_DELAY = 24690 +OFFSET_X = 24691 +OFFSET_Y = 24692 +OFFSET_Z = 24693 +DURATION_SECONDS = 24704 +DURATION_MINUTES_SECONDS = 24709 +DURATION_HOURS_MINUTES = 24710 +ASPIRATE_DELAY = 24721 +TRAVEL_RATE = 24722 +BUFFER = 24720 +WASH_CYCLES = 24723 +WASH_FORMAT = 24724 +FRACTIONAL_VOLUME = 24727 + +MAX_DURATION = 300 +MAX_PUMP_DELAY = 5000 +MAX_PRIME_VOLUME = 8000 +MAX_STRIP_DISPENSE_VOLUME = 30000 +HALF_MICROLITRE = 50000 +"""The volume that means half a microlitre. It is compared for exactly, before any range.""" + +OFFSET_X_RANGE = (-125, 125) +OFFSET_Y_RANGE = (-40, 40) +OFFSET_Z_RANGE = (1, 1500) +OFFSET_X_MANIFOLD_RANGE = (-60, 60) +OFFSET_Y_405TS_RANGE = (-60, 60) +OFFSET_Y_OTHER_RANGE = (-40, 40) +OFFSET_Z_405TS_RANGE = (1, 255) +OFFSET_Z_OTHER_RANGE = (1, 210) +"""How far a step may reach. These are the instrument's own limits, not the plate's.""" + + +def buffer(value: Buffer, prefix: str = "") -> Rejection | None: + """Check a buffer inlet. + + Args: + value: The inlet the step names. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if value in BUFFERS: + return None + return Rejection(BUFFER, prefix + "Buffer must be A, B, C, or D") + + +def duration(value: int, prefix: str = "") -> Rejection | None: + """Check a duration in seconds. + + Args: + value: The duration in seconds. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if 1 <= value <= MAX_DURATION: + return None + return Rejection(DURATION_SECONDS, f"{prefix}Duration must be 1..{MAX_DURATION}") + + +def peri_flow_rate(value: PeriFlowRate, prefix: str = "") -> Rejection | None: + """Check a peristaltic flow rate. + + Args: + value: The rate the step names. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if value in ("Low", "Medium", "High"): + return None + return Rejection(FLOW_RATE, prefix + "Flow Rate must be Low, Medium, or High") + + +def flow_rate(value: int, minimum: int, maximum: int, prefix: str = "") -> Rejection | None: + """Check a numeric flow rate. + + Args: + value: The rate the step names. + minimum: The slowest rate allowed. + maximum: The fastest rate allowed. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if minimum <= value <= maximum: + return None + return Rejection(FLOW_RATE, f"{prefix}Flow Rate must be {minimum}..{maximum}") + + +def cassette_type(value: CassetteType | None, peri_wash_allowed: bool = False) -> Rejection | None: + """Check the cassette a step requires. + + The two catch-all requirements and no requirement at all are rejected: a step has to say what it + needs. The peristaltic wash cassettes are only allowed to a step that primes or purges one. + + Args: + value: The cassette the step requires. + peri_wash_allowed: Whether the peristaltic wash cassettes are allowed here. + + Returns: + A rejection, or None. + """ + allowed = {"Any", "1uL", "5uL", "10uL"} + if peri_wash_allowed: + allowed |= {"PeriWash aspirate", "PeriWash dispense"} + if value in allowed: + return None + return Rejection(CASSETTE) + + +def syringe(value: Syringe, manifold: SyringeManifold, prefix: str = "") -> Rejection | None: + """Check which syringe a step drives against the fitted manifold. + + Four manifolds can drive both syringes at once; on any other a step drives one of them. + + Args: + value: The syringe the step drives. + manifold: The fitted syringe manifold. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + both_allowed = manifold in ( + SyringeManifold.TUBE_8, + SyringeManifold.TUBE_16_7, + SyringeManifold.TUBE_32_SMALL_BORE, + SyringeManifold.TUBE_32_LARGE_BORE, + ) + if value in ("A", "B") or (value == "Both" and both_allowed): + return None + text = "must use syringe A, B or Both" if both_allowed else "must use syringe A or B" + return Rejection(SYRINGE, prefix + text) + + +def volume(value: int, minimum: int, maximum: int, prefix: str = "") -> Rejection | None: + """Check a volume against a range. + + Args: + value: The volume in µL. + minimum: The smallest volume allowed. + maximum: The largest volume allowed. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if minimum <= value <= maximum: + return None + return Rejection(VOLUME, f"{prefix}Volume must be {minimum}..{maximum}") + + +def volume_or_half_microlitre( + value: int, minimum: int, maximum: int, half_allowed: bool, prefix: str = "" +) -> Rejection | None: + """Check a volume that may instead be half a microlitre. + + Half a microlitre is a value of its own rather than a point on the range, so it is either + allowed or it is the rejection. + + Args: + value: The volume in µL. + minimum: The smallest volume allowed. + maximum: The largest volume allowed. + half_allowed: Whether this instrument can dispense half a microlitre. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if value == HALF_MICROLITRE: + if half_allowed: + return None + return Rejection(VOLUME, prefix + "Volume of 0.5 µL is not supported by this instrument") + if minimum <= value <= maximum: + return None + message = f"{prefix}Volume must be {minimum}..{maximum}" + if half_allowed: + message += " or 0.5 " + return Rejection(VOLUME, message) + + +SYRINGE_PRIME_MINIMUM = (80, 160, 400, 480, 640) +"""The smallest syringe prime volume in µL at each flow rate.""" + +STRIP_PRIME_MINIMUM: dict[StripWasherManifold, tuple[int, ...]] = { + StripWasherManifold.PLATE_6_WELL: (310, 130, 80, 240, 240, 300, 440, 400, 400, 800, 880), + StripWasherManifold.PLATE_12_WELL: (315, 180, 180, 180, 240, 240, 300, 420, 480, 780, 870), + StripWasherManifold.PLATE_24_WELL: (320, 160, 80, 240, 160, 320, 440, 400, 480, 800, 880), + StripWasherManifold.PLATE_48_WELL: (360, 360, 360, 360, 360, 360, 420, 420, 600, 630, 900), +} +"""The smallest strip washer prime volume in µL at each flow rate, per fitted manifold.""" + +_STRIP_PRIME_DEFAULT = (320, 160, 80, 240, 160, 320, 440, 400, 480, 800, 880) + +STRIP_DISPENSE_MINIMUM: dict[StripWasherManifold, tuple[int, ...]] = { + StripWasherManifold.PLATE_6_WELL: (155, 65, 65, 120, 120, 150, 225, 225, 225, 400, 450), + StripWasherManifold.PLATE_12_WELL: (105, 60, 60, 60, 80, 80, 100, 140, 160, 260, 290), + StripWasherManifold.PLATE_24_WELL: (80, 40, 40, 60, 60, 80, 110, 105, 120, 200, 220), + StripWasherManifold.PLATE_48_WELL: (60, 60, 60, 60, 60, 60, 70, 70, 100, 105, 150), +} +"""The smallest strip washer dispense volume in µL per well at each flow rate, per manifold.""" + +_STRIP_DISPENSE_DEFAULT = (40, 20, 20, 30, 20, 40, 55, 50, 60, 100, 110) + +SYRINGE_MINIMUM: dict[int, tuple[float, ...]] = { + 6: (40, 80, 200, 240, 320), + 12: (30, 60, 140, 160, 220), + 24: (20, 40, 100, 120, 160), + 48: (40, 80, 200, 240, 320), + 96: (10, 20, 50, 60, 80), + 384: (5, 10, 25, 30, 40), + 1536: (3, 3, 3, 3, 3), +} +"""The smallest syringe dispense volume in µL at each flow rate, per well count.""" + +SYRINGE_MINIMUM_384_EIGHT_TUBE = (10, 20, 50, 60, 80) +"""The same for 384 wells through the eight-tube manifold, whose wider tubes each give more.""" + + +def syringe_prime_volume(value: int, rate: int, prefix: str = "") -> Rejection | None: + """Check a syringe prime volume, whose floor moves with the flow rate. + + Args: + value: The volume in µL. + rate: The flow rate the step primes at. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + minimum = SYRINGE_PRIME_MINIMUM[rate - 1] + if minimum <= value <= MAX_PRIME_VOLUME: + return None + return Rejection( + VOLUME, + f"{prefix}Volume for the specified\r\nFlow Rate must be {minimum}..{MAX_PRIME_VOLUME}", + ) + + +def strip_prime_volume( + value: int, rate: int, manifold: StripWasherManifold, prefix: str = "" +) -> Rejection | None: + """Check a strip washer prime volume, whose floor moves with the rate and the manifold. + + Args: + value: The volume in µL. + rate: The flow rate the step primes at. + manifold: The fitted strip washer manifold. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + minimum = STRIP_PRIME_MINIMUM.get(manifold, _STRIP_PRIME_DEFAULT)[rate - 1] + if minimum <= value <= MAX_PRIME_VOLUME: + return None + return Rejection( + VOLUME, + f"{prefix}Volume for the specified Flow \r\nRate and Manifold type must be " + f"{minimum}..{MAX_PRIME_VOLUME}", + ) + + +def strip_dispense_volume( + value: int, rate: int, manifold: StripWasherManifold, prefix: str = "" +) -> Rejection | None: + """Check a strip washer dispense volume, whose floor moves with the rate and the manifold. + + Args: + value: The volume in µL. + rate: The flow rate the step dispenses at. + manifold: The fitted strip washer manifold. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + minimum = STRIP_DISPENSE_MINIMUM.get(manifold, _STRIP_DISPENSE_DEFAULT)[rate - 1] + if minimum <= value <= MAX_STRIP_DISPENSE_VOLUME: + return None + return Rejection( + VOLUME, + f"{prefix}Volume for the specified Flow \r\nRate and Manifold type must be " + f"{minimum}..{MAX_STRIP_DISPENSE_VOLUME}", + ) + + +def syringe_volume( + value: float, + rate: int, + manifold: SyringeManifold, + wells: int, + maximum: int, + prefix: str = "", +) -> Rejection | None: + """Check a syringe dispense volume against the plate and the flow rate. + + A plate whose well count is not in the table has no floor at all. A fractional volume through the + sixteen-tube manifold is rejected on its own account. + + Args: + value: The volume in µL. + rate: The flow rate the step dispenses at. + manifold: The fitted syringe manifold. + wells: How many wells the plate has. + maximum: The largest volume allowed, which depends on what the instrument has fitted. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + table = SYRINGE_MINIMUM.get(wells, (0.0, 0.0, 0.0, 0.0, 0.0)) + if wells == 384 and manifold is SyringeManifold.TUBE_8: + table = SYRINGE_MINIMUM_384_EIGHT_TUBE + minimum = table[rate - 1] + if value < minimum or value > maximum: + return Rejection( + VOLUME, + f"{prefix}Volume for the specified Flow Rate\r\nand Plate Type must be " + f"{minimum}..{maximum}", + ) + if value != int(value) and manifold is SyringeManifold.TUBE_16: + return Rejection(FRACTIONAL_VOLUME) + return None + + +def pump_delay(value: int, prefix: str = "") -> Rejection | None: + """Check a pump delay. + + Args: + value: The delay in ms. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if 0 <= value <= MAX_PUMP_DELAY: + return None + return Rejection(PUMP_DELAY, f"{prefix}Pump Delay must be 0..{MAX_PUMP_DELAY}") + + +def offset_x(value: int, minimum: int, maximum: int, prefix: str = "") -> Rejection | None: + """Check an offset across the plate. + + Args: + value: The offset. + minimum: The furthest allowed one way. + maximum: The furthest allowed the other. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if minimum <= value <= maximum: + return None + return Rejection(OFFSET_X, f"{prefix}X-axis offset must be {minimum}..{maximum}") + + +def offset_y(value: int, minimum: int, maximum: int, prefix: str = "") -> Rejection | None: + """Check an offset along the plate. + + Args: + value: The offset. + minimum: The furthest allowed one way. + maximum: The furthest allowed the other. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if minimum <= value <= maximum: + return None + return Rejection(OFFSET_Y, f"{prefix}Y-axis offset must be {minimum}..{maximum}") + + +def offset_z(value: int, minimum: int, maximum: int, prefix: str = "") -> Rejection | None: + """Check a depth offset. + + Args: + value: The offset. + minimum: The shallowest allowed. + maximum: The deepest allowed. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if minimum <= value <= maximum: + return None + return Rejection(OFFSET_Z, f"{prefix}Z-axis offset must be {minimum}..{maximum}") + + +def aspirate_delay(value: int, vacuum: bool, prefix: str = "") -> Rejection | None: + """Check how long an aspirate keeps going. + + The delay is a filtration time in seconds when the vacuum does the work, and a delay in + milliseconds when the tips do. + + Args: + value: The delay. + vacuum: Whether the step filters under vacuum. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if vacuum: + if 5 <= value <= 999: + return None + return Rejection(ASPIRATE_DELAY, prefix + "Delay must be 5..999 sec") + if 0 <= value <= MAX_PUMP_DELAY: + return None + return Rejection(ASPIRATE_DELAY, prefix + "Delay must be 0..5000 msec") + + +def travel_rate( + value: TravelRate, allowed: tuple[TravelRate, ...], prefix: str = "" +) -> Rejection | None: + """Check a travel rate against the ones this manifold offers. + + Args: + value: The rate the step names. + allowed: The rates the manifold offers. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if value in allowed: + return None + return Rejection(TRAVEL_RATE, prefix + "Travel Rate is invalid") + + +def column_selection(values: list[int], prefix: str = "") -> Rejection | None: + """Check a column selection. + + Selecting no column at all is allowed, and washes nothing. + + Args: + values: The selection, one entry per column. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if all(value in (0, 1) for value in values): + return None + return Rejection( + COLUMN_SELECTION, prefix + "Column selection settings are invalid; data is corrupt" + ) + + +def row_selection(values: list[int], sections: int, prefix: str = "") -> Rejection | None: + """Check a row selection. + + Unlike a column selection this one has to select something, and only the sections the plate + actually has are looked at. + + Args: + values: The selection, one entry per row. + sections: How many row sections the plate has. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if all(value in (0, 1) for value in values) and any(values[:sections]): + return None + return Rejection(ROW_SELECTION, prefix + "Row selection values are invalid; data is corrupt") + + +def count(value: int, prefix: str = "") -> Rejection | None: + """Check how many times a step pre-dispenses. + + Args: + value: The count. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if 1 <= value <= 9: + return None + return Rejection(COUNT, prefix + "Count must be 1..9") + + +def prime_cycles(value: int) -> Rejection | None: + """Check how many prime cycles a step runs. + + Args: + value: The number of cycles. + + Returns: + A rejection, or None. + """ + if 1 <= value <= 99: + return None + return Rejection(PRIME_CYCLES, "Number of prime cycles must be 1..99") + + +def wash_cycles(value: int, prefix: str = "") -> Rejection | None: + """Check how many wash cycles a step runs. + + Args: + value: The number of cycles. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if 1 <= value <= 250: + return None + return Rejection(WASH_CYCLES, prefix + "Number of wash cycles must be 1..250") + + +def wash_format(value: WashFormat, prefix: str = "") -> Rejection | None: + """Check what a wash covers. + + Args: + value: The format the step names. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if value in ("Plate", "Strip", "Sector"): + return None + return Rejection(WASH_FORMAT, prefix + "Invalid Wash Format") + + +def short_duration(value: int, prefix: str = "") -> Rejection | None: + """Check a duration stored as minutes and seconds. + + Args: + value: The duration in seconds. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + if 1 <= value <= 59 * 60 + 59: + return None + return Rejection(DURATION_MINUTES_SECONDS, prefix + "Duration must be 00:01..59:59") + + +def long_duration(value: int, short_range: bool, prefix: str = "") -> Rejection | None: + """Check a duration stored as hours and minutes. + + Args: + value: The duration in seconds. + short_range: Whether this duration is limited to four hours rather than a day. + prefix: What to call the step in the message. + + Returns: + A rejection, or None. + """ + limit = 3 if short_range else 23 + minutes = value // 60 + if 1 <= minutes <= limit * 60 + 59: + return None + return Rejection(DURATION_HOURS_MINUTES, prefix + f"Duration must be 00:01..{limit:02d}:59") diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/configuration.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/configuration.py new file mode 100644 index 00000000000..864b65023d4 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/configuration.py @@ -0,0 +1,334 @@ +"""Whether the instrument is configured for what a step asks of it. + +These rules are about the instrument rather than the step: what a step type needs to be fitted, +which plates the fitted manifolds can work, and the two commitments a protocol makes to itself -- +one buffer inlet throughout unless a valve box can switch it, and one bottle per syringe. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import StripWasherManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.valve_box import ValveBox +from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold +from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import SyringeBottle +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_auto_clean import ( + ManifoldAutoClean, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_wash import ManifoldWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_prime import SyringePrime +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import Rejection + +COMMITMENT_CONFLICT = 24614 +SYRINGE_PLATE_16_TUBE = 24628 +SYRINGE_PLATE_32_TUBE = 24629 +SYRINGE_PLATE_8_TUBE = 24584 +SYRINGE_PLATE_6_WELL = 24935 +SYRINGE_PLATE_12_WELL = 24936 +SYRINGE_PLATE_24_WELL = 24937 +SYRINGE_PLATE_48_WELL = 24944 +NO_SYRINGE = 24615 +WASHER_PLATE_384_128_TUBE = 24631 +WASHER_PLATE_384_96_SINGLE = 24585 +WASHER_PLATE_1536 = 24630 +WASHER_PLATE = 24625 +NO_VACUUM_FILTRATION = 24616 +NO_PERI_PUMP = 24729 +NO_ULTRASONIC = 24678 +NO_STRIP_WASHER = 24928 +STRIP_WASHER_PLATE = 24929 +NO_PERI_WASH = 24706 + +_NEEDS_SYRINGE_PLATE = (StepType.SYRINGE_DISPENSE, StepType.WASH_1536) +_NEEDS_WASHER_PLATE = ( + StepType.MANIFOLD_WASH, + StepType.MANIFOLD_DISPENSE, + StepType.MANIFOLD_ASPIRATE, + StepType.WASH_1536, +) +_NEEDS_BUFFER = ( + StepType.MANIFOLD_WASH, + StepType.MANIFOLD_DISPENSE, + StepType.MANIFOLD_AUTO_CLEAN, + StepType.MANIFOLD_PRIME, +) +_NEEDS_BOTTLE = (StepType.SYRINGE_PRIME, StepType.SYRINGE_DISPENSE) +_NEEDS_VACUUM = (StepType.MANIFOLD_WASH, StepType.MANIFOLD_ASPIRATE) +_NEEDS_PERI_PUMP = (StepType.PERI_DISPENSE, StepType.PERI_PRIME, StepType.PERI_PURGE) +_NEEDS_SYRINGE = (StepType.SYRINGE_PRIME, StepType.SYRINGE_DISPENSE) +_NEEDS_STRIP_PLATE = (StepType.STRIP_WASH, StepType.STRIP_ASPIRATE, StepType.STRIP_DISPENSE) +_NEEDS_PERI_WASH = (StepType.PERI_WASH_ASPIRATE, StepType.PERI_WASH_DISPENSE) + +_STRIP_WASHER_PLATES: dict[StripWasherManifold, tuple[int, ...]] = { + StripWasherManifold.PLATE_6_WELL: (6,), + StripWasherManifold.PLATE_12_WELL: (12,), + StripWasherManifold.PLATE_24_WELL: (24,), + StripWasherManifold.PLATE_48_WELL: (48,), + StripWasherManifold.PLATE_96_WELL: (96, 384), +} + +_A_BOTTLES = ("A1", "A2") +_B_BOTTLES = ("B1", "B2") +_INCOMPATIBLE: tuple[tuple[SyringeBottle, tuple[SyringeBottle, ...]], ...] = ( + ("A1", ("A2B1", "A2B2")), + ("A2", ("A1B1", "A1B2")), + ("B1", ("A1B2", "A2B2")), + ("B2", ("A1B1", "A2B1")), +) + + +@dataclass +class Commitments: + """What a protocol has committed itself to as it is checked. + + Attributes: + buffer: The buffer inlet the protocol draws from, once a step has named one. + bottle_a: The bottle syringe A draws from, once a step has named one. + bottle_b: The same for syringe B. + bottle_both: The pairing used when both syringes run together. + """ + + buffer: Buffer | None = None + bottle_a: SyringeBottle | None = None + bottle_b: SyringeBottle | None = None + bottle_both: SyringeBottle | None = None + + +def check_configuration( + step: Step, + settings: InstrumentSettings, + plate: PlateRecord, + commitments: Commitments, + absent: frozenset[int], + carrier_type: CarrierType | None = None, +) -> Rejection | None: + """Check what a step needs from the instrument's configuration. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + commitments: What the protocol has committed to so far, updated in place. + absent: Rules this model does not have. + carrier_type: The fitted carrier, when the device has read it. Vacuum filtration is only + confirmed when it has. + + Returns: + A rejection, or None. + """ + step_type = step.step_type + wells = plate.wells + + if step_type in _NEEDS_SYRINGE_PLATE: + rejection = _check_syringe_plate(settings.syringe_manifold, plate, absent) + if rejection is not None: + return rejection + + # A model without a wash manifold is not asked to have one. + if settings.family is not InstrumentFamily.MULTIFLO_FX and step_type in _NEEDS_WASHER_PLATE: + rejection = _check_washer_plate(settings.washer_manifold, wells) + if rejection is not None: + return rejection + + if step_type in _NEEDS_BUFFER and settings.valve_box in ( + ValveBox.SYRINGE, + ValveBox.NOT_INSTALLED, + ): + buffer = _buffer_of(step) + if buffer is not None: + if commitments.buffer is None: + commitments.buffer = buffer + elif commitments.buffer != buffer: + return Rejection(COMMITMENT_CONFLICT) + + if step_type in _NEEDS_BOTTLE and settings.valve_box is not ValveBox.SYRINGE: + rejection = _check_bottle(step, commitments) + if rejection is not None: + return rejection + + if step_type in _NEEDS_VACUUM and _wants_vacuum(step): + if not settings.vacuum_filtration: + return Rejection(NO_VACUUM_FILTRATION) + if carrier_type is not None and carrier_type is not CarrierType.VACUUM_FILTRATION: + return Rejection(NO_VACUUM_FILTRATION) + + if step_type in _NEEDS_PERI_PUMP and not settings.peri_pump: + return Rejection(NO_PERI_PUMP) + if step_type is StepType.MANIFOLD_AUTO_CLEAN and not settings.ultrasonic: + return Rejection(NO_ULTRASONIC) + if step_type in _NEEDS_SYRINGE and ( + settings.syringe_box is SyringeBoxType.NOT_INSTALLED + or settings.syringe_manifold is SyringeManifold.NOT_INSTALLED + ): + return Rejection(NO_SYRINGE) + if ( + step_type is StepType.STRIP_PRIME + and NO_STRIP_WASHER not in absent + and settings.strip_washer_manifold is StripWasherManifold.NOT_INSTALLED + ): + return Rejection(NO_STRIP_WASHER) + if step_type in _NEEDS_STRIP_PLATE and STRIP_WASHER_PLATE not in absent: + manifold = settings.strip_washer_manifold + required = _STRIP_WASHER_PLATES.get(manifold) + if required is None: + if manifold is StripWasherManifold.NOT_INSTALLED: + return Rejection(NO_STRIP_WASHER) + return Rejection(STRIP_WASHER_PLATE) + if wells not in required: + return Rejection(STRIP_WASHER_PLATE) + if step_type in _NEEDS_PERI_WASH and not settings.peri_wash_enabled: + return Rejection(NO_PERI_WASH) + return None + + +def _check_syringe_plate( + manifold: SyringeManifold, plate: PlateRecord, absent: frozenset[int] +) -> Rejection | None: + """Check the plate against the fitted syringe manifold. + + Args: + manifold: The fitted syringe manifold. + plate: The plate the protocol runs on. + absent: Rules this model does not have. + + Returns: + A rejection, or None. + """ + wells = plate.wells + if manifold in (SyringeManifold.TUBE_16, SyringeManifold.TUBE_16_7): + if wells not in (96, 384) or plate.plate_type is PlateType.PLATE_96_HALF_WELL: + return Rejection(SYRINGE_PLATE_16_TUBE) + elif manifold in (SyringeManifold.TUBE_32_LARGE_BORE, SyringeManifold.TUBE_32_SMALL_BORE): + if wells != 1536: + return Rejection(SYRINGE_PLATE_32_TUBE) + elif manifold is SyringeManifold.TUBE_8: + if wells not in (96, 384): + return Rejection(SYRINGE_PLATE_8_TUBE) + elif manifold is SyringeManifold.PLATE_6_WELL and wells != 6: + if SYRINGE_PLATE_6_WELL not in absent: + return Rejection(SYRINGE_PLATE_6_WELL) + elif manifold is SyringeManifold.PLATE_12_WELL and wells != 12: + if SYRINGE_PLATE_12_WELL not in absent: + return Rejection(SYRINGE_PLATE_12_WELL) + elif manifold is SyringeManifold.PLATE_24_WELL and wells != 24: + if SYRINGE_PLATE_24_WELL not in absent: + return Rejection(SYRINGE_PLATE_24_WELL) + elif manifold is SyringeManifold.PLATE_48_WELL and wells != 48: + if SYRINGE_PLATE_48_WELL not in absent: + return Rejection(SYRINGE_PLATE_48_WELL) + elif manifold is SyringeManifold.NOT_INSTALLED: + return Rejection(NO_SYRINGE) + return None + + +def _check_washer_plate(manifold: WasherManifold, wells: int) -> Rejection | None: + """Check the plate against the fitted wash manifold. + + Args: + manifold: The fitted wash manifold. + wells: How many wells the plate has. + + Returns: + A rejection, or None. + """ + if wells == 384: + if manifold is WasherManifold.TUBE_128: + return Rejection(WASHER_PLATE_384_128_TUBE) + if manifold is WasherManifold.TUBE_96_SINGLE: + return Rejection(WASHER_PLATE_384_96_SINGLE) + elif wells == 1536: + if manifold is not WasherManifold.TUBE_128: + return Rejection(WASHER_PLATE_1536) + elif manifold not in (WasherManifold.TUBE_96_DUAL, WasherManifold.TUBE_96_SINGLE): + return Rejection(WASHER_PLATE) + return None + + +def _check_bottle(step: Step, commitments: Commitments) -> Rejection | None: + """Check which bottle a syringe step draws from against what the protocol has committed to. + + A step that runs both syringes names a pairing, which has to agree with the bottle each side has + already been committed to. + + Args: + step: The syringe step. + commitments: What the protocol has committed to so far, updated in place. + + Returns: + A rejection, or None. + """ + if not isinstance(step, (SyringePrime, SyringeDispense)): + return None + bottle = step.syringe_bottle + if bottle in _A_BOTTLES: + if commitments.bottle_a is None: + commitments.bottle_a = bottle + elif commitments.bottle_a != bottle: + return Rejection(COMMITMENT_CONFLICT) + elif bottle in _B_BOTTLES: + if commitments.bottle_b is None: + commitments.bottle_b = bottle + elif commitments.bottle_b != bottle: + return Rejection(COMMITMENT_CONFLICT) + else: + if commitments.bottle_both is None: + commitments.bottle_both = bottle + elif commitments.bottle_both != bottle: + return Rejection(COMMITMENT_CONFLICT) + for chosen, incompatible in _INCOMPATIBLE: + side = commitments.bottle_a if chosen in _A_BOTTLES else commitments.bottle_b + if side == chosen and commitments.bottle_both in incompatible: + return Rejection(COMMITMENT_CONFLICT) + return None + + +def _buffer_of(step: Step) -> Buffer | None: + """Which buffer inlet a step draws from. + + Args: + step: The step to ask. + + Returns: + The inlet, or None when the step names none. A wash draws through the dispense it owns. + """ + if isinstance(step, (ManifoldPrime, ManifoldDispense, ManifoldAutoClean)): + return step.buffer + if isinstance(step, ManifoldWash): + return step.dispense.buffer + return None + + +def _wants_vacuum(step: Step) -> bool: + """Whether a step filters under vacuum. + + A wash filters when the aspirate it always runs does, or when the final aspirate does and the + stage that runs it is on. + + Args: + step: The step to ask. + + Returns: + Whether it needs the vacuum. + """ + if isinstance(step, ManifoldAspirate): + return step.vacuum_filtration + if isinstance(step, ManifoldWash): + return step.aspirate.vacuum_filtration or ( + step.stages.final_aspirate and step.final_aspirate.vacuum_filtration + ) + return False diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/plate_rules.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/plate_rules.py new file mode 100644 index 00000000000..c179a1dd019 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/plate_rules.py @@ -0,0 +1,69 @@ +"""Which step types a plate allows. + +The dispensers and the primes do not care what plate is loaded. The wash manifold steps need at +least 96 wells and cannot work a 1536-well plate, which has a wash of its own. The strip washer +steps need fewer than 1536 wells and a plate that is neither deep-well nor tubes. The peristaltic +wash steps work 96- and 384-well plates only. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import Rejection + +WRONG_PLATE = 24675 +WASH_NEEDS_1536_WASH = 24627 +STRIP_WRONG_PLATE = 24930 +NEEDS_1536 = 24632 + +_DEEP_OR_TUBES = ( + PlateType.PLATE_96_MINI_TUBES, + PlateType.PLATE_96_DEEP_WELL, + PlateType.PLATE_384_DEEP_WELL, +) +_PLATE_INDEPENDENT = ( + StepType.PERI_DISPENSE, + StepType.PERI_PRIME, + StepType.PERI_PURGE, + StepType.SYRINGE_DISPENSE, + StepType.SYRINGE_PRIME, + StepType.MANIFOLD_PRIME, + StepType.MANIFOLD_AUTO_CLEAN, + StepType.STRIP_PRIME, + StepType.SHAKE_SOAK, +) +_STRIP_STEPS = (StepType.STRIP_WASH, StepType.STRIP_ASPIRATE, StepType.STRIP_DISPENSE) +_PERI_WASH_STEPS = (StepType.PERI_WASH_ASPIRATE, StepType.PERI_WASH_DISPENSE) + + +def check_plate(plate: PlateRecord, step_type: StepType) -> Rejection | None: + """Check whether a plate allows a step type. + + Args: + plate: The plate the protocol runs on. + step_type: What the step does. + + Returns: + A rejection, or None. A wash on a 1536-well plate is rejected with a code of its own, since + that plate has a wash of its own. + """ + wells = plate.wells + if step_type in _PLATE_INDEPENDENT: + return None + if step_type is StepType.MANIFOLD_ASPIRATE: + return None if wells >= 96 else Rejection(WRONG_PLATE) + if step_type in (StepType.MANIFOLD_WASH, StepType.MANIFOLD_DISPENSE): + if 96 <= wells < 1536: + return None + return Rejection(WASH_NEEDS_1536_WASH if wells == 1536 else WRONG_PLATE) + if step_type in _STRIP_STEPS: + if wells < 1536 and plate.plate_type not in _DEEP_OR_TUBES: + return None + return Rejection(STRIP_WRONG_PLATE) + if step_type is StepType.WASH_1536: + return None if wells == 1536 else Rejection(NEEDS_1536) + if step_type in _PERI_WASH_STEPS: + return None if wells in (96, 384) else Rejection(WRONG_PLATE) + return Rejection(WRONG_PLATE) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/protocol_pass.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/protocol_pass.py new file mode 100644 index 00000000000..563d7391fd2 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/protocol_pass.py @@ -0,0 +1,190 @@ +"""Checking a whole protocol in one pass. + +The pass answers two things at once. The report says whether the protocol can run and why not; the +reservations say what it requires of the peristaltic pumps, which is what opening a batch uses to +make the hardware match. Skip the pass and the reservations are empty, so the batch opens against +whatever cassette happens to be fitted -- which is why running a protocol validates it first. + +Nothing here talks to the instrument. The device gathers the facts -- what is fitted, which plate, +which rules its firmware runs, and, when it has asked, which plates it accepts and which carrier is +on it -- and passes them in, so every rule is testable without hardware. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.devices.build_rules import BASECODE_STEP_TYPES, BuildRules +from pylabrobot.agilent.biotek.lhc.devices.build_rules import basecode_for +from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_restriction import PlateRestriction +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_random_access_dispense import ( + PeriRandomAccessDispense, +) +from pylabrobot.agilent.biotek.lhc.protocols.validation import configuration, plate_rules +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ( + Rejection, + StepReport, + ValidationReport, +) +from pylabrobot.agilent.biotek.lhc.protocols.validation.reservations import ( + Reservations, + check_cassette_head, + check_pump_exclusivity, + claim_pump, + reserve_cassette, + uses_random_access, +) +from pylabrobot.agilent.biotek.lhc.protocols.validation.step_checks import check_step + +ONLY_96_WELL = 24582 +ONLY_1536_WELL = 24583 +WRONG_CARRIER = 24834 +STEP_NOT_IN_FIRMWARE = 24707 + +_PERI_STEPS = (StepType.PERI_DISPENSE, StepType.PERI_PRIME, StepType.PERI_PURGE) + + +def validate( + steps: list[Step], + settings: InstrumentSettings, + plate: PlateRecord, + rules: BuildRules, + plate_restriction: PlateRestriction | None = None, + carrier_type: CarrierType | None = None, +) -> tuple[ValidationReport, Reservations]: + """Check a protocol. + + Every step is checked, so the report lists all the failures rather than stopping at the first. + What the protocol claims of the pumps accumulates across the steps, which means a conflict + between two steps is reported against the second of them. + + Args: + steps: The steps to check, in the order they will run. + settings: What the instrument has fitted. These should be what the instrument reports rather + than what a protocol file declares. + plate: The plate the protocol runs on. + rules: Which rules this model's firmware runs. + plate_restriction: Which plates the instrument accepts, when the device has asked it. + carrier_type: Which carrier is fitted, when the device has asked it. + + Returns: + The report, and what the protocol requires of the pumps. + """ + report = ValidationReport() + reservations = Reservations() + commitments = configuration.Commitments() + absent = rules.absent_checks + basecode = basecode_for(settings) + + rejection = _check_instrument(plate, plate_restriction, carrier_type, absent) + if rejection is not None: + report.rejection = rejection + return report, reservations + + for number, step in enumerate(steps, start=1): + report.steps.append( + StepReport( + number=number, + step_type=step.step_type, + rejection=_check_one( + step, settings, plate, rules, basecode, reservations, commitments, carrier_type + ), + ) + ) + return report, reservations + + +def _check_instrument( + plate: PlateRecord, + plate_restriction: PlateRestriction | None, + carrier_type: CarrierType | None, + absent: frozenset[int], +) -> Rejection | None: + """Check the plate against what the instrument itself will accept. + + This belongs to no single step: it is about the instrument and the plate, so it stops the whole + protocol rather than one of its steps. + + Args: + plate: The plate the protocol runs on. + plate_restriction: Which plates the instrument accepts, when the device has asked it. + carrier_type: Which carrier is fitted, when the device has asked it. + absent: Rules this model does not have. + + Returns: + A rejection, or None. + """ + if plate_restriction is PlateRestriction.ALLOW_96_WELL_ONLY and plate.wells != 96: + return Rejection(ONLY_96_WELL) + if plate_restriction is PlateRestriction.ALLOW_1536_WELL_ONLY and plate.wells != 1536: + return Rejection(ONLY_1536_WELL) + if carrier_type is not None and WRONG_CARRIER not in absent: + mini_tubes = plate.plate_type is PlateType.PLATE_96_MINI_TUBES + if (carrier_type is CarrierType.MINI_TUBE) != mini_tubes: + return Rejection(WRONG_CARRIER) + return None + + +def _check_one( + step: Step, + settings: InstrumentSettings, + plate: PlateRecord, + rules: BuildRules, + basecode: Basecode, + reservations: Reservations, + commitments: configuration.Commitments, + carrier_type: CarrierType | None, +) -> Rejection | None: + """Check one step, in the order the instrument checks it. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + rules: Which rules this model's firmware runs. + basecode: Which firmware variant the instrument is running. + reservations: What the protocol has claimed so far, updated in place. + commitments: What the protocol has committed to so far, updated in place. + carrier_type: Which carrier is fitted, when the device has asked it. + + Returns: + A rejection, or None. + """ + absent = rules.absent_checks + rejection = plate_rules.check_plate(plate, step.step_type) + if rejection is not None and rejection.code not in absent: + return rejection + if rules.basecode_step_types and step.step_type not in BASECODE_STEP_TYPES[basecode]: + return Rejection(STEP_NOT_IN_FIRMWARE) + if uses_random_access(step) and not settings.supports_random_access_tail: + # This model cannot store the random-access fields, so it cannot be told to use them. The + # alternative would be running the step as an ordinary one, which is a different operation. + return Rejection(STEP_NOT_IN_FIRMWARE) + if rules.peri_pump_exclusivity: + claim_pump(step, reservations) + rejection = check_step(step, settings, plate) + if rejection is not None: + return rejection + if step.step_type in _PERI_STEPS and isinstance(step, (PeriPrime, PeriDispense)): + rejection = reserve_cassette(step, settings, reservations, absent) + if rejection is not None: + return rejection + if step.step_type is StepType.PERI_DISPENSE: + reservations.dispense_reserved = True + if isinstance(step, PeriRandomAccessDispense): + rejection = check_cassette_head(step, plate, absent) + if rejection is not None: + return rejection + rejection = configuration.check_configuration( + step, settings, plate, commitments, absent, carrier_type + ) + if rejection is not None or not rules.peri_pump_exclusivity: + return rejection + return check_pump_exclusivity(reservations) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/report.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/report.py new file mode 100644 index 00000000000..149113b20ae --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/report.py @@ -0,0 +1,99 @@ +"""What validation answers with.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType + + +@dataclass(frozen=True) +class Rejection: + """Why a step cannot run. + + Attributes: + code: The instrument's code for this rejection. + reason: What is wrong, in the words the instrument would use. Empty for the rejections the + instrument reports as a code alone. + """ + + code: int + reason: str = "" + + def __str__(self) -> str: + """The rejection as one line. + + Returns: + The reason and its code, or just the code when there is no reason. + """ + return f"{self.reason} [{self.code}]" if self.reason else f"[{self.code}]" + + +@dataclass(frozen=True) +class StepReport: + """What validation found about one step. + + Attributes: + number: Which step this is, counting from 1. + step_type: What the step does. + rejection: Why it cannot run, or None when it can. + """ + + number: int + step_type: StepType + rejection: Rejection | None = None + + @property + def ok(self) -> bool: + """Whether this step can run.""" + return self.rejection is None + + def __str__(self) -> str: + """The step as one line. + + Returns: + The step's number and type, and why it cannot run. + """ + state = "ok" if self.ok else str(self.rejection) + return f"step {self.number} ({self.step_type.name}): {state}" + + +@dataclass +class ValidationReport: + """What validation found about a protocol. + + Truthy when every step can run, so it reads as an answer to the question that was asked. Printing + it lists only what cannot run. + + Attributes: + steps: One report per step, in protocol order. + rejection: Why the protocol as a whole cannot run, for a problem that belongs to no single + step -- a plate the instrument does not work with, or one it will not accept. + """ + + steps: list[StepReport] = field(default_factory=list) + rejection: Rejection | None = None + + def __bool__(self) -> bool: + """Whether the whole protocol can run.""" + return self.rejection is None and all(step.ok for step in self.steps) + + @property + def failures(self) -> list[StepReport]: + """The steps that cannot run, in protocol order.""" + return [step for step in self.steps if not step.ok] + + def __str__(self) -> str: + """The report as text. + + Returns: + One line saying whether the protocol can run, followed by a line per step that cannot. + """ + if self.rejection is not None: + return f"protocol cannot run: {self.rejection}" + failures = self.failures + if not failures: + return f"protocol can run: {len(self.steps)} steps" + lines = [f"protocol cannot run: {len(failures)} of {len(self.steps)} steps"] + lines += [f" {step}" for step in failures] + return "\n".join(lines) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/reservations.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/reservations.py new file mode 100644 index 00000000000..ea9b7c3d1ed --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/reservations.py @@ -0,0 +1,286 @@ +"""What a protocol claims of the peristaltic pumps. + +One instrument, two pumps, one cassette in each. Checking a protocol accumulates what its steps +require -- which cassette in which pump, which dispense head, which pumps are used at all -- and +that record is both a rule (two steps may not want different cassettes in one pump) and an output: +opening a batch is what makes the hardware match it. + +A conflict therefore shows up on the *second* step of a pair, not the first. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_head import CassetteHead +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import CassetteType +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PeriPump +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_random_access_dispense import ( + PeriRandomAccessDispense, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_aspirate import PeriWashAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_dispense import PeriWashDispense +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import Rejection + +CONFLICT = 24617 +SINGLE_WELL_UNAVAILABLE = 24933 +NO_SECOND_PUMP = 24676 +HEAD_PLATE_MISMATCH = 24945 + +_PERI_STEP_TYPES = (StepType.PERI_DISPENSE, StepType.PERI_PRIME, StepType.PERI_PURGE) +_PERI_WASH_STEP_TYPES = (StepType.PERI_WASH_ASPIRATE, StepType.PERI_WASH_DISPENSE) +_PERI_WASH_CASSETTES = ("PeriWash aspirate", "PeriWash dispense") +_SINGLE_WELL_HEADS = ("1 tube to 1 well", "8 tubes to 1 chute") +_MULTI_TUBE_HEADS = ("8 tubes to 8 wells", "8 tubes to 1 well", "8 tubes to 1 chute") + + +@dataclass +class Reservations: + """What the protocol checked so far requires of the pumps. + + Attributes: + cassette_primary: The cassette the primary pump must hold, or None while nothing requires one. + cassette_secondary: The same for the secondary pump. + cassette_head: The dispense head the fitted cassette must have, or None while nothing requires + one. + uses_primary: Whether any step drives the primary pump. + uses_secondary: Whether any step drives the secondary pump. + single_well: Whether any step dispenses into single wells. + dispense_reserved: Whether a peristaltic dispense got far enough to claim a cassette, which is + what makes the head worth checking against the plate. + peri_pumps: Which pumps ordinary peristaltic steps claim, primary first. + peri_wash_pumps: Which pumps peristaltic wash steps claim, primary first. + """ + + cassette_primary: CassetteType | None = None + cassette_secondary: CassetteType | None = None + cassette_head: CassetteHead | None = None + uses_primary: bool = False + uses_secondary: bool = False + single_well: bool = False + dispense_reserved: bool = False + peri_pumps: list[bool] = field(default_factory=lambda: [False, False]) + peri_wash_pumps: list[bool] = field(default_factory=lambda: [False, False]) + any_cassette_primary: bool = False + any_cassette_secondary: bool = False + + +def reserve_cassette( + step: PeriPrime | PeriDispense, + settings: InstrumentSettings, + reservations: Reservations, + absent: frozenset[int], +) -> Rejection | None: + """Claim a pump and a cassette for a peristaltic step. + + This is what stops a protocol asking for two different cassettes in one pump, for a pump that is + not fitted, or for single-well dispensing an instrument cannot do. + + Args: + step: The peristaltic step, which names a cassette and a pump. + settings: What the instrument has fitted. + reservations: What the protocol has claimed so far, updated in place. + absent: Rules this model does not have. + + Returns: + A rejection, or None. + """ + cassette = step.cassette_type + pump = step.peri_pump + head, random_access = _random_access_of(step) + + single_well = random_access or head in _SINGLE_WELL_HEADS + if single_well: + reservations.single_well = True + if SINGLE_WELL_UNAVAILABLE not in absent: + if not settings.single_well_enabled: + return Rejection(SINGLE_WELL_UNAVAILABLE) + if settings.peri_pump_2 and pump == "Primary": + return Rejection(SINGLE_WELL_UNAVAILABLE) + if pump == "Primary": + reservations.uses_primary = True + if pump == "Secondary": + if not settings.peri_pump_2: + return Rejection(NO_SECOND_PUMP) + reservations.uses_secondary = True + + if cassette != "Any": + if pump == "Primary": + if reservations.cassette_primary is None: + reservations.cassette_primary = cassette + elif reservations.cassette_primary != cassette: + return Rejection(CONFLICT) + if pump == "Secondary": + if reservations.cassette_secondary is None: + reservations.cassette_secondary = cassette + elif reservations.cassette_secondary != cassette: + return Rejection(CONFLICT) + if head is None: + if pump == "Primary": + reservations.any_cassette_primary = True + else: + reservations.any_cassette_secondary = True + elif reservations.cassette_head is None: + reservations.cassette_head = head + elif reservations.cassette_head != head: + return Rejection(CONFLICT) + elif not random_access: + if pump == "Primary": + if reservations.single_well and not settings.peri_pump_2: + return Rejection(CONFLICT) + reservations.any_cassette_primary = True + if pump == "Secondary": + if reservations.single_well: + return Rejection(CONFLICT) + reservations.any_cassette_secondary = True + else: + if pump == "Primary" and reservations.any_cassette_primary: + return Rejection(CONFLICT) + if pump == "Secondary" and reservations.any_cassette_secondary: + return Rejection(CONFLICT) + + if not single_well: + if pump != "Primary": + if reservations.single_well: + return Rejection(CONFLICT) + elif reservations.single_well and not settings.peri_pump_2: + return Rejection(CONFLICT) + elif pump == "Primary": + if reservations.any_cassette_primary: + return Rejection(CONFLICT) + elif reservations.any_cassette_secondary: + return Rejection(CONFLICT) + return None + + +def _random_access_of(step: PeriPrime | PeriDispense) -> tuple[CassetteHead | None, bool]: + """Whether a step dispenses at random access, and through which head. + + A dispense says so by being a random-access dispense; a prime or purge says so with a flag, since + it primes the same cassette without dispensing into wells. + + Args: + step: The peristaltic step. + + Returns: + The head the step names, and whether it is a random-access step. + """ + if isinstance(step, PeriRandomAccessDispense): + return step.cassette_head, True + if isinstance(step, PeriPrime): + return step.random_access.cassette_head, step.random_access.enabled + return None, False + + +def uses_random_access(step: Step) -> bool: + """Whether a step carries random-access fields at all. + + A model that predates random access cannot store them, and so cannot run such a step. + + Args: + step: The step to ask. + + Returns: + Whether it does. + """ + if isinstance(step, PeriRandomAccessDispense): + return True + if isinstance(step, PeriPrime): + return step.random_access.enabled + return False + + +def check_cassette_head( + step: PeriRandomAccessDispense, plate: PlateRecord, absent: frozenset[int] +) -> Rejection | None: + """Check a dispense head against the plate it would dispense into. + + A head that feeds several tubes at once cannot serve more than 24 wells, and a single-tube head + cannot serve more than 384. + + Args: + step: The random-access dispense. + plate: The plate the protocol runs on. + absent: Rules this model does not have. + + Returns: + A rejection, or None. + """ + if HEAD_PLATE_MISMATCH in absent: + return None + head = step.cassette_head if step.cassette_head is not None else "1 tube to 1 well" + if head in _MULTI_TUBE_HEADS and plate.wells > 24: + return Rejection(HEAD_PLATE_MISMATCH) + if head == "1 tube to 1 well" and plate.wells > 384: + return Rejection(HEAD_PLATE_MISMATCH) + return None + + +def claim_pump(step: Step, reservations: Reservations) -> None: + """Record which pump a step wants, and for which kind of fluid. + + A peristaltic dispense always claims an ordinary pump. A prime or purge claims one too, once it + names a cassette -- and if that cassette is a peristaltic wash one it is priming a wash manifold, + so it claims the wash side instead. A peristaltic wash step always claims the wash side. + + Args: + step: The step to record. + reservations: What the protocol has claimed so far, updated in place. + """ + claims = reservations.peri_pumps + if step.step_type in _PERI_WASH_STEP_TYPES: + claims = reservations.peri_wash_pumps + elif step.step_type in (StepType.PERI_PRIME, StepType.PERI_PURGE): + cassette = step.cassette_type if isinstance(step, (PeriPrime, PeriDispense)) else None + if cassette in _PERI_WASH_CASSETTES: + claims = reservations.peri_wash_pumps + elif cassette == "Any": + return + elif step.step_type is not StepType.PERI_DISPENSE: + return + claims[1 if _pump_of(step) == "Secondary" else 0] = True + + +def _pump_of(step: Step) -> PeriPump | None: + """Which pump a step drives. + + Args: + step: The step to ask. + + Returns: + The pump, or None when the step names none or drives no pump at all. + """ + if isinstance(step, (PeriPrime, PeriDispense, PeriWashAspirate, PeriWashDispense)): + return step.peri_pump + return None + + +def check_pump_exclusivity(reservations: Reservations) -> Rejection | None: + """Check that no pump serves both an ordinary peristaltic step and a peristaltic wash step. + + The two use different cassettes, so one pump cannot do both jobs in one protocol. Claims are + recorded before a step is checked, so a step rejected for its own reasons has still claimed its + pump. + + Args: + reservations: What the protocol claimed. + + Returns: + A rejection, or None. + """ + if any( + peri and peri_wash + for peri, peri_wash in zip(reservations.peri_pumps, reservations.peri_wash_pumps) + ): + return Rejection( + CONFLICT, + "A P-Dispense step cannot use the same Peri-pump as any PW-step. " + "They use mutually exclusive cassettes.", + ) + return None diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/step_checks.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/step_checks.py new file mode 100644 index 00000000000..9b10291fc18 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/step_checks.py @@ -0,0 +1,795 @@ +"""What each step type checks about itself. + +Every rule here is a pure function of the step, what the instrument has fitted and the plate. The +first check that fails is the answer. A step that a wash owns is checked with the flags that wash +pushes into it, which is why the wash rules build adjusted copies of their steps rather than +checking them as stored. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any, Callable + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.valve_box import ValveBox +from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import ( + STRIP_TRAVEL_RATES, + WASHER_TRAVEL_RATES, +) +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_auto_clean import ( + ManifoldAutoClean, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_wash import ManifoldWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_purge import PeriPurge +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_random_access_dispense import ( + PeriRandomAccessDispense, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_aspirate import PeriWashAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_dispense import PeriWashDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_aspirate import StripAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_dispense import StripDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_prime import StripPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_wash import StripWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_prime import SyringePrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.wash_1536 import Wash1536 +from pylabrobot.agilent.biotek.lhc.protocols.validation import checks +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import Rejection + +_VACUUM_FILTRATION_ON_1536 = 24694 +_CELL_WASHING_NEEDS_VACUUM_DELAY = 24726 +_NOTHING_TO_DO = 24712 +_MOVE_HOME_REQUIRED = 24711 +_NO_SECTOR_SELECTED = 24725 +_STRIP_OFFSET_X_RANGE = (-400, 400) +_STRIP_OFFSET_Y_RANGE = (-99, 99) +_STRIP_OFFSET_Z_RANGE = (1, 1500) +_WIDE_OFFSET_X_RANGE = (-400, 400) +_WIDE_OFFSET_Y_RANGE = (-99, 99) +_WIDE_VOLUME_MAXIMUM = 30000 +_VOLUME_MAXIMUM = 3000 + + +def _manifold_y_range(settings: InstrumentSettings) -> tuple[int, int]: + """How far the wash manifold reaches along the plate. + + Args: + settings: What the instrument has fitted. + + Returns: + The range. + """ + if settings.family is InstrumentFamily.MODEL_405_TS: + return checks.OFFSET_Y_405TS_RANGE + return checks.OFFSET_Y_OTHER_RANGE + + +def _manifold_z_range(settings: InstrumentSettings) -> tuple[int, int]: + """How deep the wash manifold reaches. + + Args: + settings: What the instrument has fitted. + + Returns: + The range. + """ + if settings.family is InstrumentFamily.MODEL_405_TS: + return checks.OFFSET_Z_405TS_RANGE + return checks.OFFSET_Z_OTHER_RANGE + + +def check_manifold_prime( + step: ManifoldPrime, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a wash manifold prime. + + The buffer is only checked when no valve box can switch it, which is the same condition that + makes a protocol commit to one buffer throughout. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Washer Prime " + rejection = checks.volume(step.volume // 1000, 5, 999, prefix) + switches_buffer = settings.valve_box in (ValveBox.WASHER, ValveBox.INTERNAL_4) + if rejection is None and not switches_buffer: + rejection = checks.buffer(step.buffer, prefix) + rejection = rejection or checks.flow_rate(step.flow_rate, 3, 11, prefix) + if rejection is not None: + return rejection + if step.prime_low_flow_path: + rejection = checks.volume(step.low_flow_path_volume // 1000, 5, 999, "Low Flow Path ") + if rejection is not None: + return rejection + if not step.submerge.enabled: + return None + return checks.long_duration(step.submerge.duration, False, "Submerge ") + + +def check_manifold_auto_clean( + step: ManifoldAutoClean, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check an automatic clean. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "AutoClean " + return checks.buffer(step.buffer, prefix) or checks.long_duration(step.duration, True, prefix) + + +def check_manifold_dispense( + step: ManifoldDispense, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a wash manifold dispense. + + Three limits move with what is fitted: the smallest volume is larger through a 96-tube manifold; + the two slowest flow rates need the cell washing module and a 96-tube dual-action manifold; and + using one of those rates additionally requires the vacuum delay. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Washer Dispense " + manifold = settings.washer_manifold + tube_96 = manifold in (WasherManifold.TUBE_96_DUAL, WasherManifold.TUBE_96_SINGLE) + minimum = 50 if tube_96 else 25 + switches_buffer = settings.valve_box in (ValveBox.WASHER, ValveBox.INTERNAL_4) + cell_washing_rates = manifold is WasherManifold.TUBE_96_DUAL and settings.cell_washing + + if switches_buffer and step.check_buffer: + rejection = checks.buffer(step.buffer, prefix) + if rejection is not None: + return rejection + if not step.check_volume: + return _check_manifold_pre_dispense(step, minimum) + rejection = checks.volume(step.volume, minimum, _VOLUME_MAXIMUM, prefix) + if rejection is not None: + return rejection + rejection = checks.flow_rate(step.flow_rate, 1 if cell_washing_rates else 3, 11, prefix) + if rejection is not None: + if manifold is not WasherManifold.TUBE_96_DUAL: + rejection = Rejection( + rejection.code, + rejection.reason + "\r\nCell Wash Flow Rates are only available when \r\n" + "the 96-tube dual-action manifold is installed.", + ) + return rejection + rejection = ( + checks.offset_x(step.positioning.x, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) + or checks.offset_y(step.positioning.y, *_manifold_y_range(settings), prefix) + or checks.offset_z(step.positioning.z, *_manifold_z_range(settings), prefix) + ) + if rejection is not None: + return rejection + if step.flow_rate in (1, 2) and not step.vacuum.enabled: + return Rejection( + _CELL_WASHING_NEEDS_VACUUM_DELAY, + prefix + "requires 'Delay start of Vacuum' option when using Cell Washing flow rates", + ) + if step.vacuum.enabled: + rejection = checks.volume( + step.vacuum.volume, 0, _VOLUME_MAXIMUM, prefix + "'Delay start of Vacuum' " + ) + if rejection is not None: + return rejection + return _check_manifold_pre_dispense(step, minimum) + + +def _check_manifold_pre_dispense(step: ManifoldDispense, minimum: int) -> Rejection | None: + """Check the pre-dispense of a wash manifold dispense. + + Args: + step: The step to check. + minimum: The smallest volume this manifold dispenses. + + Returns: + A rejection, or None. + """ + if not step.pre_dispense.enabled: + return None + prefix = "Washer Pre-dispense " + return checks.volume( + step.pre_dispense.volume, minimum, _VOLUME_MAXIMUM, prefix + ) or checks.flow_rate(step.pre_dispense.flow_rate, 3, 11, prefix) + + +def check_manifold_aspirate( + step: ManifoldAspirate, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a wash manifold aspirate. + + Filtering under vacuum is a short path: it is not offered on a 1536-well plate, the delay is a + time in seconds, and nothing else is checked because the tips do not move. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Washer Aspirate " + if step.vacuum_filtration: + if plate.wells == 1536: + return Rejection(_VACUUM_FILTRATION_ON_1536) + return checks.aspirate_delay(step.delay, True, prefix) + rejection = ( + checks.aspirate_delay(step.delay, False, prefix) + or checks.travel_rate(step.travel_rate, WASHER_TRAVEL_RATES, prefix) + or checks.offset_x(step.positioning.x, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) + or checks.offset_y(step.positioning.y, *_manifold_y_range(settings), prefix) + or checks.offset_z(step.positioning.z, *_manifold_z_range(settings), prefix) + ) + if rejection is not None: + return rejection + if plate.wells == 1536: + return checks.column_selection(step.columns.values, prefix) + if not step.secondary.enabled: + return None + prefix = "Secondary Aspirate " + return ( + checks.offset_x(step.secondary.positioning.x, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) + or checks.offset_y(step.secondary.positioning.y, *_manifold_y_range(settings), prefix) + or checks.offset_z(step.secondary.positioning.z, *_manifold_z_range(settings), prefix) + ) + + +def check_syringe_prime( + step: SyringePrime, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a syringe prime. + + The syringe is checked against a sixteen-tube manifold whatever is fitted, so a prime is never + rejected for the manifold it will actually run on. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Syringe Prime " + rejection = ( + checks.syringe(step.syringe, SyringeManifold.TUBE_16, prefix) + or checks.flow_rate(step.flow_rate, 1, 5, prefix) + or checks.prime_cycles(step.cycles) + or checks.pump_delay(step.pump_delay, prefix) + or checks.syringe_prime_volume(step.volume, step.flow_rate, prefix) + ) + if rejection is not None or not step.submerge.enabled: + return rejection + return checks.long_duration(step.submerge.duration, False, "Submerge ") + + +def check_syringe_dispense( + step: SyringeDispense, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a syringe dispense. + + The wider dispense offsets move three limits at once: the largest volume and both offset ranges. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Syringe Dispense " + wide = settings.advanced_dispense_offsets + maximum = _WIDE_VOLUME_MAXIMUM if wide else _VOLUME_MAXIMUM + x_range = _WIDE_OFFSET_X_RANGE if wide else checks.OFFSET_X_RANGE + y_range = _WIDE_OFFSET_Y_RANGE if wide else checks.OFFSET_Y_RANGE + rejection = ( + checks.syringe(step.syringe, settings.syringe_manifold, prefix) + or checks.flow_rate(step.flow_rate, 1, 5, prefix) + or checks.syringe_volume( + step.volume, step.flow_rate, settings.syringe_manifold, plate.wells, maximum, prefix + ) + or checks.offset_x(step.positioning.x, *x_range, prefix) + or checks.offset_y(step.positioning.y, *y_range, prefix) + or checks.column_selection(step.columns.values, prefix) + or checks.pump_delay(step.pump_delay, prefix) + or checks.offset_z(step.positioning.z, *checks.OFFSET_Z_RANGE, prefix) + ) + if rejection is not None or not step.pre_dispense.enabled: + return rejection + prefix = "Syringe Pre-dispense " + return checks.syringe_volume( + step.pre_dispense.volume, + step.flow_rate, + settings.syringe_manifold, + plate.wells, + maximum, + prefix, + ) or checks.count(step.pre_dispense.count, prefix) + + +def check_peri_prime( + step: PeriPrime, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a peristaltic prime or purge. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Peri-pump Purge " if isinstance(step, PeriPurge) else "Peri-pump Prime " + rejection = ( + checks.cassette_type(step.cassette_type, settings.peri_wash_enabled) + or checks.volume(step.volume, 1, _VOLUME_MAXIMUM, prefix) + or checks.peri_flow_rate(step.flow_rate, prefix) + ) + if rejection is not None or step.fixed_volume: + return rejection + return checks.duration(step.duration, prefix) + + +def check_peri_dispense( + step: PeriDispense, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a peristaltic dispense. + + Two limits move with what is fitted: the largest volume, and how far the step may reach across + the plate. Half-microlitre volumes are only offered when the step dispenses through one tube. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Peri-pump Dispense " + random_access = isinstance(step, PeriRandomAccessDispense) + chute_head = isinstance(step, PeriRandomAccessDispense) and ( + step.cassette_head == "8 tubes to 1 chute" + ) + tubes = 8 if chute_head else 1 + half_allowed = tubes == 1 and settings.half_ul_enabled + maximum = _WIDE_VOLUME_MAXIMUM if settings.advanced_dispense_offsets else _VOLUME_MAXIMUM + x_range = ( + _WIDE_OFFSET_X_RANGE + if settings.family is InstrumentFamily.MULTIFLO_FX + else checks.OFFSET_X_RANGE + ) + y_range = _WIDE_OFFSET_Y_RANGE if random_access else checks.OFFSET_Y_RANGE + rejection = ( + checks.cassette_type(step.cassette_type) + or checks.volume_or_half_microlitre(step.volume, 1, maximum, half_allowed, prefix) + or checks.peri_flow_rate(step.flow_rate, prefix) + or checks.offset_x(step.positioning.x, *x_range, prefix) + or checks.offset_y(step.positioning.y, *y_range, prefix) + or checks.offset_z(step.positioning.z, *checks.OFFSET_Z_RANGE, prefix) + or checks.column_selection(step.columns.values, prefix) + or checks.row_selection(step.rows.values, plate.rows // 8, prefix) + ) + if rejection is not None or not step.pre_dispense.enabled: + return rejection + prefix = "Peri-pump Pre-dispense " + return checks.volume_or_half_microlitre( + step.pre_dispense.volume, 1, maximum, settings.half_ul_enabled, prefix + ) or checks.count(step.pre_dispense.count, prefix) + + +def check_shake_soak( + step: ShakeSoak, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a shake or soak. + + A step that neither shakes, soaks nor moves the carrier home has nothing to do. Shaking and + soaking for more than a minute together requires moving the carrier home afterwards. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + if not step.move_carrier_home and not step.shake.enabled and not step.soak.enabled: + return Rejection(_NOTHING_TO_DO) + total = 0 + if step.shake.enabled: + rejection = checks.short_duration(step.shake.duration, "Shake ") + if rejection is not None: + return rejection + total += step.shake.duration + if step.soak.enabled: + rejection = checks.short_duration(step.soak.duration, "Soak ") + if rejection is not None: + return rejection + total += step.soak.duration + if total > 60 and not step.move_carrier_home: + return Rejection( + _MOVE_HOME_REQUIRED, + "'Move carrier home' is required if the total Shake/Soak durations exceed 1 minute", + ) + return None + + +def check_manifold_wash( + step: ManifoldWash, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a wash and each of the steps it will actually run. + + With a sector format selected the wash has to name a sector the plate has. The bottom wash is + checked without a buffer of its own, and its volume only when the stage that runs it is on. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + rejection = checks.wash_cycles(step.cycles) or checks.wash_format(step.wash_format) + if rejection is not None: + return rejection + if step.sectors.value == 0: + return Rejection(_NO_SECTOR_SELECTED) + bottom_wash = dataclasses.replace( + step.bottom_wash, check_buffer=False, check_volume=step.stages.bottom_wash + ) + rejection = ( + check_manifold_dispense(bottom_wash, settings, plate) + or check_manifold_aspirate(step.aspirate, settings, plate) + or check_manifold_dispense(step.dispense, settings, plate) + ) + if rejection is not None: + return rejection + if step.stages.shake_soak_after_dispense: + rejection = check_shake_soak(step.shake_soak, settings, plate) + if rejection is not None: + return rejection + if step.stages.final_aspirate: + return check_manifold_aspirate(step.final_aspirate, settings, plate) + return None + + +def check_wash_1536( + step: Wash1536, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a 1536-well wash and the steps it will actually run. + + The volume it pre-dispenses before washing is checked as a syringe volume against a 32-tube + large-bore manifold and 1536 wells, which is the only geometry this wash runs on, at the flow + rate of the syringe dispense it owns. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + if step.stages.pre_dispense_before: + prefix = "Pre-dispense before washing " + rejection = checks.syringe_volume( + step.pre_dispense_before_volume, + step.dispense.flow_rate, + SyringeManifold.TUBE_32_LARGE_BORE, + 1536, + _VOLUME_MAXIMUM, + prefix, + ) or checks.count(step.pre_dispense_before_count, prefix) + if rejection is not None: + return rejection + rejection = ( + checks.wash_cycles(step.cycles) + or checks.wash_format(step.wash_format) + or check_manifold_aspirate(step.aspirate, settings, plate) + or check_syringe_dispense(step.dispense, settings, plate) + ) + if rejection is not None: + return rejection + if step.stages.shake_soak_after_dispense: + rejection = check_shake_soak(step.shake_soak, settings, plate) + if rejection is not None: + return rejection + if step.stages.final_aspirate: + return check_manifold_aspirate(step.final_aspirate, settings, plate) + return None + + +def check_strip_prime( + step: StripPrime, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a strip washer prime. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Washer Prime " + rejection = ( + checks.flow_rate(step.flow_rate, 1, 11, prefix) + or checks.strip_prime_volume( + step.volume, step.flow_rate, settings.strip_washer_manifold, prefix + ) + or checks.prime_cycles(step.cycles) + ) + if rejection is not None or not step.submerge.enabled: + return rejection + return checks.long_duration(step.submerge.duration, False, "Submerge ") + + +def check_strip_aspirate( + step: StripAspirate, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a strip washer aspirate. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Washer Aspirate " + rejection = ( + checks.aspirate_delay(step.delay, False, prefix) + or checks.travel_rate(step.travel_rate, STRIP_TRAVEL_RATES, prefix) + or checks.offset_x(step.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.offset_y(step.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_z(step.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + ) + if rejection is not None: + return rejection + if plate.wells == 1536: + return checks.column_selection(step.columns.values, prefix) + if not step.secondary.enabled: + return None + prefix = "Secondary Aspirate " + return ( + checks.offset_x(step.secondary.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.offset_y(step.secondary.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_z(step.secondary.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + ) + + +def check_strip_dispense( + step: StripDispense, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a strip washer dispense. + + A step a wash owns skips the rate and volume checks unless it is the bottom wash or the + between-cycles dispense, and has no selections of its own to check. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "Bottom Wash " if step.is_bottom_wash else "Wash Dispense " + manifold = settings.strip_washer_manifold + checked = (not step.in_wash) or step.is_cycle_dispense or step.is_bottom_wash + if checked: + rejection = ( + checks.flow_rate(step.flow_rate, 1, 11, prefix) + or checks.strip_dispense_volume(step.volume, step.flow_rate, manifold, prefix) + or checks.offset_x(step.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.offset_y(step.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_z(step.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + ) + if rejection is not None: + return rejection + if step.vacuum.enabled: + rejection = checks.volume( + step.vacuum.volume, 0, _WIDE_VOLUME_MAXIMUM, prefix + " 'Delay start of Vacuum'" + ) + if rejection is not None: + return rejection + if not step.in_wash: + rejection = checks.column_selection(step.columns.values, prefix) or checks.row_selection( + step.rows.values, plate.rows // 8, prefix + ) + if rejection is not None: + return rejection + if not (step.pre_dispense.enabled or step.force_pre_dispense): + return None + prefix = "Wash Pre-dispense " + return ( + checks.flow_rate(step.pre_dispense.flow_rate, 1, 11, prefix) + or checks.strip_dispense_volume( + step.pre_dispense.volume, step.pre_dispense.flow_rate, manifold, prefix + ) + or checks.count(step.pre_dispense.count, prefix) + ) + + +def check_strip_wash( + step: StripWash, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a strip washer wash and the steps it will actually run. + + The two dispenses take their pre-dispense from different stage flags before anything is checked, + which is what makes their pre-dispense rules fire. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + bottom_wash = dataclasses.replace( + step.bottom_wash, + is_cycle_dispense=False, + is_bottom_wash=step.stages.bottom_wash, + force_pre_dispense=False, + pre_dispense=dataclasses.replace( + step.bottom_wash.pre_dispense, enabled=step.stages.pre_dispense_before + ), + ) + dispense = dataclasses.replace( + step.dispense, + is_cycle_dispense=True, + is_bottom_wash=False, + force_pre_dispense=step.stages.pre_dispense_between, + pre_dispense=dataclasses.replace(step.dispense.pre_dispense, enabled=False), + ) + rejection = ( + checks.wash_cycles(step.cycles) + or checks.wash_format(step.wash_format) + or check_strip_dispense(bottom_wash, settings, plate) + or check_strip_aspirate(step.aspirate, settings, plate) + or check_strip_dispense(dispense, settings, plate) + ) + if rejection is not None: + return rejection + if step.stages.shake_soak_after_dispense: + rejection = check_shake_soak(step.shake_soak, settings, plate) + if rejection is not None: + return rejection + if step.stages.final_aspirate: + return check_strip_aspirate(step.final_aspirate, settings, plate) + return None + + +def check_peri_wash_aspirate( + step: PeriWashAspirate, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a peristaltic wash aspirate. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "PW-Aspirate " + return ( + checks.volume(step.volume, 1, _WIDE_VOLUME_MAXIMUM, prefix) + or checks.offset_y(step.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_x(step.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.row_selection(step.rows.values, plate.rows // 8, prefix) + or checks.column_selection(step.columns.values, prefix) + or checks.flow_rate(step.flow_rate, 0, 4, prefix) + or checks.offset_z(step.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + ) + + +def check_peri_wash_dispense( + step: PeriWashDispense, settings: InstrumentSettings, plate: PlateRecord +) -> Rejection | None: + """Check a peristaltic wash dispense. + + Both pre-dispense values are checked whether or not pre-dispensing is switched on, which no + other dispense does. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + """ + prefix = "PW-Dispense " + return ( + checks.volume(step.volume, 1, _WIDE_VOLUME_MAXIMUM, prefix) + or checks.offset_y(step.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_x(step.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.volume(step.pre_dispense.volume, 25, _WIDE_VOLUME_MAXIMUM, "Pre-dispense ") + or checks.count(step.pre_dispense.count, "Pre-dispense ") + or checks.row_selection(step.rows.values, plate.rows // 8, prefix) + or checks.column_selection(step.columns.values, prefix) + or checks.flow_rate(step.flow_rate, 0, 7, prefix) + or checks.offset_z(step.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + ) + + +StepCheck = Callable[[Any, InstrumentSettings, PlateRecord], "Rejection | None"] +"""The rules for one kind of step. Each takes the step class it is registered for.""" + +CHECKS: dict[type[Step], StepCheck] = { + ManifoldPrime: check_manifold_prime, + ManifoldAutoClean: check_manifold_auto_clean, + ManifoldDispense: check_manifold_dispense, + ManifoldAspirate: check_manifold_aspirate, + ManifoldWash: check_manifold_wash, + SyringePrime: check_syringe_prime, + SyringeDispense: check_syringe_dispense, + PeriPrime: check_peri_prime, + PeriPurge: check_peri_prime, + PeriDispense: check_peri_dispense, + PeriRandomAccessDispense: check_peri_dispense, + ShakeSoak: check_shake_soak, + Wash1536: check_wash_1536, + StripPrime: check_strip_prime, + StripAspirate: check_strip_aspirate, + StripDispense: check_strip_dispense, + StripWash: check_strip_wash, + PeriWashAspirate: check_peri_wash_aspirate, + PeriWashDispense: check_peri_wash_dispense, +} +"""Which rules apply to each step class.""" + + +def check_step(step: Step, settings: InstrumentSettings, plate: PlateRecord) -> Rejection | None: + """Check a step's own fields. + + Args: + step: The step to check. + settings: What the instrument has fitted. + plate: The plate the protocol runs on. + + Returns: + A rejection, or None. + + Raises: + KeyError: If no rules are known for this kind of step. + """ + return CHECKS[type(step)](step, settings, plate) From 354310fa76e53b30173817502248d2d65a7dcc53 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Mon, 31 Aug 2026 13:58:50 +0200 Subject: [PATCH 05/19] completed code, minor bugfix --- pylabrobot/agilent/biotek/lhc/__init__.py | 11 + .../agilent/biotek/lhc/comm/__init__.py | 4 + pylabrobot/agilent/biotek/lhc/comm/link.py | 252 +++++++++++ .../agilent/biotek/lhc/devices/__init__.py | 22 + .../agilent/biotek/lhc/devices/batch.py | 342 +++++++++++++++ .../biotek/lhc/devices/components/__init__.py | 11 + .../components/peristaltic_dispenser.py | 259 +++++++++++ .../devices/components/syringe_dispenser.py | 112 +++++ .../biotek/lhc/devices/components/washer.py | 405 ++++++++++++++++++ .../agilent/biotek/lhc/devices/el406.py | 364 ++++++++++++++++ .../agilent/biotek/lhc/devices/execution.py | 350 +++++++++++++++ .../agilent/biotek/lhc/devices/multiflo.py | 352 +++++++++++++++ .../agilent/biotek/lhc/devices/multiflo_fx.py | 376 ++++++++++++++++ .../agilent/biotek/lhc/devices/runtime.py | 96 +++++ .../biotek/lhc/devices/settings_document.py | 256 +++++++++++ .../biotek/lhc/devices/settings_query.py | 312 ++++++++++++++ .../biotek/lhc/devices/washer_405ts.py | 342 +++++++++++++++ .../biotek/lhc/plate_geometry/__init__.py | 16 +- .../biotek/lhc/plate_geometry/resolution.py | 152 +++++++ .../biotek/lhc/protocols/steps/definition.py | 117 ++++- .../lhc/protocols/steps/step_interface.py | 14 + .../steps/steps/manifold_aspirate.py | 4 +- .../steps/steps/manifold_auto_clean.py | 4 +- .../steps/steps/manifold_dispense.py | 4 +- .../protocols/steps/steps/manifold_prime.py | 4 +- .../protocols/steps/steps/manifold_wash.py | 5 +- .../protocols/steps/steps/peri_dispense.py | 4 +- .../lhc/protocols/steps/steps/peri_prime.py | 4 +- .../steps/peri_random_access_dispense.py | 4 +- .../steps/steps/peri_wash_aspirate.py | 5 +- .../steps/steps/peri_wash_dispense.py | 4 +- .../lhc/protocols/steps/steps/shake_soak.py | 4 +- .../protocols/steps/steps/strip_aspirate.py | 4 +- .../protocols/steps/steps/strip_dispense.py | 4 +- .../lhc/protocols/steps/steps/strip_prime.py | 6 +- .../lhc/protocols/steps/steps/strip_wash.py | 4 +- .../protocols/steps/steps/syringe_dispense.py | 4 +- .../protocols/steps/steps/syringe_prime.py | 4 +- .../lhc/protocols/steps/steps/wash_1536.py | 4 +- .../lhc/protocols/validation/__init__.py | 14 +- .../lhc/protocols/validation/configuration.py | 45 +- .../lhc/protocols/validation/protocol_pass.py | 9 +- .../lhc/protocols/validation/reservations.py | 27 +- 43 files changed, 4295 insertions(+), 41 deletions(-) create mode 100644 pylabrobot/agilent/biotek/lhc/comm/link.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/batch.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/components/washer.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/el406.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/execution.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/multiflo.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/runtime.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/settings_document.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/settings_query.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py create mode 100644 pylabrobot/agilent/biotek/lhc/plate_geometry/resolution.py diff --git a/pylabrobot/agilent/biotek/lhc/__init__.py b/pylabrobot/agilent/biotek/lhc/__init__.py index a2f581dddc9..79f34c168a2 100644 --- a/pylabrobot/agilent/biotek/lhc/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/__init__.py @@ -1 +1,12 @@ """Driver generation for the BioTek washer/dispenser family.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.devices.el406 import EL406 +from pylabrobot.agilent.biotek.lhc.devices.multiflo import MultiFlo +from pylabrobot.agilent.biotek.lhc.devices.multiflo_fx import MultiFloFX +from pylabrobot.agilent.biotek.lhc.devices.washer_405ts import Washer405TS +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol +from pylabrobot.agilent.biotek.lhc.protocols.read_write_utilities.protocol_file import read, write + +__all__ = ["EL406", "MultiFlo", "MultiFloFX", "Protocol", "Washer405TS", "read", "write"] diff --git a/pylabrobot/agilent/biotek/lhc/comm/__init__.py b/pylabrobot/agilent/biotek/lhc/comm/__init__.py index 9fb55b4a5d7..47e919ef087 100644 --- a/pylabrobot/agilent/biotek/lhc/comm/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/comm/__init__.py @@ -4,6 +4,7 @@ from .connection import open_transport, transport_for from .ftdi_transport import FtdiTransport, is_ftdi_port, serial_number +from .link import ACK, NAK, Link from .serial_transport import SerialTransport from .transport import ( BAUDRATE, @@ -13,10 +14,13 @@ ) __all__ = [ + "ACK", "BAUDRATE", "DEFAULT_READ_TIMEOUT", "DEFAULT_WRITE_TIMEOUT", + "NAK", "FtdiTransport", + "Link", "SerialTransport", "Transport", "is_ftdi_port", diff --git a/pylabrobot/agilent/biotek/lhc/comm/link.py b/pylabrobot/agilent/biotek/lhc/comm/link.py new file mode 100644 index 00000000000..58fe76260b6 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/comm/link.py @@ -0,0 +1,252 @@ +"""One framed request and its reply. + +The transports below this module move bytes; the device layer above it decides which commands to +send and in what order. This is the single place that knows the shape of an exchange: purge, write +the header, write the payload, read the acknowledgement, read the reply frame, check it and turn a +non-zero status into an exception. + +A link is what a device holds. It owns the input/output lifecycle and nothing else, so a device +never touches a transport directly and never learns which kind of transport it got. +""" + +from __future__ import annotations + +import asyncio +import logging + +from pylabrobot.agilent.biotek.lhc.comm.connection import transport_for +from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.error_handling import ( + NOT_ACKNOWLEDGED, + PORT_WOULD_NOT_OPEN, + REPLY_TIMED_OUT, + ErrorKind, + fail, + raise_for_status, +) +from pylabrobot.agilent.biotek.lhc.serialization.command import Command +from pylabrobot.agilent.biotek.lhc.serialization.frame import HEADER_LENGTH, Header + +logger = logging.getLogger(__name__) + +ACK = 0x06 +NAK = 0x15 + +_ACK_POLL_INTERVAL = 0.01 +_PAYLOAD_WRITE_DELAY = 0.001 +_PURGE_ROUNDS = 6 + + +class Link: + """The framed connection to one instrument. + + Args: + port: The port the instrument is on. Ignored when ``io`` is given. + family: Which family the instrument belongs to, which decides what an error code means. + name: Human-readable instrument name, used in logs and error messages. + timeout: Default reply timeout in seconds. A command that answers only once it has finished + moving carries a longer one of its own. + io: An already built transport to use instead of opening ``port``. This is how a capture and + replay double is supplied to a test; there is no other way past the port string. + """ + + def __init__( + self, + port: str = "", + family: InstrumentFamily = InstrumentFamily.EL406, + name: str = "BioTek instrument", + timeout: float = DEFAULT_READ_TIMEOUT, + io: Transport | None = None, + ) -> None: + if io is None and not port: + raise ValueError("either a port or a transport is required") + self._io = io if io is not None else transport_for(port=port, name=name, timeout=timeout) + self._family = family + self._name = name + self._timeout = timeout + self._open = False + self._exchange = asyncio.Lock() + + @property + def family(self) -> InstrumentFamily: + """Which family the instrument belongs to.""" + return self._family + + @property + def name(self) -> str: + """The instrument's name, as it appears in logs and error messages.""" + return self._name + + @property + def port(self) -> str: + """The port the instrument is on.""" + return self._io.port + + @property + def is_open(self) -> bool: + """Whether the link is open.""" + return self._open + + async def setup(self) -> None: + """Open the link. Calling this on an open link does nothing. + + Raises: + LinkError: If the port will not open. + """ + if self._open: + return + try: + await self._io.setup() + except Exception as error: + raise fail( + ErrorKind.LINK, + f"{self._name} on {self._io.port} will not open: {error}", + operation="open", + code=PORT_WOULD_NOT_OPEN, + ) from error + self._open = True + logger.info("opened %s on %s", self._name, self._io.port) + + async def stop(self) -> None: + """Close the link. Calling this on a closed link does nothing.""" + if not self._open: + return + self._open = False + await self._io.stop() + logger.info("closed %s on %s", self._name, self._io.port) + + async def purge(self) -> None: + """Discard whatever is buffered in either direction.""" + for _ in range(_PURGE_ROUNDS): + await self._io.purge() + + async def request(self, command: Command, operation: str = "") -> bytes: + """Send a command and read its reply. + + One exchange at a time: the lock is what keeps two callers from interleaving their frames on a + link that has no way to tell one reply from another. + + Args: + command: The command to send. + operation: What is being attempted, for the message of any exception raised. + + Returns: + The reply's answer, with the instrument's status split off. + + Raises: + LinkError: If the link is closed, the write fails, nothing acknowledges the command, or the + reply does not arrive intact. + BiotekError: A subclass matching what failed, if the instrument reports an error status. + """ + if not self._open: + raise fail(ErrorKind.LINK, f"{self._name} is not open", operation=operation or "request") + timeout = self._timeout if command.timeout is None else command.timeout + async with self._exchange: + await self.purge() + await self._write(command.to_bytes(), operation) + await self._read_ack(operation) + header, payload = await self._read_reply(command, timeout, operation) + if not command.reply_is_intact(header, payload): + raise fail( + ErrorKind.LINK, + f"reply to command {command.number} did not arrive intact", + operation=operation or "request", + ) + status, answer = command.parse_reply(payload) + raise_for_status(status, self._family, operation) + return answer + + async def _write(self, frame: bytes, operation: str) -> None: + """Write a frame, header first and payload second, as the instrument expects it. + + Args: + frame: The header followed by the payload. + operation: What is being attempted, for the message of any exception raised. + + Raises: + LinkError: If the write fails. + """ + header, payload = frame[:HEADER_LENGTH], frame[HEADER_LENGTH:] + try: + await self._io.write(header) + if payload: + await asyncio.sleep(_PAYLOAD_WRITE_DELAY) + await self._io.write(payload) + except Exception as error: + raise fail( + ErrorKind.LINK, + f"writing to {self._name} on {self._io.port} failed: {error}", + operation=operation or "write", + ) from error + logger.debug("[%s] sent %s", self._io.port, frame.hex()) + + async def _read_ack(self, operation: str) -> None: + """Wait for the instrument to acknowledge a command. + + Args: + operation: What is being attempted, for the message of any exception raised. + + Raises: + LinkError: If the instrument refuses the command or does not answer at all. + """ + deadline = asyncio.get_running_loop().time() + self._timeout + while asyncio.get_running_loop().time() < deadline: + byte = await self._io.read(1) + if not byte: + await asyncio.sleep(_ACK_POLL_INTERVAL) + continue + if byte[0] == ACK: + return + if byte[0] == NAK: + raise fail( + ErrorKind.LINK, + f"{self._name} refused the command", + operation=operation or "acknowledge", + code=NOT_ACKNOWLEDGED, + ) + logger.debug("[%s] discarding %#04x while waiting to be acknowledged", self._io.port, byte[0]) + raise fail( + ErrorKind.LINK, + f"{self._name} did not acknowledge the command within {self._timeout:g}s", + operation=operation or "acknowledge", + code=NOT_ACKNOWLEDGED, + ) + + async def _read_reply( + self, command: Command, timeout: float, operation: str + ) -> tuple[Header, bytes]: + """Read a reply frame: the header, then as many payload bytes as it declares. + + Args: + command: The command that was sent, named in any exception raised. + timeout: How long to wait for the header and the payload, each. + operation: What is being attempted, for the message of any exception raised. + + Returns: + The reply header and its payload. + + Raises: + LinkError: If either part does not arrive in time. + """ + raw = await self._io.read_exactly(HEADER_LENGTH, timeout) + if len(raw) < HEADER_LENGTH: + raise fail( + ErrorKind.LINK, + f"{self._name} answered command {command.number} with {len(raw)} of " + f"{HEADER_LENGTH} header bytes within {timeout:g}s", + operation=operation or "read", + code=REPLY_TIMED_OUT, + ) + header = Header.from_bytes(raw) + payload = await self._io.read_exactly(header.payload_length, timeout) + if len(payload) < header.payload_length: + raise fail( + ErrorKind.LINK, + f"{self._name} answered command {command.number} with {len(payload)} of " + f"{header.payload_length} payload bytes within {timeout:g}s", + operation=operation or "read", + code=REPLY_TIMED_OUT, + ) + logger.debug("[%s] got %s %s", self._io.port, raw.hex(), payload.hex()) + return header, payload diff --git a/pylabrobot/agilent/biotek/lhc/devices/__init__.py b/pylabrobot/agilent/biotek/lhc/devices/__init__.py index e69de29bb2d..e7e0666342b 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/devices/__init__.py @@ -0,0 +1,22 @@ +"""The instruments, and the behaviour they share. + +One class per model, and each is what a user builds: :class:`~.el406.EL406`, +:class:`~.washer_405ts.Washer405TS`, :class:`~.multiflo.MultiFlo` and +:class:`~.multiflo_fx.MultiFloFX`. There is no base class -- what the models have in common is held +rather than inherited: the link to the instrument, the record of what it has fitted, and the +functions in :mod:`.execution` and :mod:`.batch` that run a step, bracket a batch and check a +protocol before it runs. Each model spells out its own public methods over those, so what it can do +is readable in one file. + +Only the plain records are re-exported here. A step is encoded against +:class:`~.instrument_settings.InstrumentSettings`, so this package is imported from below as well as +from above and must stay cheap to import; the instruments themselves are exported from the package +above, or imported from their own modules. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.devices.build_rules import BuildRules, basecode_for, rules_for +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings + +__all__ = ["BuildRules", "InstrumentSettings", "basecode_for", "rules_for"] diff --git a/pylabrobot/agilent/biotek/lhc/devices/batch.py b/pylabrobot/agilent/biotek/lhc/devices/batch.py new file mode 100644 index 00000000000..b1694ab2b46 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/batch.py @@ -0,0 +1,342 @@ +"""Opening and closing a batch. + +The instrument has no notion of a step outside a batch: opening one is what homes the motors and +tells the firmware which plate it is working with, and every step then carries that plate's byte. +The bracket is re-entrant, so one imperative call brackets itself while a caller who opens a batch +by hand pays the opening cost once for a whole sequence of them. + +Opening is not only the one command. What a protocol requires of the peristaltic pumps was worked +out by the validation pass; this is where the hardware is made to match it -- the cassette in each +pinned pump, the dispense head on the models that have a settable one, and that each pump a step +drives can run at all. A protocol that reserves nothing, which is every wash-only protocol, opens +with the one command and nothing before it. +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.motion.motor import Motor +from pylabrobot.agilent.biotek.lhc.enums.motion.motor_home_type import MotorHomeType +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_head import ( + CASSETTE_HEAD_TO_BYTE, + CassetteHead, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_mode import CASSETTE_MODE_TO_BYTE +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import ( + CASSETTE_TYPE_TO_BYTE, + CassetteType, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE, PeriPump +from pylabrobot.agilent.biotek.lhc.error_handling import raise_for_status +from pylabrobot.agilent.biotek.lhc.protocols.validation.reservations import ( + CONFLICT, + check_head_fits, +) +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber +from pylabrobot.agilent.biotek.lhc.serialization.commands.configuration import ( + ByteQuery, + SelectorQuery, + SelectorWrite, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import HomeVerifyMotors +from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import ( + ExitProtocol, + InitProtocol, +) + +logger = logging.getLogger(__name__) + +PUMP_COVER_OPEN = 24728 +"""The pump's cover is open, so it cannot turn.""" + +NO_PUMP_ASSEMBLY = 24729 +"""No pump assembly is in place.""" + +_PUMP_READY = 2 +_PUMP_COVER_OPEN = 1 +_PLATE_WIDE_HEAD: CassetteHead = "8 tubes to 8 wells" +_SINGLE_TUBE_HEAD: CassetteHead = "1 tube to 1 well" + + +@asynccontextmanager +async def batch(runtime: Runtime, home_on_close: bool = False) -> AsyncIterator[None]: + """Hold a batch open for the duration of the block. + + Re-entrant: inside a batch that is already open this does nothing, so a call that brackets itself + is free to be made inside a larger batch. The instrument is held for as long as the batch is open + and released whether the block succeeded, failed, or the open itself failed. + + Args: + runtime: The device's state. + home_on_close: Whether to home the transport before closing. The instrument does not do this of + its own accord; ask for it when the next thing to touch the plate is a person. + + Yields: + Nothing. The batch is open for the body of the block. + + Raises: + BiotekError: If the batch cannot be opened, including because the hardware cannot be made to + match what the protocol requires. + """ + if runtime.in_batch: + yield + return + await runtime.port.acquire() + try: + await open_batch(runtime) + except BaseException: + runtime.port.release() + raise + runtime.in_batch = True + try: + yield + finally: + runtime.in_batch = False + try: + await close_batch(runtime, home_on_close=home_on_close) + finally: + runtime.port.release() + + +async def open_batch(runtime: Runtime) -> None: + """Make the pumps match what the protocol requires, then open the batch. + + Args: + runtime: The device's state, whose reservations say what to match. + + Raises: + BiotekError: If the hardware cannot be made to match, if a pump a step drives cannot run, or if + the instrument will not open the batch. + RejectedError: If no plate has been set. + """ + plate_type = runtime.plate_type + await _reconcile_pumps(runtime) + await runtime.link.request(InitProtocol(plate_type), operation="open batch") + logger.info("batch open on %s for a %s", runtime.link.name, plate_type.name) + + +async def close_batch(runtime: Runtime, home_on_close: bool = False) -> None: + """Close the batch. + + Args: + runtime: The device's state. + home_on_close: Whether to home the transport first. + + Raises: + BiotekError: If the instrument will not close the batch. + """ + if home_on_close: + await runtime.link.request( + HomeVerifyMotors(int(MotorHomeType.HOME_XYZ_MOTORS), int(Motor.CARRIER_X)), + operation="home", + ) + await runtime.link.request(ExitProtocol(), operation="close batch") + logger.info("batch closed on %s", runtime.link.name) + + +async def _reconcile_pumps(runtime: Runtime) -> None: + """Make the peristaltic hardware match what the protocol reserved. + + In order: the dispense head, on the model that has a settable one; then each pinned pump's + cassette; then that every pump a step drives can run. A protocol that pinned nothing and drives + no pump does none of it. + + Args: + runtime: The device's state. + + Raises: + BiotekError: If a required cassette or head cannot be fitted, or a pump cannot run. + """ + reservations = runtime.reservations + head = reservations.cassette_head + move_primary = False + move_secondary = False + if runtime.reconciles_cassette_head: + head, move_primary, move_secondary = await _resolve_head(runtime) + await _reconcile_cassette(runtime, "Primary", reservations.cassette_primary, head, move_primary) + await _reconcile_cassette( + runtime, "Secondary", reservations.cassette_secondary, head, move_secondary + ) + if reservations.uses_primary: + await _check_pump_can_run(runtime.link, "Primary") + if reservations.uses_secondary: + await _check_pump_can_run(runtime.link, "Secondary") + + +async def _resolve_head(runtime: Runtime) -> tuple[CassetteHead | None, bool, bool]: + """Work out which dispense head the protocol needs and whether it has to be written. + + The head is fitted to one pump -- the second when two are fitted -- so at most one of the two + answers is ever true. Writing it is only possible where that pump's cassette is pinned to a + particular one, since the head is written as part of setting the cassette; where the protocol + accepts any cassette there is nothing to write and the fitted head has to be right already. + + Args: + runtime: The device's state. + + Returns: + The head to write, and whether it must be written for the primary and for the secondary pump. + + Raises: + BiotekError: If the fitted head cannot serve the protocol, or does not suit the plate. + """ + settings = runtime.settings + reservations = runtime.reservations + required = reservations.cassette_head + fitted = await _fitted_head(runtime) if settings.single_well_enabled else None + secondary = settings.peri_pump_2 + + if reservations.single_well: + if required is None: + # Nothing reserved a head, so the fitted one has to serve single wells already: a head that + # feeds eight wells at once, or no head at all, cannot. + if fitted is None or fitted == _PLATE_WIDE_HEAD: + raise_for_status(CONFLICT, runtime.family, "cassette head") + if reservations.dispense_reserved: + _require_head_fits(runtime, fitted) + return None, False, False + pinned = reservations.cassette_secondary if secondary else reservations.cassette_primary + move = fitted != required and pinned is not None + if reservations.dispense_reserved: + _require_head_fits(runtime, required) + return required, move and not secondary, move and secondary + + if not settings.single_well_enabled: + return required, False, False + if not ( + (reservations.any_cassette_primary and not secondary) or reservations.any_cassette_secondary + ): + return required, False, False + + # No step needs single wells, but one accepts any cassette on an instrument that can dispense + # into them, so the head has to be the plate-wide one. + if required is None: + required = _PLATE_WIDE_HEAD + elif required != _PLATE_WIDE_HEAD: + raise_for_status(CONFLICT, runtime.family, "cassette head") + move = fitted != required + pinned = reservations.cassette_secondary if secondary else reservations.cassette_primary + if move and pinned is None: + raise_for_status(CONFLICT, runtime.family, "cassette head") + if reservations.dispense_reserved: + _require_head_fits(runtime, required) + return required, move and not secondary, move and secondary + + +def _require_head_fits(runtime: Runtime, head: CassetteHead | None) -> None: + """Check a dispense head against the plate on the carrier. + + Args: + runtime: The device's state. + head: The head that would be used. + + Raises: + BiotekError: If the head cannot serve that plate. + RejectedError: If no plate has been set. + """ + rejection = check_head_fits(head, runtime.plate_record, runtime.rules.absent_checks) + if rejection is not None: + raise_for_status(rejection.code, runtime.family, "cassette head") + + +async def _fitted_head(runtime: Runtime) -> CassetteHead | None: + """Read which dispense head is on the instrument. + + Args: + runtime: The device's state. + + Returns: + The head, or None when the instrument reports one this package does not know, which is how it + reports having none. + """ + selector = PERI_PUMP_TO_BYTE["Secondary" if runtime.settings.peri_pump_2 else "Primary"] + command = SelectorQuery(CommandNumber.GET_EXT_PERI_CASSETTE_HEAD, selector) + answer = command.parse(await runtime.link.request(command, operation="fitted cassette head")) + for head, value in CASSETTE_HEAD_TO_BYTE.items(): + if value == answer: + return head + return None + + +async def _reconcile_cassette( + runtime: Runtime, + pump: PeriPump, + required: CassetteType | None, + head: CassetteHead | None, + move_head: bool, +) -> None: + """Make one pump hold the cassette the protocol requires. + + Nothing happens unless the protocol pinned a cassette for that pump. Otherwise the fitted one is + read and, if it is wrong -- or right while the head still has to move -- the instrument's own + setting decides what may be done about it: only an instrument set to fit the cassette itself is + written to, and any other setting reports the mismatch, because there is nobody here to ask to + change a cassette by hand. A write is read back, so a cassette that did not change is reported + rather than run with. + + Args: + runtime: The device's state. + pump: Which pump to reconcile. + required: The cassette the protocol pinned, or None when it pinned none. + head: The dispense head to write along with the cassette. + move_head: Whether the head has to be written. + + Raises: + BiotekError: If the cassette cannot be made to match. + """ + if required is None: + return + link = runtime.link + selector = PERI_PUMP_TO_BYTE[pump] + fitted = SelectorQuery(CommandNumber.GET_SELECTED_PERI_CASSETTE_TYPE, selector) + answer = fitted.parse(await link.request(fitted, operation=f"{pump.lower()} pump cassette")) + if answer == CASSETTE_TYPE_TO_BYTE[required] and not move_head: + return + mode = ByteQuery(CommandNumber.GET_CASSETTE_MODE) + setting = mode.parse(await link.request(mode, operation="cassette mode")) + if setting != CASSETTE_MODE_TO_BYTE["Auto set"]: + raise_for_status(CONFLICT, runtime.family, f"{pump.lower()} pump cassette") + await link.request( + SelectorWrite( + CommandNumber.SET_SELECTED_PERI_CASSETTE_TYPE, selector, CASSETTE_TYPE_TO_BYTE[required] + ), + operation=f"{pump.lower()} pump cassette", + ) + answer = fitted.parse(await link.request(fitted, operation=f"{pump.lower()} pump cassette")) + if answer != CASSETTE_TYPE_TO_BYTE[required]: + raise_for_status(CONFLICT, runtime.family, f"{pump.lower()} pump cassette") + logger.info("set the %s pump to a %s cassette", pump.lower(), required) + if not move_head: + return + await link.request( + SelectorWrite( + CommandNumber.SET_EXT_PERI_CASSETTE_HEAD, + selector, + CASSETTE_HEAD_TO_BYTE[head if head is not None else _SINGLE_TUBE_HEAD], + ), + operation="cassette head", + ) + logger.info("set the dispense head to %s", head) + + +async def _check_pump_can_run(link: Link, pump: PeriPump) -> None: + """Check that a pump a step drives is in a state to turn. + + Args: + link: The link to the instrument. + pump: Which pump to check. + + Raises: + BiotekError: If its cover is open or no pump assembly is in place. + """ + command = SelectorQuery(CommandNumber.GET_SELECTED_PERI_STATE, PERI_PUMP_TO_BYTE[pump]) + state = command.parse(await link.request(command, operation=f"{pump.lower()} pump state")) + if state == _PUMP_READY: + return + code = PUMP_COVER_OPEN if state == _PUMP_COVER_OPEN else NO_PUMP_ASSEMBLY + raise_for_status(code, link.family, f"{pump.lower()} pump") diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/__init__.py b/pylabrobot/agilent/biotek/lhc/devices/components/__init__.py index e69de29bb2d..afb29b5f23c 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/components/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/devices/components/__init__.py @@ -0,0 +1,11 @@ +"""The capability objects an instrument exposes for the hardware it is fitted with.""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( + PeristalticDispenser, +) +from pylabrobot.agilent.biotek.lhc.devices.components.syringe_dispenser import SyringeDispenser +from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher + +__all__ = ["PeristalticDispenser", "PlateWasher", "SyringeDispenser"] diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py b/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py new file mode 100644 index 00000000000..0565c0d490a --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py @@ -0,0 +1,259 @@ +"""Dispensing from a peristaltic pump. + +The capability object an instrument with peristaltic pumps exposes. Each method runs one step on its +own, checked and bracketed in a batch; several of them inside ``async with device.batch():`` share +one opening. + +Two things about a pump are decided before a step runs rather than by it. Which cassette is in which +pump is reconciled when the batch opens, from what the checked protocol asked for, so a step names +the cassette it needs and the opening is what fits it. And the peristaltic wash steps are a separate +pair of methods because they draw through wash cassettes, which no ordinary peristaltic step can +share a pump with. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_head import CassetteHead +from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import CassetteType +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import PeriFlowRate +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PeriPump +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( + PreDispense, + RandomAccess, + WellVolumeMap, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_purge import PeriPurge +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_random_access_dispense import ( + PeriRandomAccessDispense, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_aspirate import PeriWashAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_dispense import PeriWashDispense + + +class PeristalticDispenser: + """The peristaltic pumps an instrument is fitted with. + + Args: + runtime: The state of the device this belongs to, which is where the link, the fitted options + and the plate on the carrier come from. + """ + + def __init__(self, runtime: Runtime) -> None: + self._runtime = runtime + + async def dispense( + self, + volume: int, + flow_rate: PeriFlowRate = "High", + cassette_type: CassetteType | None = "Any", + peri_pump: PeriPump | None = "Primary", + positioning: Positioning | None = None, + pre_dispense: PreDispense | None = None, + columns: WellMask | None = None, + rows: WellMask | None = None, + well_volumes: WellVolumeMap | None = None, + cassette_head: CassetteHead | None = None, + ) -> None: + """Dispense from a peristaltic pump. + + Giving a per-well volume map, a dispense head, or both makes this a dispense into individually + chosen wells, which is a different payload and a different command. An instrument old enough not + to carry those fields cannot run it, and the check refuses the step rather than quietly running + it across the whole plate. + + Args: + volume: Volume per tube in µL. Ignored for the wells a volume map gives a volume of their own. + flow_rate: How fast to dispense. + cassette_type: The cassette the step requires, or None to accept whatever is fitted. + peri_pump: Which pump to drive, or None to leave the choice to the instrument. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, at what volume and how many times. + columns: Which columns to dispense into. + rows: Which rows to dispense into. + well_volumes: A volume for each of sixteen individually chosen wells. + cassette_head: How the fitted cassette's tubes map onto wells. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step: PeriDispense + if well_volumes is None and cassette_head is None: + step = PeriDispense( + volume=volume, flow_rate=flow_rate, cassette_type=cassette_type, peri_pump=peri_pump + ) + else: + step = PeriRandomAccessDispense( + volume=volume, + flow_rate=flow_rate, + cassette_type=cassette_type, + peri_pump=peri_pump, + cassette_head=cassette_head, + ) + if well_volumes is not None: + step.well_volumes = well_volumes + if positioning is not None: + step.positioning = positioning + if pre_dispense is not None: + step.pre_dispense = pre_dispense + if columns is not None: + step.columns = columns + if rows is not None: + step.rows = rows + await run_steps(self._runtime, [step]) + + async def prime( + self, + volume: int = 300, + duration: int | None = None, + flow_rate: PeriFlowRate = "High", + cassette_type: CassetteType | None = "Any", + peri_pump: PeriPump | None = "Primary", + home_when_finished: bool = True, + random_access: RandomAccess | None = None, + ) -> None: + """Pump fluid through a cassette until its tubing is full. + + Args: + volume: Volume per tube in µL, used when no duration is given. + duration: How long to pump in seconds. Given, the step runs to a duration rather than to a + volume. + flow_rate: How fast to pump. + cassette_type: The cassette the step requires, or None to accept whatever is fitted. + peri_pump: Which pump to drive, or None to leave the choice to the instrument. + home_when_finished: Whether the carrier homes after the step. + random_access: Whether the step primes a random-access head, and which one. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = PeriPrime( + volume=volume, + fixed_volume=duration is None, + flow_rate=flow_rate, + cassette_type=cassette_type, + peri_pump=peri_pump, + home_when_finished=home_when_finished, + ) + if duration is not None: + step.duration = duration + if random_access is not None: + step.random_access = random_access + await run_steps(self._runtime, [step]) + + async def purge( + self, + volume: int = 300, + duration: int | None = None, + flow_rate: PeriFlowRate = "High", + cassette_type: CassetteType | None = "Any", + peri_pump: PeriPump | None = "Primary", + home_when_finished: bool = True, + random_access: RandomAccess | None = None, + ) -> None: + """Pump a cassette empty, running fluid to waste rather than to the plate. + + Args: + volume: Volume per tube in µL, used when no duration is given. + duration: How long to pump in seconds. Given, the step runs to a duration rather than to a + volume. + flow_rate: How fast to pump. + cassette_type: The cassette the step requires, or None to accept whatever is fitted. + peri_pump: Which pump to drive, or None to leave the choice to the instrument. + home_when_finished: Whether the carrier homes after the step. + random_access: Whether the step purges a random-access head, and which one. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = PeriPurge( + volume=volume, + fixed_volume=duration is None, + flow_rate=flow_rate, + cassette_type=cassette_type, + peri_pump=peri_pump, + home_when_finished=home_when_finished, + ) + if duration is not None: + step.duration = duration + if random_access is not None: + step.random_access = random_access + await run_steps(self._runtime, [step]) + + async def wash_aspirate( + self, + volume: int = 100, + flow_rate: int = 2, + peri_pump: PeriPump | None = "Primary", + positioning: Positioning | None = None, + columns: WellMask | None = None, + rows: WellMask | None = None, + ) -> None: + """Draw spent medium off gently through a peristaltic wash manifold. + + Paired with :meth:`wash_dispense`, this exchanges medium without disturbing what is growing in + the well. It needs a wash cassette and manifold on the pump it drives, which no ordinary + peristaltic step may then share. + + Args: + volume: Volume per tube in µL. + flow_rate: How fast to aspirate, as a position on the aspirate rate scale. + peri_pump: Which pump to drive. + positioning: Where in the well to aspirate. + columns: Which columns to aspirate. + rows: Which row sections to aspirate. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = PeriWashAspirate(volume=volume, flow_rate=flow_rate, peri_pump=peri_pump) + if positioning is not None: + step.positioning = positioning + if columns is not None: + step.columns = columns + if rows is not None: + step.rows = rows + await run_steps(self._runtime, [step]) + + async def wash_dispense( + self, + volume: int = 100, + flow_rate: int = 2, + peri_pump: PeriPump | None = "Primary", + positioning: Positioning | None = None, + pre_dispense: PreDispense | None = None, + columns: WellMask | None = None, + rows: WellMask | None = None, + ) -> None: + """Add fresh medium gently through a peristaltic wash manifold. + + Paired with :meth:`wash_aspirate`. Pre-dispensing into the priming trough on every plate makes + up for what evaporates from the manifold tubing between plates. + + Args: + volume: Volume per tube in µL. + flow_rate: How fast to dispense, as a position on the dispense rate scale. + peri_pump: Which pump to drive. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, at what volume and how many times. + columns: Which columns to dispense into. + rows: Which row sections to dispense into. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = PeriWashDispense(volume=volume, flow_rate=flow_rate, peri_pump=peri_pump) + if positioning is not None: + step.positioning = positioning + if pre_dispense is not None: + step.pre_dispense = pre_dispense + if columns is not None: + step.columns = columns + if rows is not None: + step.rows = rows + await run_steps(self._runtime, [step]) diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py b/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py new file mode 100644 index 00000000000..170fb7bff07 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py @@ -0,0 +1,112 @@ +"""Dispensing from a syringe. + +The capability object an instrument with a syringe box exposes. Each method runs one step on its +own, checked and bracketed in a batch; several of them inside ``async with device.batch():`` share +one opening. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe import Syringe +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import SyringeBottle +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import PreDispense, Submerge +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_prime import SyringePrime + + +class SyringeDispenser: + """The syringes an instrument is fitted with. + + Args: + runtime: The state of the device this belongs to, which is where the link, the fitted options + and the plate on the carrier come from. + """ + + def __init__(self, runtime: Runtime) -> None: + self._runtime = runtime + + async def dispense( + self, + volume: int, + syringe: Syringe = "A", + flow_rate: int = 2, + syringe_bottle: SyringeBottle = "A1", + pump_delay: int = 0, + positioning: Positioning | None = None, + pre_dispense: PreDispense | None = None, + columns: WellMask | None = None, + rows: WellMask | None = None, + ) -> None: + """Dispense into every selected well from a syringe. + + Args: + volume: Volume per well in µL. + syringe: Which syringe to dispense from. Dispensing from both at once needs a double box. + flow_rate: How fast to dispense. + syringe_bottle: Which bottle to draw from. + pump_delay: How long the pump waits between wells, in ms. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, at what volume and how many times. + columns: Which columns to dispense into. + rows: Which rows to dispense into. Only instruments that select rows use this. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = SyringeDispense( + volume=volume, + syringe=syringe, + flow_rate=flow_rate, + syringe_bottle=syringe_bottle, + pump_delay=pump_delay, + selects_rows=rows is not None, + ) + if positioning is not None: + step.positioning = positioning + if pre_dispense is not None: + step.pre_dispense = pre_dispense + if columns is not None: + step.columns = columns + if rows is not None: + step.rows = rows + await run_steps(self._runtime, [step]) + + async def prime( + self, + volume: int = 5_000, + syringe: Syringe = "A", + flow_rate: int = 5, + cycles: int = 2, + syringe_bottle: SyringeBottle = "A1", + pump_delay: int = 0, + submerge: Submerge | None = None, + ) -> None: + """Draw fluid through a syringe until its lines are full. + + Args: + volume: Volume to draw in µL. + syringe: Which syringe to prime. + flow_rate: How fast to draw. + cycles: How many prime cycles to run. + syringe_bottle: Which bottle to draw from. + pump_delay: How long the pump waits between cycles, in ms. + submerge: Whether to leave the tips in fluid afterwards, and for how long. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = SyringePrime( + volume=volume, + syringe=syringe, + flow_rate=flow_rate, + cycles=cycles, + syringe_bottle=syringe_bottle, + pump_delay=pump_delay, + ) + if submerge is not None: + step.submerge = submerge + await run_steps(self._runtime, [step]) diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/washer.py b/pylabrobot/agilent/biotek/lhc/devices/components/washer.py new file mode 100644 index 00000000000..936977d3662 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/components/washer.py @@ -0,0 +1,405 @@ +"""Washing a plate. + +The capability object an instrument with a wash manifold, a strip washer manifold, or both exposes. +Every method here is one step run on its own: it builds the step, runs the check that decides +whether the instrument can run it, brackets it in a batch if one is not open already, and waits for +it to finish. Doing several of them inside ``async with device.batch():`` pays the opening cost once. + +The arguments spelled out are the ones a caller usually sets. Everything a step groups -- where in +the well to work, whether to pre-dispense, what to do between cycles -- is passed as the group it +belongs to, and left out to keep the step's own default. A step needing more than that is built +outright and handed to ``device.run_step()``. +""" + +from __future__ import annotations + +from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TravelRate +from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WashFormat +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( + PreDispense, + SecondaryAspirate, + Sectors, + Submerge, + VacuumDelay, + WashStages, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_auto_clean import ( + ManifoldAutoClean, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_wash import ManifoldWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_aspirate import StripAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_dispense import StripDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_prime import StripPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_wash import StripWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.wash_1536 import Wash1536 + + +class PlateWasher: + """The wash manifolds an instrument is fitted with. + + Args: + runtime: The state of the device this belongs to, which is where the link, the fitted options + and the plate on the carrier come from. + """ + + def __init__(self, runtime: Runtime) -> None: + self._runtime = runtime + + async def wash( + self, + cycles: int = 3, + wash_format: WashFormat = "Plate", + dispense: ManifoldDispense | None = None, + aspirate: ManifoldAspirate | None = None, + stages: WashStages | None = None, + shake_soak: ShakeSoak | None = None, + bottom_wash: ManifoldDispense | None = None, + final_aspirate: ManifoldAspirate | None = None, + sectors: Sectors | None = None, + ) -> None: + """Wash the plate through the wash manifold. + + Args: + cycles: How many wash cycles to run. + wash_format: Whether to wash the whole plate, selected sectors or selected strips. + dispense: The dispense that refills the well each cycle. + aspirate: The aspirate that empties the well at the start of each cycle. + stages: Which optional stages run. + shake_soak: The pause after each dispense. + bottom_wash: The dispense that washes the bottom of the well, when that stage runs. + final_aspirate: The aspirate that empties the well after the last cycle. + sectors: Which sectors to wash, when the format selects sectors. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = ManifoldWash(cycles=cycles, wash_format=wash_format) + if dispense is not None: + step.dispense = dispense + if aspirate is not None: + step.aspirate = aspirate + if stages is not None: + step.stages = stages + if shake_soak is not None: + step.shake_soak = shake_soak + if bottom_wash is not None: + step.bottom_wash = bottom_wash + if final_aspirate is not None: + step.final_aspirate = final_aspirate + if sectors is not None: + step.sectors = sectors + await run_steps(self._runtime, [step]) + + async def aspirate( + self, + travel_rate: TravelRate = "3", + delay: int = 0, + vacuum_filtration: bool = False, + positioning: Positioning | None = None, + secondary: SecondaryAspirate | None = None, + columns: WellMask | None = None, + ) -> None: + """Draw the wells empty through the wash manifold. + + Args: + travel_rate: How fast the tips descend into the well. + delay: How long to keep aspirating once the tips are down, in ms. Under vacuum filtration + this is the filtration time in seconds instead. + vacuum_filtration: Whether to pull the wells through a filter plate instead of aspirating + from above. Not available on a 1536-well plate. + positioning: Where in the well to aspirate. + secondary: Whether to aspirate a second time, in what pattern and where. + columns: Which columns to aspirate. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = ManifoldAspirate( + travel_rate=travel_rate, delay=delay, vacuum_filtration=vacuum_filtration + ) + if positioning is not None: + step.positioning = positioning + if secondary is not None: + step.secondary = secondary + if columns is not None: + step.columns = columns + await run_steps(self._runtime, [step]) + + async def dispense( + self, + volume: int, + buffer: Buffer = "A", + flow_rate: int = 7, + positioning: Positioning | None = None, + pre_dispense: PreDispense | None = None, + vacuum: VacuumDelay | None = None, + ) -> None: + """Dispense into every selected well through the wash manifold. + + Args: + volume: Volume per well in µL. + buffer: Which buffer inlet to draw from. + flow_rate: How fast to dispense. The two slowest rates need the cell washing module and a + 96-tube dual-action manifold. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, and at what volume and rate. + vacuum: Whether to hold the vacuum off until a volume has been dispensed. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = ManifoldDispense(volume=volume, buffer=buffer, flow_rate=flow_rate) + if positioning is not None: + step.positioning = positioning + if pre_dispense is not None: + step.pre_dispense = pre_dispense + if vacuum is not None: + step.vacuum = vacuum + await run_steps(self._runtime, [step]) + + async def prime( + self, + volume: int = 40_000, + buffer: Buffer = "A", + flow_rate: int = 9, + prime_low_flow_path: bool = True, + low_flow_path_volume: int = 5_000, + submerge: Submerge | None = None, + ) -> None: + """Pump fluid through the wash manifold until the lines are full. + + Args: + volume: Volume to pump in µL. The instrument runs this at millilitre resolution. + buffer: Which buffer inlet to draw from. + flow_rate: How fast to pump. + prime_low_flow_path: Whether to prime the low flow path as well. + low_flow_path_volume: Volume to pump through the low flow path in µL, when it is primed. + submerge: Whether to leave the tips in fluid afterwards, and for how long. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = ManifoldPrime( + volume=volume, + buffer=buffer, + flow_rate=flow_rate, + prime_low_flow_path=prime_low_flow_path, + low_flow_path_volume=low_flow_path_volume, + ) + if submerge is not None: + step.submerge = submerge + await run_steps(self._runtime, [step]) + + async def auto_clean(self, duration: int = 3600, buffer: Buffer = "A") -> None: + """Soak the wash manifold in cleaning fluid. + + Args: + duration: How long to clean, in seconds. The instrument runs this at whole-minute resolution. + buffer: Which buffer inlet the cleaning fluid comes from. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + await run_steps(self._runtime, [ManifoldAutoClean(duration=duration, buffer=buffer)]) + + async def wash_1536( + self, + cycles: int = 3, + wash_format: WashFormat = "Plate", + pre_dispense_before_volume: int = 10, + pre_dispense_before_count: int = 2, + dispense: SyringeDispense | None = None, + aspirate: ManifoldAspirate | None = None, + stages: WashStages | None = None, + shake_soak: ShakeSoak | None = None, + final_aspirate: ManifoldAspirate | None = None, + ) -> None: + """Wash a 1536-well plate, refilling the wells from a syringe rather than from the manifold. + + A separate step rather than a plate-dependent :meth:`wash`: it is dispensed by different + hardware, carries no bottom wash, and selects wells on its dispense. + + Args: + cycles: How many wash cycles to run. + wash_format: Whether to wash the whole plate, selected sectors or selected strips. + pre_dispense_before_volume: Volume per well in µL to pre-dispense before washing starts. + pre_dispense_before_count: How many times to pre-dispense before washing starts. + dispense: The syringe dispense that refills the well each cycle. + aspirate: The aspirate that empties the well at the start of each cycle. + stages: Which optional stages run. + shake_soak: The pause after each dispense. + final_aspirate: The aspirate that empties the well after the last cycle. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = Wash1536( + cycles=cycles, + wash_format=wash_format, + pre_dispense_before_volume=pre_dispense_before_volume, + pre_dispense_before_count=pre_dispense_before_count, + ) + if dispense is not None: + step.dispense = dispense + if aspirate is not None: + step.aspirate = aspirate + if stages is not None: + step.stages = stages + if shake_soak is not None: + step.shake_soak = shake_soak + if final_aspirate is not None: + step.final_aspirate = final_aspirate + await run_steps(self._runtime, [step]) + + async def strip_wash( + self, + cycles: int = 3, + wash_format: WashFormat = "Plate", + dispense: StripDispense | None = None, + aspirate: StripAspirate | None = None, + stages: WashStages | None = None, + shake_soak: ShakeSoak | None = None, + bottom_wash: StripDispense | None = None, + final_aspirate: StripAspirate | None = None, + columns: WellMask | None = None, + rows: WellMask | None = None, + ) -> None: + """Wash the plate through the strip washer manifold. + + Args: + cycles: How many wash cycles to run. + wash_format: Whether to wash the whole plate, selected sectors or selected strips. + dispense: The dispense that refills the well each cycle. + aspirate: The aspirate that empties the well at the start of each cycle. + stages: Which optional stages run. + shake_soak: The pause after each dispense. + bottom_wash: The dispense that washes the bottom of the well, when that stage runs. + final_aspirate: The aspirate that empties the well after the last cycle. + columns: Which columns to wash. + rows: Which rows to wash. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = StripWash(cycles=cycles, wash_format=wash_format) + if dispense is not None: + step.dispense = dispense + if aspirate is not None: + step.aspirate = aspirate + if stages is not None: + step.stages = stages + if shake_soak is not None: + step.shake_soak = shake_soak + if bottom_wash is not None: + step.bottom_wash = bottom_wash + if final_aspirate is not None: + step.final_aspirate = final_aspirate + if columns is not None: + step.columns = columns + if rows is not None: + step.rows = rows + await run_steps(self._runtime, [step]) + + async def strip_aspirate( + self, + travel_rate: TravelRate = "3", + delay: int = 0, + positioning: Positioning | None = None, + secondary: SecondaryAspirate | None = None, + columns: WellMask | None = None, + rows: WellMask | None = None, + ) -> None: + """Draw the wells empty through the strip washer manifold. + + Args: + travel_rate: How fast the tips descend into the well. The strip washer offers two rates the + wash manifold does not. + delay: How long to keep aspirating once the tips are down, in ms. + positioning: Where in the well to aspirate. + secondary: Whether to aspirate a second time, in what pattern and where. + columns: Which columns to aspirate. + rows: Which rows to aspirate. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = StripAspirate(travel_rate=travel_rate, delay=delay) + if positioning is not None: + step.positioning = positioning + if secondary is not None: + step.secondary = secondary + if columns is not None: + step.columns = columns + if rows is not None: + step.rows = rows + await run_steps(self._runtime, [step]) + + async def strip_dispense( + self, + volume: int, + flow_rate: int = 5, + positioning: Positioning | None = None, + pre_dispense: PreDispense | None = None, + vacuum: VacuumDelay | None = None, + columns: WellMask | None = None, + rows: WellMask | None = None, + ) -> None: + """Dispense into every selected well through the strip washer manifold. + + Args: + volume: Volume per well in µL. + flow_rate: How fast to dispense. + positioning: Where in the well to dispense. + pre_dispense: Whether to pre-dispense first, at what volume, rate and how many times. + vacuum: Whether to hold the vacuum off until a volume has been dispensed. + columns: Which columns to dispense into. + rows: Which rows to dispense into. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = StripDispense(volume=volume, flow_rate=flow_rate) + if positioning is not None: + step.positioning = positioning + if pre_dispense is not None: + step.pre_dispense = pre_dispense + if vacuum is not None: + step.vacuum = vacuum + if columns is not None: + step.columns = columns + if rows is not None: + step.rows = rows + await run_steps(self._runtime, [step]) + + async def strip_prime( + self, + volume: int = 5_000, + flow_rate: int = 5, + cycles: int = 2, + submerge: Submerge | None = None, + ) -> None: + """Pump fluid through the strip washer manifold until its lines are full. + + Args: + volume: Volume to pump in µL. What the manifold accepts depends on which one is fitted. + flow_rate: How fast to pump. + cycles: How many prime cycles to run. + submerge: Whether to leave the tips in fluid afterwards, and for how long. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + step = StripPrime(volume=volume, flow_rate=flow_rate, cycles=cycles) + if submerge is not None: + step.submerge = submerge + await run_steps(self._runtime, [step]) diff --git a/pylabrobot/agilent/biotek/lhc/devices/el406.py b/pylabrobot/agilent/biotek/lhc/devices/el406.py new file mode 100644 index 00000000000..cf4b7ad9542 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/el406.py @@ -0,0 +1,364 @@ +"""The EL406, a washer-dispenser with a wash manifold, syringes and one peristaltic pump.""" + +from __future__ import annotations + +import logging +from contextlib import AbstractAsyncContextManager +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport +from pylabrobot.agilent.biotek.lhc.devices import batch as batching +from pylabrobot.agilent.biotek.lhc.devices import execution, settings_query +from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for +from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( + PeristalticDispenser, +) +from pylabrobot.agilent.biotek.lhc.devices.components.syringe_dispenser import SyringeDispenser +from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.plate_geometry.resolution import resolve +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Shake, Soak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.validation.configuration import available_step_types +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import RunSelfCheck +from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( + FirmwareVersion, + GetFirmwareVersion, + GetSerialNumber, + Ping, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import RunStatus +from pylabrobot.resources import Plate + +logger = logging.getLogger(__name__) + +PALETTE: tuple[StepType, ...] = ( + StepType.MANIFOLD_WASH, + StepType.MANIFOLD_ASPIRATE, + StepType.MANIFOLD_DISPENSE, + StepType.MANIFOLD_PRIME, + StepType.MANIFOLD_AUTO_CLEAN, + StepType.WASH_1536, + StepType.SYRINGE_DISPENSE, + StepType.SYRINGE_PRIME, + StepType.PERI_DISPENSE, + StepType.PERI_PRIME, + StepType.PERI_PURGE, + StepType.SHAKE_SOAK, +) +"""Every step type this model can be built to run. What it does run depends on what is fitted.""" + + +class EL406: + """An EL406 washer-dispenser. + + What the instrument can do is reached through the capability objects it exposes: + :attr:`washer` for the wash manifold, :attr:`syringe_dispenser` for the syringes and + :attr:`peristaltic_dispenser` for the pump. All three are always there; whether a particular + operation can run depends on what the instrument reports as fitted, which :meth:`setup` reads and + :meth:`get_available_steps` reports. + + A single operation and a whole protocol take the same path -- checked, bracketed in a batch, + polled to completion -- so: + + ```python + device = EL406(port="/dev/ttyUSB0") + await device.setup() + device.set_plate(plate) + await device.washer.wash(cycles=3) + async with device.batch(): + await device.washer.prime() + await device.syringe_dispenser.dispense(volume=50) + await device.stop() + ``` + + Args: + port: The port the instrument is on. A string carrying a device serial number names a USB + bridge; anything else is taken to be a serial port. + name: What to call this instrument in logs and error messages. + timeout: How long to wait for a reply, in seconds. Commands that answer only once the + instrument has stopped moving carry longer timeouts of their own. + io: An already open transport to use instead of opening ``port``, for a test that replays a + recorded exchange. + + Attributes: + washer: The wash manifold. + syringe_dispenser: The syringes. + peristaltic_dispenser: The peristaltic pump. + """ + + family: ClassVar[InstrumentFamily] = InstrumentFamily.EL406 + checked_on_hardware: ClassVar[bool] = False + + def __init__( + self, + port: str = "", + name: str = "EL406", + timeout: float = DEFAULT_READ_TIMEOUT, + io: Transport | None = None, + ) -> None: + self._runtime = Runtime( + link=Link(port=port, family=self.family, name=name, timeout=timeout, io=io), + family=self.family, + rules=rules_for(self.family), + ) + self.washer = PlateWasher(self._runtime) + self.syringe_dispenser = SyringeDispenser(self._runtime) + self.peristaltic_dispenser = PeristalticDispenser(self._runtime) + + @property + def name(self) -> str: + """What this instrument is called in logs and error messages.""" + return self._runtime.link.name + + @property + def settings(self) -> InstrumentSettings: + """What the instrument reported as fitted when :meth:`setup` last ran.""" + return self._runtime.settings + + @property + def plate(self) -> PlateRecord | None: + """The plate the instrument is set to work, or None while none has been set.""" + return self._runtime.plate + + async def setup(self) -> None: + """Open the link and read what the instrument has fitted. + + Reading the fitted options is not optional: every step is encoded against them, and checking a + protocol measures it against them. + + Raises: + BiotekError: If the port will not open, nothing answers on it, or the fitted options cannot + be read. + """ + if not self.checked_on_hardware: + logger.warning( + "the %s driver has not been checked against a real instrument; verify every protocol on " + "labware you can afford to lose before trusting it", + type(self).__name__, + ) + link = self._runtime.link + await link.setup() + await link.request(Ping(), operation="ping") + self._runtime.settings = await settings_query.read_settings(link, self.family) + self._runtime.forget_instrument_facts() + logger.info("%s is ready: %s", self.name, self._runtime.settings) + + async def stop(self) -> None: + """Close the link. Calling this on a closed instrument does nothing.""" + await self._runtime.link.stop() + + def set_plate(self, plate: Plate, plate_type: PlateType | None = None) -> None: + """Tell the instrument which plate is on its carrier. + + Args: + plate: The labware on the carrier. Its columns, rows and well depth decide which of the + formats the instrument works it as. + plate_type: The format to use, for labware that cannot be resolved on its own or that is to + be worked as something else. + + Raises: + ValueError: If the plate matches no format this model works, or more than one. The message + names the candidates, which are what ``plate_type`` may be set to. + """ + self._runtime.plate = resolve(plate, self.family, plate_type) + self._runtime.forget_instrument_facts() + logger.info("%s is set to a %s", self.name, self._runtime.plate.label) + + def clear_plate(self) -> None: + """Forget which plate is on the carrier. Nothing can run until another is set.""" + self._runtime.plate = None + self._runtime.forget_instrument_facts() + + def get_available_steps(self) -> list[StepType]: + """Which operations this instrument can carry out as it is fitted. + + Returns: + The step types, in the order they are numbered. A type this model is never built to run is + absent whatever is fitted, and so is one whose hardware is missing. + """ + fitted = set(available_step_types(self._runtime.settings)) + return [step_type for step_type in PALETTE if step_type in fitted] + + async def can_run(self, protocol: Protocol | list[Step]) -> ValidationReport: + """Check whether a protocol can run on the instrument as it is. + + The report is truthy when every step can run, and prints as the list of those that cannot, so + it reads as the answer to the question. Running a protocol does this first; call it beforehand + to see what is wrong without touching the plate. + + Args: + protocol: The protocol, or the steps on their own. + + Returns: + The report. + + Raises: + RejectedError: If no plate has been set. + ValueError: If the protocol carries a step this package cannot read. + """ + return await execution.can_run(self._runtime, execution.steps_of(protocol)) + + async def run_protocol( + self, + protocol: Protocol | list[Step], + check: bool = True, + home_on_close: bool = False, + ) -> None: + """Run every step of a protocol, in one batch. + + Args: + protocol: The protocol, or the steps on their own. + check: Whether to check the protocol first. Turning this off also gives up what the check + works out about the pumps, so the batch opens against whatever cassette is fitted rather + than the one the protocol asks for. + home_on_close: Whether to home the transport before closing the batch. + + Raises: + BiotekError: If the protocol cannot run, the hardware cannot be made to match it, or a step + fails. + RejectedError: If no plate has been set. + ValueError: If the protocol carries a step this package cannot read. + """ + await execution.run_steps( + self._runtime, execution.steps_of(protocol), check=check, home_on_close=home_on_close + ) + + async def run_step(self, step: Step) -> None: + """Run one step built outright, for an operation the capability objects do not spell out. + + Args: + step: The step to run. + + Raises: + BiotekError: If the step cannot run, or fails while running. + RejectedError: If no plate has been set. + """ + await execution.run_steps(self._runtime, [step]) + + def batch(self, home_on_close: bool = False) -> AbstractAsyncContextManager[None]: + """Hold one batch open across several operations. + + Opening a batch homes the motors and takes the instrument, which is worth doing once rather + than per operation. Nesting is allowed and does nothing: an operation inside an open batch + joins it. + + Args: + home_on_close: Whether to home the transport before closing the batch. The instrument does + not do this of its own accord; ask for it when the next thing to touch the plate is a + person. + + Returns: + A context manager holding the batch open for the body of the block. + """ + return batching.batch(self._runtime, home_on_close=home_on_close) + + async def shake( + self, + duration: int = 5, + intensity: ShakeIntensity = "Medium", + axis: ShakeAxis = "X", + soak_duration: int = 0, + move_carrier_home: bool = True, + ) -> None: + """Shake the plate, soak it, or both. + + Args: + duration: How long to shake, in seconds. Zero shakes not at all. + intensity: How hard to shake. + axis: Which axis to shake along. + soak_duration: How long to leave the plate still afterwards, in seconds. Zero soaks not at + all. + move_carrier_home: Whether the carrier returns home afterwards. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + shake = Shake(enabled=duration > 0, intensity=intensity, axis=axis) + if duration > 0: + shake.duration = duration + soak = Soak(enabled=soak_duration > 0) + if soak_duration > 0: + soak.duration = soak_duration + await execution.run_steps( + self._runtime, + [ShakeSoak(shake=shake, soak=soak, move_carrier_home=move_carrier_home)], + ) + + async def get_status(self) -> RunStatus: + """Ask what the instrument is doing. + + Returns: + The run state, and the phase and countdown of a running step. + + Raises: + BiotekError: If the instrument reports a fault. + """ + return await execution.status(self._runtime) + + async def get_serial_number(self) -> str: + """Read the instrument's serial number. + + Returns: + The serial number. + + Raises: + BiotekError: If it cannot be read. + """ + command = GetSerialNumber() + return command.parse(await self._runtime.link.request(command, operation="serial number")) + + async def get_firmware_version(self) -> FirmwareVersion: + """Read the instrument's firmware version. + + Returns: + The version record, whose halves are the instrument's two processors. + + Raises: + BiotekError: If it cannot be read. + """ + command = GetFirmwareVersion() + return command.parse(await self._runtime.link.request(command, operation="firmware version")) + + async def self_check(self) -> None: + """Run the instrument's own self-check and wait for it. + + Raises: + BiotekError: If the check does not pass, reporting what failed. + """ + await self._runtime.link.request(RunSelfCheck(), operation="self check") + + async def abort(self) -> None: + """Stop the running step. + + Raises: + BiotekError: If the instrument will not stop. + """ + await execution.abort(self._runtime) + + async def pause(self) -> None: + """Pause the running step. + + Raises: + BiotekError: If the instrument will not pause. + """ + await execution.pause(self._runtime) + + async def resume(self) -> None: + """Resume a paused step. + + Raises: + BiotekError: If the instrument will not resume. + """ + await execution.resume(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/devices/execution.py b/pylabrobot/agilent/biotek/lhc/devices/execution.py new file mode 100644 index 00000000000..b4c7f66b9a9 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/execution.py @@ -0,0 +1,350 @@ +"""Running steps, and checking a protocol before running it. + +A step command is answered as soon as the instrument has accepted the step, so the only way to +learn that it has finished is to ask: send it, then poll the run state until it stops being busy. +That, and the check that has to happen before any of it, is the same on every model, so it lives +here as functions over a :class:`~.runtime.Runtime` rather than in a class the models inherit. + +Checking is not only a check. The pass accumulates what the protocol requires of the peristaltic +pumps, and opening a batch is what makes the hardware match it, so a run that skips the check opens +against whatever happens to be fitted. That is why running a protocol checks it first, and why the +opt-out is a named argument rather than the default. +""" + +from __future__ import annotations + +import asyncio +import logging + +from pylabrobot.agilent.biotek.lhc.devices.batch import batch +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_restriction import PlateRestriction +from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState +from pylabrobot.agilent.biotek.lhc.error_handling import BiotekError, ErrorKind, fail +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.validation.protocol_pass import validate +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport +from pylabrobot.agilent.biotek.lhc.protocols.validation.reservations import Reservations +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import ( + CommandNumber, + command_for_step, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.configuration import ByteQuery +from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import ( + AbortStep, + GetProtocolStatus, + PauseStep, + ResumeStep, + RunStatus, + RunStep, +) + +logger = logging.getLogger(__name__) + +POLL_INTERVAL = 0.1 +"""How long to wait between two status polls, in seconds.""" + +READY_TIMEOUT = 15.0 +"""How long to wait for the instrument to go idle before sending a step, in seconds.""" + +STEP_TIMEOUT = 3600.0 +"""How long to wait for a step to finish, in seconds. A wash with a long soak is minutes of it.""" + +_SETTLE = 0.5 +"""How long to wait after a step is accepted before polling, in seconds.""" + +_RUNNING = (RunState.BUSY, RunState.PAUSED) + + +def steps_of(protocol: Protocol | list[Step]) -> list[Step]: + """The steps to run, from either a protocol or a list of them. + + A protocol read from a file carries its entries rather than its steps, so the steps are built + from them the first time they are asked for. + + Args: + protocol: The protocol, or the steps on their own. + + Returns: + The steps, in the order they will run. + + Raises: + ValueError: If the protocol carries a step this package cannot read. + """ + if isinstance(protocol, list): + return protocol + return protocol.steps if protocol.steps else protocol.build_steps() + + +async def status(runtime: Runtime) -> RunStatus: + """Ask the instrument what it is doing. + + Args: + runtime: The device's state. + + Returns: + The run state, and the phase and countdown of a running step. + + Raises: + BiotekError: If the instrument reports a fault, which is how a step that failed is learned of. + """ + command = GetProtocolStatus() + return command.parse(await runtime.link.request(command, operation="status")) + + +async def wait_until_idle( + runtime: Runtime, timeout: float = READY_TIMEOUT, interval: float = POLL_INTERVAL +) -> None: + """Wait for the instrument to stop being busy. + + Args: + runtime: The device's state. + timeout: How long to wait, in seconds. + interval: How long to wait between polls, in seconds. + + Raises: + BiotekError: If the instrument is still busy when the time is up, or reports a fault. + """ + deadline = asyncio.get_running_loop().time() + timeout + while True: + if (await status(runtime)).state not in _RUNNING: + return + if asyncio.get_running_loop().time() >= deadline: + raise fail( + ErrorKind.LINK, + f"{runtime.link.name} is still busy after {timeout:g}s", + operation="wait until idle", + ) + await asyncio.sleep(interval) + + +async def run_step( + runtime: Runtime, + step: Step, + timeout: float = STEP_TIMEOUT, + interval: float = POLL_INTERVAL, +) -> None: + """Run one step inside the batch that is already open, and wait for it to finish. + + Args: + runtime: The device's state. + step: The step to run, encoded against what the instrument has fitted. + timeout: How long to wait for it to finish, in seconds. + interval: How long to wait between polls, in seconds. + + Raises: + BiotekError: If the instrument refuses the step, reports a fault while running it, or is still + running it when the time is up. + RejectedError: If no plate has been set. + KeyError: If no command runs that kind of step. + """ + plate_type = runtime.plate_type + await wait_until_idle(runtime) + command = RunStep(command_for_step(step), plate_type, step.to_bytes(runtime.settings)) + await runtime.link.request(command, operation=step.step_type.name) + logger.info("running %s on %s", step.step_type.name, runtime.link.name) + await asyncio.sleep(_SETTLE) + await _wait_for_step(runtime, step, timeout, interval) + + +async def _wait_for_step(runtime: Runtime, step: Step, timeout: float, interval: float) -> None: + """Poll until a running step finishes. + + Args: + runtime: The device's state. + step: The step being waited for, named in any exception raised. + timeout: How long to wait, in seconds. + interval: How long to wait between polls, in seconds. + + Raises: + BiotekError: If the instrument reports a fault, or the step is still running when the time is + up. + """ + deadline = asyncio.get_running_loop().time() + timeout + paused = False + while True: + reported = await status(runtime) + if reported.state not in _RUNNING: + logger.info("%s finished on %s", step.step_type.name, runtime.link.name) + return + if reported.state is RunState.PAUSED and not paused: + paused = True + logger.warning("%s is paused on %s", step.step_type.name, runtime.link.name) + if asyncio.get_running_loop().time() >= deadline: + raise fail( + ErrorKind.LINK, + f"{step.step_type.name} has not finished on {runtime.link.name} after {timeout:g}s", + operation=step.step_type.name, + ) + await asyncio.sleep(interval) + + +async def run_steps( + runtime: Runtime, + steps: list[Step], + check: bool = True, + home_on_close: bool = False, + timeout: float = STEP_TIMEOUT, +) -> None: + """Check a protocol, then run every step of it in one batch. + + Args: + runtime: The device's state. + steps: The steps to run, in order. + check: Whether to check the protocol first. Leaving this out is what makes the batch open + against whatever cassette happens to be fitted, since it is the check that works out what the + protocol requires. + home_on_close: Whether to home the transport before closing the batch. + timeout: How long to wait for each step to finish, in seconds. + + Raises: + BiotekError: If the protocol cannot run, if the hardware cannot be made to match it, or if a + step fails. + RejectedError: If no plate has been set. + """ + if check: + report = await can_run(runtime, steps) + if not report: + raise fail( + ErrorKind.REJECTED, + str(report), + operation="run protocol", + code=_first_code(report), + ) + else: + runtime.reservations = Reservations() + logger.warning( + "running %d steps on %s unchecked: the batch will open against the fitted cassettes rather " + "than the ones the protocol asks for", + len(steps), + runtime.link.name, + ) + async with batch(runtime, home_on_close=home_on_close): + for step in steps: + await run_step(runtime, step, timeout=timeout) + + +async def can_run(runtime: Runtime, steps: list[Step]) -> ValidationReport: + """Check whether a protocol can run on the instrument as it is. + + The facts the rules are measured against are gathered here -- what is fitted, which plate is on + the carrier, which rules the firmware runs, and, where the instrument answers for them, which + plates it accepts and which carrier is on it. What the protocol requires of the pumps is kept for + the next batch to open against. + + Args: + runtime: The device's state. + steps: The steps to check, in the order they would run. + + Returns: + The report: truthy when every step can run, and printing as the list of those that cannot. + + Raises: + RejectedError: If no plate has been set. + """ + plate = runtime.plate_record + await _read_instrument_facts(runtime) + report, reservations = validate( + steps=steps, + settings=runtime.settings, + plate=plate, + rules=runtime.rules, + plate_restriction=runtime.plate_restriction, + carrier_type=runtime.carrier_type, + ) + runtime.reservations = reservations + logger.debug("%s: %s", runtime.link.name, report) + return report + + +async def abort(runtime: Runtime) -> None: + """Stop the running step. + + Args: + runtime: The device's state. + + Raises: + BiotekError: If the instrument will not stop. + """ + await runtime.link.request(AbortStep(), operation="abort") + + +async def pause(runtime: Runtime) -> None: + """Pause the running step. + + Args: + runtime: The device's state. + + Raises: + BiotekError: If the instrument will not pause. + """ + await runtime.link.request(PauseStep(), operation="pause") + + +async def resume(runtime: Runtime) -> None: + """Resume a paused step. + + Args: + runtime: The device's state. + + Raises: + BiotekError: If the instrument will not resume. + """ + await runtime.link.request(ResumeStep(), operation="resume") + + +async def _read_instrument_facts(runtime: Runtime) -> None: + """Ask the instrument which plates it accepts and which carrier is fitted, once. + + Both are set on the instrument rather than by a protocol, and both are rules of their own. A + model whose firmware does not answer for one leaves it unknown, and the rule that reads it is + skipped rather than guessed at. + + Args: + runtime: The device's state, updated in place. + """ + if runtime.plate_restriction is None: + answer = await _optional_byte(runtime, CommandNumber.GET_PLATE_RESTRICTION) + if answer is not None: + runtime.plate_restriction = PlateRestriction(answer) + if runtime.carrier_type is None: + answer = await _optional_byte(runtime, CommandNumber.GET_CARRIER_TYPE) + if answer is not None: + runtime.carrier_type = CarrierType(answer) + + +async def _optional_byte(runtime: Runtime, number: CommandNumber) -> int | None: + """Read a one-byte answer that not every firmware gives. + + Args: + runtime: The device's state. + number: Which query to send. + + Returns: + The byte, or None when the instrument would not answer. + """ + command = ByteQuery(number) + try: + return command.parse(await runtime.link.request(command, operation=number.name)) + except BiotekError as error: + logger.debug("%s does not answer %s: %s", runtime.link.name, number.name, error) + return None + + +def _first_code(report: ValidationReport) -> int: + """The code of the first thing that stops a protocol running. + + Args: + report: The report to read. + + Returns: + The code, or zero when the report says nothing is wrong. + """ + if report.rejection is not None: + return report.rejection.code + for step in report.steps: + if step.rejection is not None: + return step.rejection.code + return 0 diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py new file mode 100644 index 00000000000..fbab5a3633a --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py @@ -0,0 +1,352 @@ +"""The MultiFlo, a dispenser with syringes and two peristaltic pumps.""" + +from __future__ import annotations + +import logging +from contextlib import AbstractAsyncContextManager +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport +from pylabrobot.agilent.biotek.lhc.devices import batch as batching +from pylabrobot.agilent.biotek.lhc.devices import execution, settings_query +from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for +from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( + PeristalticDispenser, +) +from pylabrobot.agilent.biotek.lhc.devices.components.syringe_dispenser import SyringeDispenser +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.plate_geometry.resolution import resolve +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Shake, Soak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.validation.configuration import available_step_types +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import RunSelfCheck +from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( + FirmwareVersion, + GetFirmwareVersion, + GetSerialNumber, + Ping, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import RunStatus +from pylabrobot.resources import Plate + +logger = logging.getLogger(__name__) + +PALETTE: tuple[StepType, ...] = ( + StepType.SYRINGE_DISPENSE, + StepType.SYRINGE_PRIME, + StepType.PERI_DISPENSE, + StepType.PERI_PRIME, + StepType.PERI_PURGE, + StepType.SHAKE_SOAK, +) +"""Every step type this model can be built to run. + +This is the oldest firmware in the family. It predates dispensing into individually chosen wells, +and cannot store the fields for it, so such a step is refused rather than run across the whole +plate.""" + + +class MultiFlo: + """A MultiFlo dispenser. + + Two ways of dispensing, reached as :attr:`syringe_dispenser` and :attr:`peristaltic_dispenser`. + There is no wash manifold, so washing a plate is not something this model does. + + ```python + device = MultiFlo(port="/dev/ttyUSB0") + await device.setup() + device.set_plate(plate) + async with device.batch(): + await device.peristaltic_dispenser.prime(volume=300) + await device.peristaltic_dispenser.dispense(volume=50) + await device.stop() + ``` + + Args: + port: The port the instrument is on. A string carrying a device serial number names a USB + bridge; anything else is taken to be a serial port. + name: What to call this instrument in logs and error messages. + timeout: How long to wait for a reply, in seconds. Commands that answer only once the + instrument has stopped moving carry longer timeouts of their own. + io: An already open transport to use instead of opening ``port``, for a test that replays a + recorded exchange. + + Attributes: + syringe_dispenser: The syringes. + peristaltic_dispenser: The peristaltic pumps. + """ + + family: ClassVar[InstrumentFamily] = InstrumentFamily.MULTIFLO + checked_on_hardware: ClassVar[bool] = False + + def __init__( + self, + port: str = "", + name: str = "MultiFlo", + timeout: float = DEFAULT_READ_TIMEOUT, + io: Transport | None = None, + ) -> None: + self._runtime = Runtime( + link=Link(port=port, family=self.family, name=name, timeout=timeout, io=io), + family=self.family, + rules=rules_for(self.family), + ) + self.syringe_dispenser = SyringeDispenser(self._runtime) + self.peristaltic_dispenser = PeristalticDispenser(self._runtime) + + @property + def name(self) -> str: + """What this instrument is called in logs and error messages.""" + return self._runtime.link.name + + @property + def settings(self) -> InstrumentSettings: + """What the instrument reported as fitted when :meth:`setup` last ran.""" + return self._runtime.settings + + @property + def plate(self) -> PlateRecord | None: + """The plate the instrument is set to work, or None while none has been set.""" + return self._runtime.plate + + async def setup(self) -> None: + """Open the link and read what the instrument has fitted. + + Reading the fitted options is not optional: every step is encoded against them, and checking a + protocol measures it against them. + + Raises: + BiotekError: If the port will not open, nothing answers on it, or the fitted options cannot + be read. + """ + if not self.checked_on_hardware: + logger.warning( + "the %s driver has not been checked against a real instrument; verify every protocol on " + "labware you can afford to lose before trusting it", + type(self).__name__, + ) + link = self._runtime.link + await link.setup() + await link.request(Ping(), operation="ping") + self._runtime.settings = await settings_query.read_settings(link, self.family) + self._runtime.forget_instrument_facts() + logger.info("%s is ready: %s", self.name, self._runtime.settings) + + async def stop(self) -> None: + """Close the link. Calling this on a closed instrument does nothing.""" + await self._runtime.link.stop() + + def set_plate(self, plate: Plate, plate_type: PlateType | None = None) -> None: + """Tell the instrument which plate is on its carrier. + + Args: + plate: The labware on the carrier. Its columns, rows and well depth decide which of the + formats the instrument works it as. + plate_type: The format to use, for labware that cannot be resolved on its own or that is to + be worked as something else. + + Raises: + ValueError: If the plate matches no format this model works, or more than one. The message + names the candidates, which are what ``plate_type`` may be set to. + """ + self._runtime.plate = resolve(plate, self.family, plate_type) + self._runtime.forget_instrument_facts() + logger.info("%s is set to a %s", self.name, self._runtime.plate.label) + + def clear_plate(self) -> None: + """Forget which plate is on the carrier. Nothing can run until another is set.""" + self._runtime.plate = None + self._runtime.forget_instrument_facts() + + def get_available_steps(self) -> list[StepType]: + """Which operations this instrument can carry out as it is fitted. + + Returns: + The step types, in the order they are numbered. A type this model is never built to run is + absent whatever is fitted, and so is one whose hardware is missing. + """ + fitted = set(available_step_types(self._runtime.settings)) + return [step_type for step_type in PALETTE if step_type in fitted] + + async def can_run(self, protocol: Protocol | list[Step]) -> ValidationReport: + """Check whether a protocol can run on the instrument as it is. + + The report is truthy when every step can run, and prints as the list of those that cannot, so + it reads as the answer to the question. Running a protocol does this first; call it beforehand + to see what is wrong without touching the plate. + + Args: + protocol: The protocol, or the steps on their own. + + Returns: + The report. + + Raises: + RejectedError: If no plate has been set. + ValueError: If the protocol carries a step this package cannot read. + """ + return await execution.can_run(self._runtime, execution.steps_of(protocol)) + + async def run_protocol( + self, + protocol: Protocol | list[Step], + check: bool = True, + home_on_close: bool = False, + ) -> None: + """Run every step of a protocol, in one batch. + + Args: + protocol: The protocol, or the steps on their own. + check: Whether to check the protocol first. Turning this off also gives up what the check + works out about the pumps, so the batch opens against whatever cassette is fitted rather + than the one the protocol asks for. + home_on_close: Whether to home the transport before closing the batch. + + Raises: + BiotekError: If the protocol cannot run, the hardware cannot be made to match it, or a step + fails. + RejectedError: If no plate has been set. + ValueError: If the protocol carries a step this package cannot read. + """ + await execution.run_steps( + self._runtime, execution.steps_of(protocol), check=check, home_on_close=home_on_close + ) + + async def run_step(self, step: Step) -> None: + """Run one step built outright, for an operation the capability objects do not spell out. + + Args: + step: The step to run. + + Raises: + BiotekError: If the step cannot run, or fails while running. + RejectedError: If no plate has been set. + """ + await execution.run_steps(self._runtime, [step]) + + def batch(self, home_on_close: bool = False) -> AbstractAsyncContextManager[None]: + """Hold one batch open across several operations. + + Opening a batch homes the motors and takes the instrument, which is worth doing once rather + than per operation. Nesting is allowed and does nothing: an operation inside an open batch + joins it. + + Args: + home_on_close: Whether to home the transport before closing the batch. The instrument does + not do this of its own accord; ask for it when the next thing to touch the plate is a + person. + + Returns: + A context manager holding the batch open for the body of the block. + """ + return batching.batch(self._runtime, home_on_close=home_on_close) + + async def shake( + self, + duration: int = 5, + intensity: ShakeIntensity = "Medium", + axis: ShakeAxis = "X", + soak_duration: int = 0, + move_carrier_home: bool = True, + ) -> None: + """Shake the plate, soak it, or both. + + Args: + duration: How long to shake, in seconds. Zero shakes not at all. + intensity: How hard to shake. + axis: Which axis to shake along. + soak_duration: How long to leave the plate still afterwards, in seconds. Zero soaks not at + all. + move_carrier_home: Whether the carrier returns home afterwards. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + shake = Shake(enabled=duration > 0, intensity=intensity, axis=axis) + if duration > 0: + shake.duration = duration + soak = Soak(enabled=soak_duration > 0) + if soak_duration > 0: + soak.duration = soak_duration + await execution.run_steps( + self._runtime, + [ShakeSoak(shake=shake, soak=soak, move_carrier_home=move_carrier_home)], + ) + + async def get_status(self) -> RunStatus: + """Ask what the instrument is doing. + + Returns: + The run state, and the phase and countdown of a running step. + + Raises: + BiotekError: If the instrument reports a fault. + """ + return await execution.status(self._runtime) + + async def get_serial_number(self) -> str: + """Read the instrument's serial number. + + Returns: + The serial number. + + Raises: + BiotekError: If it cannot be read. + """ + command = GetSerialNumber() + return command.parse(await self._runtime.link.request(command, operation="serial number")) + + async def get_firmware_version(self) -> FirmwareVersion: + """Read the instrument's firmware version. + + Returns: + The version record, whose halves are the instrument's two processors. + + Raises: + BiotekError: If it cannot be read. + """ + command = GetFirmwareVersion() + return command.parse(await self._runtime.link.request(command, operation="firmware version")) + + async def self_check(self) -> None: + """Run the instrument's own self-check and wait for it. + + Raises: + BiotekError: If the check does not pass, reporting what failed. + """ + await self._runtime.link.request(RunSelfCheck(), operation="self check") + + async def abort(self) -> None: + """Stop the running step. + + Raises: + BiotekError: If the instrument will not stop. + """ + await execution.abort(self._runtime) + + async def pause(self) -> None: + """Pause the running step. + + Raises: + BiotekError: If the instrument will not pause. + """ + await execution.pause(self._runtime) + + async def resume(self) -> None: + """Resume a paused step. + + Raises: + BiotekError: If the instrument will not resume. + """ + await execution.resume(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py new file mode 100644 index 00000000000..e41f8fa22ee --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py @@ -0,0 +1,376 @@ +"""The MultiFlo FX, a dispenser with syringes, two peristaltic pumps and a strip washer.""" + +from __future__ import annotations + +import logging +from contextlib import AbstractAsyncContextManager +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport +from pylabrobot.agilent.biotek.lhc.devices import batch as batching +from pylabrobot.agilent.biotek.lhc.devices import execution, settings_query +from pylabrobot.agilent.biotek.lhc.devices.build_rules import ( + BASECODE_STEP_TYPES, + basecode_for, + rules_for, +) +from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( + PeristalticDispenser, +) +from pylabrobot.agilent.biotek.lhc.devices.components.syringe_dispenser import SyringeDispenser +from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.plate_geometry.resolution import resolve +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Shake, Soak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.validation.configuration import available_step_types +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import RunSelfCheck +from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( + FirmwareVersion, + GetFirmwareVersion, + GetSerialNumber, + Ping, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import RunStatus +from pylabrobot.resources import Plate + +logger = logging.getLogger(__name__) + +PALETTE: tuple[StepType, ...] = ( + StepType.SYRINGE_DISPENSE, + StepType.SYRINGE_PRIME, + StepType.PERI_DISPENSE, + StepType.PERI_PRIME, + StepType.PERI_PURGE, + StepType.PERI_WASH_ASPIRATE, + StepType.PERI_WASH_DISPENSE, + StepType.STRIP_WASH, + StepType.STRIP_ASPIRATE, + StepType.STRIP_DISPENSE, + StepType.STRIP_PRIME, + StepType.SHAKE_SOAK, +) +"""Every step type this model can be built to run. + +Which of them a particular instrument offers depends on both what is fitted and which firmware +variant is installed, and no instrument offers all of them at once: the peristaltic wash steps and +dispensing into individually chosen wells come from different firmware builds.""" + + +class MultiFloFX: + """A MultiFlo FX dispenser. + + The newest model in the family, and the only one whose peristaltic dispense head can be set from + the host: opening a batch fits the head and the cassettes the checked protocol asked for. + + Its washing is done by a strip washer manifold rather than a plate wash manifold, so of the + methods on :attr:`washer` only the strip ones run here; :meth:`get_available_steps` says which. + Dispensing is reached as :attr:`syringe_dispenser` and :attr:`peristaltic_dispenser`, and gentle + medium exchange through the peristaltic wash manifolds is on the latter. + + ```python + device = MultiFloFX(port="ftdi:FT1ABCDE") + await device.setup() + device.set_plate(plate) + async with device.batch(): + await device.peristaltic_dispenser.wash_aspirate(volume=100) + await device.peristaltic_dispenser.wash_dispense(volume=100) + await device.stop() + ``` + + Args: + port: The port the instrument is on. A string carrying a device serial number names a USB + bridge; anything else is taken to be a serial port. + name: What to call this instrument in logs and error messages. + timeout: How long to wait for a reply, in seconds. Commands that answer only once the + instrument has stopped moving carry longer timeouts of their own. + io: An already open transport to use instead of opening ``port``, for a test that replays a + recorded exchange. + + Attributes: + washer: The strip washer manifold. + syringe_dispenser: The syringes. + peristaltic_dispenser: The peristaltic pumps, including the wash manifolds. + """ + + family: ClassVar[InstrumentFamily] = InstrumentFamily.MULTIFLO_FX + checked_on_hardware: ClassVar[bool] = False + + def __init__( + self, + port: str = "", + name: str = "MultiFlo FX", + timeout: float = DEFAULT_READ_TIMEOUT, + io: Transport | None = None, + ) -> None: + self._runtime = Runtime( + link=Link(port=port, family=self.family, name=name, timeout=timeout, io=io), + family=self.family, + rules=rules_for(self.family), + reconciles_cassette_head=True, + ) + self.washer = PlateWasher(self._runtime) + self.syringe_dispenser = SyringeDispenser(self._runtime) + self.peristaltic_dispenser = PeristalticDispenser(self._runtime) + + @property + def name(self) -> str: + """What this instrument is called in logs and error messages.""" + return self._runtime.link.name + + @property + def settings(self) -> InstrumentSettings: + """What the instrument reported as fitted when :meth:`setup` last ran.""" + return self._runtime.settings + + @property + def plate(self) -> PlateRecord | None: + """The plate the instrument is set to work, or None while none has been set.""" + return self._runtime.plate + + async def setup(self) -> None: + """Open the link and read what the instrument has fitted. + + Reading the fitted options is not optional: every step is encoded against them, and checking a + protocol measures it against them. + + Raises: + BiotekError: If the port will not open, nothing answers on it, or the fitted options cannot + be read. + """ + if not self.checked_on_hardware: + logger.warning( + "the %s driver has not been checked against a real instrument; verify every protocol on " + "labware you can afford to lose before trusting it", + type(self).__name__, + ) + link = self._runtime.link + await link.setup() + await link.request(Ping(), operation="ping") + self._runtime.settings = await settings_query.read_settings(link, self.family) + self._runtime.forget_instrument_facts() + logger.info("%s is ready: %s", self.name, self._runtime.settings) + + async def stop(self) -> None: + """Close the link. Calling this on a closed instrument does nothing.""" + await self._runtime.link.stop() + + def set_plate(self, plate: Plate, plate_type: PlateType | None = None) -> None: + """Tell the instrument which plate is on its carrier. + + Args: + plate: The labware on the carrier. Its columns, rows and well depth decide which of the + formats the instrument works it as. + plate_type: The format to use, for labware that cannot be resolved on its own or that is to + be worked as something else. + + Raises: + ValueError: If the plate matches no format this model works, or more than one. The message + names the candidates, which are what ``plate_type`` may be set to. + """ + self._runtime.plate = resolve(plate, self.family, plate_type) + self._runtime.forget_instrument_facts() + logger.info("%s is set to a %s", self.name, self._runtime.plate.label) + + def clear_plate(self) -> None: + """Forget which plate is on the carrier. Nothing can run until another is set.""" + self._runtime.plate = None + self._runtime.forget_instrument_facts() + + def get_available_steps(self) -> list[StepType]: + """Which operations this instrument can carry out as it is fitted. + + This model's firmware comes in variants offering different step types, and which variant is + installed follows from what the instrument reports, so the palette is narrowed twice: by what + is fitted, and by what the firmware knows. + + Returns: + The step types, in the order they are numbered. + """ + settings = self._runtime.settings + fitted = set(available_step_types(settings)) + in_firmware = BASECODE_STEP_TYPES[basecode_for(settings)] + return [step_type for step_type in PALETTE if step_type in fitted and step_type in in_firmware] + + async def can_run(self, protocol: Protocol | list[Step]) -> ValidationReport: + """Check whether a protocol can run on the instrument as it is. + + The report is truthy when every step can run, and prints as the list of those that cannot, so + it reads as the answer to the question. Running a protocol does this first; call it beforehand + to see what is wrong without touching the plate. + + Args: + protocol: The protocol, or the steps on their own. + + Returns: + The report. + + Raises: + RejectedError: If no plate has been set. + ValueError: If the protocol carries a step this package cannot read. + """ + return await execution.can_run(self._runtime, execution.steps_of(protocol)) + + async def run_protocol( + self, + protocol: Protocol | list[Step], + check: bool = True, + home_on_close: bool = False, + ) -> None: + """Run every step of a protocol, in one batch. + + Args: + protocol: The protocol, or the steps on their own. + check: Whether to check the protocol first. Turning this off also gives up what the check + works out about the pumps, so the batch opens against whatever cassette is fitted rather + than the one the protocol asks for. + home_on_close: Whether to home the transport before closing the batch. + + Raises: + BiotekError: If the protocol cannot run, the hardware cannot be made to match it, or a step + fails. + RejectedError: If no plate has been set. + ValueError: If the protocol carries a step this package cannot read. + """ + await execution.run_steps( + self._runtime, execution.steps_of(protocol), check=check, home_on_close=home_on_close + ) + + async def run_step(self, step: Step) -> None: + """Run one step built outright, for an operation the capability objects do not spell out. + + Args: + step: The step to run. + + Raises: + BiotekError: If the step cannot run, or fails while running. + RejectedError: If no plate has been set. + """ + await execution.run_steps(self._runtime, [step]) + + def batch(self, home_on_close: bool = False) -> AbstractAsyncContextManager[None]: + """Hold one batch open across several operations. + + Opening a batch homes the motors and takes the instrument, which is worth doing once rather + than per operation. Nesting is allowed and does nothing: an operation inside an open batch + joins it. + + Args: + home_on_close: Whether to home the transport before closing the batch. The instrument does + not do this of its own accord; ask for it when the next thing to touch the plate is a + person. + + Returns: + A context manager holding the batch open for the body of the block. + """ + return batching.batch(self._runtime, home_on_close=home_on_close) + + async def shake( + self, + duration: int = 5, + intensity: ShakeIntensity = "Medium", + axis: ShakeAxis = "X", + soak_duration: int = 0, + move_carrier_home: bool = True, + ) -> None: + """Shake the plate, soak it, or both. + + Args: + duration: How long to shake, in seconds. Zero shakes not at all. + intensity: How hard to shake. + axis: Which axis to shake along. + soak_duration: How long to leave the plate still afterwards, in seconds. Zero soaks not at + all. + move_carrier_home: Whether the carrier returns home afterwards. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + shake = Shake(enabled=duration > 0, intensity=intensity, axis=axis) + if duration > 0: + shake.duration = duration + soak = Soak(enabled=soak_duration > 0) + if soak_duration > 0: + soak.duration = soak_duration + await execution.run_steps( + self._runtime, + [ShakeSoak(shake=shake, soak=soak, move_carrier_home=move_carrier_home)], + ) + + async def get_status(self) -> RunStatus: + """Ask what the instrument is doing. + + Returns: + The run state, and the phase and countdown of a running step. + + Raises: + BiotekError: If the instrument reports a fault. + """ + return await execution.status(self._runtime) + + async def get_serial_number(self) -> str: + """Read the instrument's serial number. + + Returns: + The serial number. + + Raises: + BiotekError: If it cannot be read. + """ + command = GetSerialNumber() + return command.parse(await self._runtime.link.request(command, operation="serial number")) + + async def get_firmware_version(self) -> FirmwareVersion: + """Read the instrument's firmware version. + + Returns: + The version record, whose halves are the instrument's two processors. + + Raises: + BiotekError: If it cannot be read. + """ + command = GetFirmwareVersion() + return command.parse(await self._runtime.link.request(command, operation="firmware version")) + + async def self_check(self) -> None: + """Run the instrument's own self-check and wait for it. + + Raises: + BiotekError: If the check does not pass, reporting what failed. + """ + await self._runtime.link.request(RunSelfCheck(), operation="self check") + + async def abort(self) -> None: + """Stop the running step. + + Raises: + BiotekError: If the instrument will not stop. + """ + await execution.abort(self._runtime) + + async def pause(self) -> None: + """Pause the running step. + + Raises: + BiotekError: If the instrument will not pause. + """ + await execution.pause(self._runtime) + + async def resume(self) -> None: + """Resume a paused step. + + Raises: + BiotekError: If the instrument will not resume. + """ + await execution.resume(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/devices/runtime.py b/pylabrobot/agilent/biotek/lhc/devices/runtime.py new file mode 100644 index 00000000000..2f783356d61 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/runtime.py @@ -0,0 +1,96 @@ +"""What a device holds while it is running. + +Every model needs the same things to hand: the link to the instrument, what the instrument has +fitted, which rules its firmware runs, which plate is on the carrier, and what the last validation +pass reserved. They are gathered here so the shared functions in :mod:`.execution` and :mod:`.batch` +can take one argument instead of eight, and so a device file stays a list of its own public methods. + +This is state, not behaviour: a device owns one of these and passes it to those functions. It is +deliberately not a base class -- nothing inherits from it, and a model that needs a fact the others +do not simply keeps that fact itself. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.devices.build_rules import COMMON, BuildRules +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_restriction import PlateRestriction +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.error_handling import ErrorKind, fail +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.protocols.validation.reservations import Reservations + + +@dataclass +class Runtime: + """Everything shared behaviour needs from the device that owns it. + + Attributes: + link: The connection to the instrument. + family: Which model this is, which the instrument does not report and so is declared. + rules: Which validation rules this model's firmware runs. + settings: What the instrument has fitted. Read from the instrument by ``setup()``, and the + record every step is encoded against. + reconciles_cassette_head: Whether opening a batch reconciles the peristaltic dispense head as + well as the cassettes. Only one model carries a head that can be set. + plate: The plate on the carrier, or None while none has been set. + reservations: What the last validation pass claimed of the pumps, which is what opening a batch + makes the hardware match. Empty until a pass has run. + plate_restriction: Which plates the instrument accepts, once it has been asked. + carrier_type: Which carrier is fitted, once the instrument has been asked. + in_batch: Whether a batch is open, which is what makes the batch context re-entrant. + port: Held for as long as a batch is open, so two callers cannot interleave runs on one + instrument. + """ + + link: Link + family: InstrumentFamily + rules: BuildRules = COMMON + settings: InstrumentSettings = field(default_factory=InstrumentSettings) + reconciles_cassette_head: bool = False + plate: PlateRecord | None = None + reservations: Reservations = field(default_factory=Reservations) + plate_restriction: PlateRestriction | None = None + carrier_type: CarrierType | None = None + in_batch: bool = False + port: asyncio.Lock = field(default_factory=asyncio.Lock) + + @property + def plate_record(self) -> PlateRecord: + """The plate on the carrier. + + Raises: + RejectedError: If no plate has been set. Nothing can be encoded or checked without one: + every step's offsets are measured from the plate's own heights. + """ + if self.plate is None: + raise fail( + ErrorKind.REJECTED, + "no plate is on the carrier; set one before running anything", + operation="plate", + ) + return self.plate + + @property + def plate_type(self) -> PlateType: + """Which format the instrument is told is on the carrier. + + Raises: + RejectedError: If no plate has been set. + """ + return self.plate_record.plate_type + + def forget_instrument_facts(self) -> None: + """Forget what was read off the instrument about the plate it will accept. + + Called when the plate changes, so the next check asks again rather than measuring a new plate + against an answer given for the old one. + """ + self.plate_restriction = None + self.carrier_type = None diff --git a/pylabrobot/agilent/biotek/lhc/devices/settings_document.py b/pylabrobot/agilent/biotek/lhc/devices/settings_document.py new file mode 100644 index 00000000000..6b44038180e --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/settings_document.py @@ -0,0 +1,256 @@ +"""Reading and writing the fitted-options document a protocol file carries. + +A protocol file stores what the instrument was fitted with as a nested document, and hands it over +as text. This module is what turns that text into :class:`InstrumentSettings` and back, so a file +round-trips unchanged: the element order, the two-space indent and the carriage returns below are +the layout a file stores, not a choice. + +Two fields are not in the document at all. Which plate carrier and cassette are fitted is not part +of it, and neither is whether the dispensers accept the wider offset range -- that one is only ever +learned by asking the instrument, so loading a file leaves it off. +""" + +from __future__ import annotations + +from typing import Mapping +from xml.etree import ElementTree + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import StripWasherManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.valve_box import ValveBox +from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold + +FAMILY_TEXT: dict[str, InstrumentFamily] = { + "e406Basic": InstrumentFamily.EL406, + "eMMDBasic": InstrumentFamily.MULTIFLO, + "e405TSBasic": InstrumentFamily.MODEL_405_TS, + "eMultiFloFX": InstrumentFamily.MULTIFLO_FX, +} +"""The text a file stores each instrument family as.""" + +WASHER_MANIFOLD_TEXT: dict[str, WasherManifold] = { + "e96TubeDual": WasherManifold.TUBE_96_DUAL, + "e192Tube": WasherManifold.TUBE_192, + "e128Tube": WasherManifold.TUBE_128, + "e96TubeSingle": WasherManifold.TUBE_96_SINGLE, + "e96DeepPin": WasherManifold.DEEP_PIN_96, + "eNotInstalled": WasherManifold.NOT_INSTALLED, +} +"""The text a file stores each wash manifold as.""" + +SYRINGE_BOX_TEXT: dict[str, SyringeBoxType] = { + "eNotInstalled": SyringeBoxType.NOT_INSTALLED, + "eAutoclavable": SyringeBoxType.AUTOCLAVABLE, + "eNonAutoclavable": SyringeBoxType.NON_AUTOCLAVABLE, +} +"""The text a file stores each syringe box as.""" + +SYRINGE_MANIFOLD_TEXT: dict[str, SyringeManifold] = { + "eNotInstalled": SyringeManifold.NOT_INSTALLED, + "e16Tube": SyringeManifold.TUBE_16, + "e32TubeLB": SyringeManifold.TUBE_32_LARGE_BORE, + "e32TubeSB": SyringeManifold.TUBE_32_SMALL_BORE, + "e16Tube7": SyringeManifold.TUBE_16_7, + "e8Tube": SyringeManifold.TUBE_8, + "e6WellPlate": SyringeManifold.PLATE_6_WELL, + "e12WellPlate": SyringeManifold.PLATE_12_WELL, + "e24WellPlate": SyringeManifold.PLATE_24_WELL, + "e48WellPlate": SyringeManifold.PLATE_48_WELL, +} +"""The text a file stores each syringe manifold as.""" + +VALVE_BOX_TEXT: dict[str, ValveBox] = { + "eNotInstalled": ValveBox.NOT_INSTALLED, + "eWasher": ValveBox.WASHER, + "eSyringe": ValveBox.SYRINGE, + "eInternal_1": ValveBox.INTERNAL_1, + "eInternal_2": ValveBox.INTERNAL_2, + "eInternal_3": ValveBox.INTERNAL_3, + "eInternal_4": ValveBox.INTERNAL_4, +} +"""The text a file stores each valve box as.""" + +STRIP_WASHER_MANIFOLD_TEXT: dict[str, StripWasherManifold] = { + "e6WellPlate": StripWasherManifold.PLATE_6_WELL, + "e12WellPlate": StripWasherManifold.PLATE_12_WELL, + "e24WellPlate": StripWasherManifold.PLATE_24_WELL, + "e48WellPlate": StripWasherManifold.PLATE_48_WELL, + "e96WellPlate": StripWasherManifold.PLATE_96_WELL, + "eNotInstalled": StripWasherManifold.NOT_INSTALLED, +} +"""The text a file stores each strip wash manifold as.""" + +ROOT = "CInstrumentSettings" +_HEADER = ( + '\r\n' + f"<{ROOT}" + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema">' +) +_TRUE = "true" +_FALSE = "false" + +_FAMILY = "Instrument" +_WASHER_MANIFOLD = "WasherManifold" +_SYRINGE_BOX = "SyringeBox" +_SYRINGE_MANIFOLD = "SyringeManifold" +_BUFFER_SWITCHING = "BufferSwitchingModule" +_BUFFER_SWITCHING_SHORT = "BufferSwitching" +_VALVE_BOX = "ValveBoxType" +_VACUUM_FILTRATION = "VacuumFiltrationWasher" +_PERI_PUMP = "PeriPumpModule" +_PERI_PUMP_2 = "PeriPumpModule2" +_ULTRASONIC = "UltrasonicModule" +_CELL_WASHING = "CellWashingModule" +_Y_AXIS = "m_bYAxisInstalled" +_HALF_UL = "m_bHalfULEnabled" +_STRIP_WASHER_MANIFOLD = "StripWasherManifold" +_SINGLE_WELL = "m_bSingleWellEnabled" +_PERI_WASH = "m_bPWEnabled" + + +def from_xml(document: str) -> InstrumentSettings: + """Read a fitted-options document. + + An element that is absent, empty or carries text this package does not know leaves its field at + the default, which is how a document written by an older release stays readable. Which instrument + wrote it is the one exception: a model this package does not know is an error rather than a + default, because every field below is read as that model would mean it. + + Args: + document: The document as the protocol file stores it. + + Returns: + The settings it describes. + + Raises: + ValueError: If the text is not a well-formed document, or names an instrument this package does + not work with. + """ + try: + root = ElementTree.fromstring(document.strip()) + except ElementTree.ParseError as error: + raise ValueError(f"fitted options will not read: {error}") from error + default = InstrumentSettings() + + def text(tag: str) -> str: + """The text of one element. + + Args: + tag: Which element to read. + + Returns: + Its text, stripped, or an empty string when the element is absent or empty. + """ + element = root.find(tag) + return "" if element is None or element.text is None else element.text.strip() + + def flag(tag: str, fallback: bool) -> bool: + """One element read as a flag. + + Args: + tag: Which element to read. + fallback: What an absent element means. + + Returns: + The flag. + """ + found = text(tag) + return fallback if not found else found == _TRUE + + named = text(_FAMILY) + if named and named not in FAMILY_TEXT: + raise ValueError( + f"fitted options are for {named}, which this package does not work with; it works with " + f"{', '.join(FAMILY_TEXT)}" + ) + valve_box = VALVE_BOX_TEXT.get(text(_VALVE_BOX), default.valve_box) + return InstrumentSettings( + family=FAMILY_TEXT.get(named, default.family), + washer_manifold=WASHER_MANIFOLD_TEXT.get(text(_WASHER_MANIFOLD), default.washer_manifold), + syringe_box=SYRINGE_BOX_TEXT.get(text(_SYRINGE_BOX), default.syringe_box), + syringe_manifold=SYRINGE_MANIFOLD_TEXT.get(text(_SYRINGE_MANIFOLD), default.syringe_manifold), + buffer_switching=flag( + _BUFFER_SWITCHING, flag(_BUFFER_SWITCHING_SHORT, default.buffer_switching) + ), + valve_box=valve_box, + vacuum_filtration=flag(_VACUUM_FILTRATION, default.vacuum_filtration), + peri_pump=flag(_PERI_PUMP, default.peri_pump), + peri_pump_2=flag(_PERI_PUMP_2, default.peri_pump_2), + ultrasonic=flag(_ULTRASONIC, default.ultrasonic), + cell_washing=flag(_CELL_WASHING, default.cell_washing), + y_axis_installed=flag(_Y_AXIS, default.y_axis_installed), + half_ul_enabled=flag(_HALF_UL, default.half_ul_enabled), + strip_washer_manifold=STRIP_WASHER_MANIFOLD_TEXT.get( + text(_STRIP_WASHER_MANIFOLD), default.strip_washer_manifold + ), + single_well_enabled=flag(_SINGLE_WELL, default.single_well_enabled), + peri_wash_enabled=flag(_PERI_WASH, default.peri_wash_enabled), + ) + + +def to_xml(settings: InstrumentSettings) -> str: + """Write a fitted-options document. + + Args: + settings: The settings to write. + + Returns: + The document, in the layout a protocol file stores: two-space indent, carriage returns + throughout and no trailing newline. Every element is written, so a document that was stored + without the newer ones gains them, each carrying what was read for it. + + Raises: + ValueError: If a field holds a value no document has text for. + """ + lines = [_HEADER] + for tag, value in ( + (_FAMILY, _text_for(FAMILY_TEXT, settings.family, _FAMILY)), + (_WASHER_MANIFOLD, _text_for(WASHER_MANIFOLD_TEXT, settings.washer_manifold, _WASHER_MANIFOLD)), + (_SYRINGE_BOX, _text_for(SYRINGE_BOX_TEXT, settings.syringe_box, _SYRINGE_BOX)), + ( + _SYRINGE_MANIFOLD, + _text_for(SYRINGE_MANIFOLD_TEXT, settings.syringe_manifold, _SYRINGE_MANIFOLD), + ), + (_BUFFER_SWITCHING, _TRUE if settings.buffer_switching else _FALSE), + (_VALVE_BOX, _text_for(VALVE_BOX_TEXT, settings.valve_box, _VALVE_BOX)), + (_VACUUM_FILTRATION, _TRUE if settings.vacuum_filtration else _FALSE), + (_PERI_PUMP, _TRUE if settings.peri_pump else _FALSE), + (_PERI_PUMP_2, _TRUE if settings.peri_pump_2 else _FALSE), + (_ULTRASONIC, _TRUE if settings.ultrasonic else _FALSE), + (_CELL_WASHING, _TRUE if settings.cell_washing else _FALSE), + (_Y_AXIS, _TRUE if settings.y_axis_installed else _FALSE), + (_HALF_UL, _TRUE if settings.half_ul_enabled else _FALSE), + ( + _STRIP_WASHER_MANIFOLD, + _text_for(STRIP_WASHER_MANIFOLD_TEXT, settings.strip_washer_manifold, _STRIP_WASHER_MANIFOLD), + ), + (_SINGLE_WELL, _TRUE if settings.single_well_enabled else _FALSE), + (_PERI_WASH, _TRUE if settings.peri_wash_enabled else _FALSE), + ): + lines.append(f" <{tag}>{value}") + lines.append(f"") + return "\r\n".join(lines) + + +def _text_for(table: Mapping[str, int], value: int, tag: str) -> str: + """The text a document stores a value as. + + Args: + table: The texts for that element, mapping each to the value it means. + value: The value to write. + tag: Which element this is, for the message of any exception raised. + + Returns: + The text. + + Raises: + ValueError: If the table has no text for that value. + """ + for text, known in table.items(): + if known == value: + return text + raise ValueError(f"no {tag} text for {value!r}") diff --git a/pylabrobot/agilent/biotek/lhc/devices/settings_query.py b/pylabrobot/agilent/biotek/lhc/devices/settings_query.py new file mode 100644 index 00000000000..5c74723a89d --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/settings_query.py @@ -0,0 +1,312 @@ +"""Asking an instrument what it has fitted. + +A protocol file says what the instrument was fitted with when the protocol was written; this asks +the instrument what it is fitted with now. The two can disagree, and it is this answer that steps +are encoded against and validation is run against. + +Which options an instrument answers for depends on its family, so there is one sequence per family. +A query for hardware the family cannot carry is not sent at all, and the field keeps the value that +means "not fitted". +""" + +from __future__ import annotations + +import logging + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import StripWasherManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_size import SyringeBoxSize +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold +from pylabrobot.agilent.biotek.lhc.enums.instrument.valve_box import ValveBox +from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold +from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE, PeriPump +from pylabrobot.agilent.biotek.lhc.error_handling import BiotekError +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber +from pylabrobot.agilent.biotek.lhc.serialization.commands.configuration import ( + ByteQuery, + FlagQuery, + GetSyringeBoxInfo, + SelectorQuery, + SyringeBox, +) + +logger = logging.getLogger(__name__) + + +async def read_settings(link: Link, family: InstrumentFamily) -> InstrumentSettings: + """Ask an instrument what it has fitted. + + Args: + link: The open link to the instrument. + family: Which model this is. It is declared rather than discovered: the instrument does not + report which model it is. + + Returns: + What it answered, with every option the family cannot carry left at "not fitted". + + Raises: + BiotekError: If an option the family does carry cannot be read, since a step encoded against a + half-read record would be encoded wrongly. + """ + if family is InstrumentFamily.MODEL_405_TS: + return await _read_washer(link, family) + settings = await _read_dispenser(link, family) + if family is not InstrumentFamily.MULTIFLO_FX: + return settings + return await _read_strip_washer_and_firmware(link, settings) + + +async def _read_washer(link: Link, family: InstrumentFamily) -> InstrumentSettings: + """Ask a wash-only instrument what it has fitted. + + Args: + link: The open link to the instrument. + family: Which model this is. + + Returns: + What it answered. + + Raises: + BiotekError: If an option cannot be read. + """ + valve_box = ValveBox(await _byte(link, CommandNumber.GET_EXT_VALVE_MODULE_INSTALLED)) + return InstrumentSettings( + family=family, + washer_manifold=WasherManifold(await _byte(link, CommandNumber.GET_WASHER_MANIFOLD_INSTALLED)), + syringe_box=SyringeBoxType.NOT_INSTALLED, + syringe_manifold=SyringeManifold.NOT_INSTALLED, + buffer_switching=valve_box is not ValveBox.NOT_INSTALLED, + valve_box=valve_box, + vacuum_filtration=await _flag(link, CommandNumber.GET_VACUUM_FILTRATION_INSTALLED), + peri_pump=False, + peri_pump_2=False, + ultrasonic=await _flag(link, CommandNumber.GET_ULTRASONIC_CLEANER_INSTALLED), + cell_washing=await _flag(link, CommandNumber.GET_CELL_WASHING_INSTALLED), + y_axis_installed=await _flag(link, CommandNumber.GET_Y_AXIS_INSTALLED), + half_ul_enabled=False, + strip_washer_manifold=StripWasherManifold.NOT_INSTALLED, + ) + + +async def _read_dispenser(link: Link, family: InstrumentFamily) -> InstrumentSettings: + """Ask an instrument with syringes and peristaltic pumps what it has fitted. + + The wash manifold, the valve box and the modules that go with it are only asked about on the + model that can carry them; on the others the query is not sent and the field says "not fitted". + + Args: + link: The open link to the instrument. + family: Which model this is. + + Returns: + What it answered. + + Raises: + BiotekError: If an option cannot be read. + """ + washes = family is InstrumentFamily.EL406 + syringe_manifold = SyringeManifold( + await _byte(link, CommandNumber.GET_SYRINGE_MANIFOLD_INSTALLED) + ) + box = await _syringe_box(link) + primary = await _peri_installed(link, "Primary") + secondary = False if washes else await _peri_installed(link, "Secondary") + valve_box = ( + ValveBox(await _byte(link, CommandNumber.GET_EXT_VALVE_MODULE_INSTALLED)) + if washes + else ValveBox.NOT_INSTALLED + ) + return InstrumentSettings( + family=family, + washer_manifold=( + WasherManifold(await _byte(link, CommandNumber.GET_WASHER_MANIFOLD_INSTALLED)) + if washes + else WasherManifold.NOT_INSTALLED + ), + syringe_box=SyringeBoxType(box.box_type), + syringe_manifold=syringe_manifold, + buffer_switching=valve_box is not ValveBox.NOT_INSTALLED, + valve_box=valve_box, + vacuum_filtration=( + await _flag(link, CommandNumber.GET_VACUUM_FILTRATION_INSTALLED) if washes else False + ), + peri_pump=primary, + peri_pump_2=secondary, + ultrasonic=( + await _flag(link, CommandNumber.GET_ULTRASONIC_CLEANER_INSTALLED) if washes else False + ), + cell_washing=(await _flag(link, CommandNumber.GET_CELL_WASHING_INSTALLED) if washes else False), + y_axis_installed=True, + half_ul_enabled=bool(await _optional_flag(link, CommandNumber.GET_IS_PERI_HALF_UL_SUPPORTED)), + strip_washer_manifold=StripWasherManifold.NOT_INSTALLED, + syringe_box_size=SyringeBoxSize(box.box_size), + ) + + +async def _read_strip_washer_and_firmware( + link: Link, settings: InstrumentSettings +) -> InstrumentSettings: + """Add what only the newest model reports: its strip washer and its firmware variant. + + The strip washer manifold is only asked about once both the box and its hardware answer yes, so + an instrument that has neither keeps a manifold of "not fitted" and offers no strip wash steps. + The firmware variant is what makes the peristaltic wash steps available, and a firmware that does + not report one leaves them unavailable. + + Args: + link: The open link to the instrument. + settings: What has been read so far. + + Returns: + The settings, with the strip washer and firmware fields filled in. + """ + manifold = settings.strip_washer_manifold + single_well = settings.single_well_enabled + if await _optional_flag(link, CommandNumber.IS_STRIP_WASHER_BOX_CONNECTED) and ( + await _optional_flag(link, CommandNumber.GET_STRIP_WASHER_HW_INSTALLED) + ): + fitted = await _optional_byte(link, CommandNumber.GET_STRIP_WASHER_MANIFOLD_TYPE) + if fitted is not None: + manifold = StripWasherManifold(fitted) + single_well = bool( + await _optional_flag(link, CommandNumber.GET_SINGLE_WELL_DISPENSER_INSTALLED) + ) + basecode = await _optional_byte(link, CommandNumber.GET_WHICH_BASECODE_IS_INSTALLED) + return InstrumentSettings( + family=settings.family, + washer_manifold=settings.washer_manifold, + syringe_box=settings.syringe_box, + syringe_manifold=settings.syringe_manifold, + buffer_switching=settings.buffer_switching, + valve_box=settings.valve_box, + vacuum_filtration=settings.vacuum_filtration, + peri_pump=settings.peri_pump, + peri_pump_2=settings.peri_pump_2, + ultrasonic=settings.ultrasonic, + cell_washing=settings.cell_washing, + y_axis_installed=settings.y_axis_installed, + half_ul_enabled=settings.half_ul_enabled, + strip_washer_manifold=manifold, + single_well_enabled=single_well, + peri_wash_enabled=basecode == Basecode.PERI_WASH, + advanced_dispense_offsets=await _answers(link, CommandNumber.GET_FLUID_TRACKING_ENABLED), + syringe_box_size=settings.syringe_box_size, + ) + + +async def _byte(link: Link, number: CommandNumber) -> int: + """Read a one-byte option. + + Args: + link: The open link to the instrument. + number: Which option to read. + + Returns: + The byte. + + Raises: + BiotekError: If the option cannot be read. + """ + command = ByteQuery(number) + return command.parse(await link.request(command, operation=number.name)) + + +async def _flag(link: Link, number: CommandNumber) -> bool: + """Read an option that is fitted or not. + + Args: + link: The open link to the instrument. + number: Which option to read. + + Returns: + Whether it is fitted. + + Raises: + BiotekError: If the option cannot be read. + """ + command = FlagQuery(number) + return command.parse_flag(await link.request(command, operation=number.name)) + + +async def _peri_installed(link: Link, pump: PeriPump) -> bool: + """Read whether a peristaltic pump is fitted. + + Args: + link: The open link to the instrument. + pump: Which pump to ask about. + + Returns: + Whether it is fitted. + + Raises: + BiotekError: If the answer cannot be read. + """ + command = SelectorQuery(CommandNumber.GET_SELECTED_PERI_INSTALLED, PERI_PUMP_TO_BYTE[pump]) + return command.parse_flag(await link.request(command, operation=f"peri pump {pump.lower()}")) + + +async def _syringe_box(link: Link) -> SyringeBox: + """Read which syringe box is fitted and how many bottles it holds. + + Args: + link: The open link to the instrument. + + Returns: + The box type and size. + + Raises: + BiotekError: If the answer cannot be read. + """ + command = GetSyringeBoxInfo() + return command.parse(await link.request(command, operation="syringe box")) + + +async def _optional_byte(link: Link, number: CommandNumber) -> int | None: + """Read a one-byte option that not every firmware answers for. + + Args: + link: The open link to the instrument. + number: Which option to read. + + Returns: + The byte, or None when the instrument would not answer. + """ + try: + return await _byte(link, number) + except BiotekError as error: + logger.debug("%s does not answer %s: %s", link.name, number.name, error) + return None + + +async def _optional_flag(link: Link, number: CommandNumber) -> bool | None: + """Read an option that not every firmware answers for. + + Args: + link: The open link to the instrument. + number: Which option to read. + + Returns: + Whether it is fitted, or None when the instrument would not answer. + """ + answer = await _optional_byte(link, number) + return None if answer is None else bool(answer) + + +async def _answers(link: Link, number: CommandNumber) -> bool: + """Whether the instrument answers a query at all, rather than what it answers. + + One option is reported this way: a firmware that has it answers, and one that does not refuses + the query. + + Args: + link: The open link to the instrument. + number: Which query to send. + + Returns: + Whether it was answered. + """ + return await _optional_byte(link, number) is not None diff --git a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py new file mode 100644 index 00000000000..9d3ae45a1bd --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py @@ -0,0 +1,342 @@ +"""The 405 TS, a plate washer with no dispensers of its own.""" + +from __future__ import annotations + +import logging +from contextlib import AbstractAsyncContextManager +from typing import ClassVar + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport +from pylabrobot.agilent.biotek.lhc.devices import batch as batching +from pylabrobot.agilent.biotek.lhc.devices import execution, settings_query +from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for +from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.plate_geometry.resolution import resolve +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Shake, Soak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.validation.configuration import available_step_types +from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import RunSelfCheck +from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( + FirmwareVersion, + GetFirmwareVersion, + GetSerialNumber, + Ping, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import RunStatus +from pylabrobot.resources import Plate + +logger = logging.getLogger(__name__) + +PALETTE: tuple[StepType, ...] = ( + StepType.MANIFOLD_WASH, + StepType.MANIFOLD_ASPIRATE, + StepType.MANIFOLD_DISPENSE, + StepType.MANIFOLD_PRIME, + StepType.MANIFOLD_AUTO_CLEAN, + StepType.SHAKE_SOAK, +) +"""Every step type this model can be built to run. What it does run depends on what is fitted.""" + + +class Washer405TS: + """A 405 TS plate washer. + + Everything it does goes through its one wash manifold, reached as :attr:`washer`. It carries no + syringes and no peristaltic pumps, so it dispenses nothing of its own; a protocol written for a + washer-dispenser has its dispensing steps refused by :meth:`can_run`. + + ```python + device = Washer405TS(port="/dev/ttyUSB0") + await device.setup() + device.set_plate(plate) + await device.washer.wash(cycles=3) + await device.stop() + ``` + + Args: + port: The port the instrument is on. A string carrying a device serial number names a USB + bridge; anything else is taken to be a serial port. + name: What to call this instrument in logs and error messages. + timeout: How long to wait for a reply, in seconds. Commands that answer only once the + instrument has stopped moving carry longer timeouts of their own. + io: An already open transport to use instead of opening ``port``, for a test that replays a + recorded exchange. + + Attributes: + washer: The wash manifold. + """ + + family: ClassVar[InstrumentFamily] = InstrumentFamily.MODEL_405_TS + checked_on_hardware: ClassVar[bool] = False + + def __init__( + self, + port: str = "", + name: str = "405 TS", + timeout: float = DEFAULT_READ_TIMEOUT, + io: Transport | None = None, + ) -> None: + self._runtime = Runtime( + link=Link(port=port, family=self.family, name=name, timeout=timeout, io=io), + family=self.family, + rules=rules_for(self.family), + ) + self.washer = PlateWasher(self._runtime) + + @property + def name(self) -> str: + """What this instrument is called in logs and error messages.""" + return self._runtime.link.name + + @property + def settings(self) -> InstrumentSettings: + """What the instrument reported as fitted when :meth:`setup` last ran.""" + return self._runtime.settings + + @property + def plate(self) -> PlateRecord | None: + """The plate the instrument is set to work, or None while none has been set.""" + return self._runtime.plate + + async def setup(self) -> None: + """Open the link and read what the instrument has fitted. + + Reading the fitted options is not optional: every step is encoded against them, and checking a + protocol measures it against them. + + Raises: + BiotekError: If the port will not open, nothing answers on it, or the fitted options cannot + be read. + """ + if not self.checked_on_hardware: + logger.warning( + "the %s driver has not been checked against a real instrument; verify every protocol on " + "labware you can afford to lose before trusting it", + type(self).__name__, + ) + link = self._runtime.link + await link.setup() + await link.request(Ping(), operation="ping") + self._runtime.settings = await settings_query.read_settings(link, self.family) + self._runtime.forget_instrument_facts() + logger.info("%s is ready: %s", self.name, self._runtime.settings) + + async def stop(self) -> None: + """Close the link. Calling this on a closed instrument does nothing.""" + await self._runtime.link.stop() + + def set_plate(self, plate: Plate, plate_type: PlateType | None = None) -> None: + """Tell the instrument which plate is on its carrier. + + Args: + plate: The labware on the carrier. Its columns, rows and well depth decide which of the + formats the instrument works it as. + plate_type: The format to use, for labware that cannot be resolved on its own or that is to + be worked as something else. + + Raises: + ValueError: If the plate matches no format this model works, or more than one. The message + names the candidates, which are what ``plate_type`` may be set to. + """ + self._runtime.plate = resolve(plate, self.family, plate_type) + self._runtime.forget_instrument_facts() + logger.info("%s is set to a %s", self.name, self._runtime.plate.label) + + def clear_plate(self) -> None: + """Forget which plate is on the carrier. Nothing can run until another is set.""" + self._runtime.plate = None + self._runtime.forget_instrument_facts() + + def get_available_steps(self) -> list[StepType]: + """Which operations this instrument can carry out as it is fitted. + + Returns: + The step types, in the order they are numbered. A type this model is never built to run is + absent whatever is fitted, and so is one whose hardware is missing. + """ + fitted = set(available_step_types(self._runtime.settings)) + return [step_type for step_type in PALETTE if step_type in fitted] + + async def can_run(self, protocol: Protocol | list[Step]) -> ValidationReport: + """Check whether a protocol can run on the instrument as it is. + + The report is truthy when every step can run, and prints as the list of those that cannot, so + it reads as the answer to the question. Running a protocol does this first; call it beforehand + to see what is wrong without touching the plate. + + Args: + protocol: The protocol, or the steps on their own. + + Returns: + The report. + + Raises: + RejectedError: If no plate has been set. + ValueError: If the protocol carries a step this package cannot read. + """ + return await execution.can_run(self._runtime, execution.steps_of(protocol)) + + async def run_protocol( + self, + protocol: Protocol | list[Step], + check: bool = True, + home_on_close: bool = False, + ) -> None: + """Run every step of a protocol, in one batch. + + Args: + protocol: The protocol, or the steps on their own. + check: Whether to check the protocol first. Turning this off also gives up what the check + works out about the pumps, so the batch opens against whatever cassette is fitted rather + than the one the protocol asks for. + home_on_close: Whether to home the transport before closing the batch. + + Raises: + BiotekError: If the protocol cannot run, the hardware cannot be made to match it, or a step + fails. + RejectedError: If no plate has been set. + ValueError: If the protocol carries a step this package cannot read. + """ + await execution.run_steps( + self._runtime, execution.steps_of(protocol), check=check, home_on_close=home_on_close + ) + + async def run_step(self, step: Step) -> None: + """Run one step built outright, for an operation the capability objects do not spell out. + + Args: + step: The step to run. + + Raises: + BiotekError: If the step cannot run, or fails while running. + RejectedError: If no plate has been set. + """ + await execution.run_steps(self._runtime, [step]) + + def batch(self, home_on_close: bool = False) -> AbstractAsyncContextManager[None]: + """Hold one batch open across several operations. + + Opening a batch homes the motors and takes the instrument, which is worth doing once rather + than per operation. Nesting is allowed and does nothing: an operation inside an open batch + joins it. + + Args: + home_on_close: Whether to home the transport before closing the batch. The instrument does + not do this of its own accord; ask for it when the next thing to touch the plate is a + person. + + Returns: + A context manager holding the batch open for the body of the block. + """ + return batching.batch(self._runtime, home_on_close=home_on_close) + + async def shake( + self, + duration: int = 5, + intensity: ShakeIntensity = "Medium", + axis: ShakeAxis = "X", + soak_duration: int = 0, + move_carrier_home: bool = True, + ) -> None: + """Shake the plate, soak it, or both. + + Args: + duration: How long to shake, in seconds. Zero shakes not at all. + intensity: How hard to shake. + axis: Which axis to shake along. + soak_duration: How long to leave the plate still afterwards, in seconds. Zero soaks not at + all. + move_carrier_home: Whether the carrier returns home afterwards. + + Raises: + BiotekError: If the step cannot run, or fails while running. + """ + shake = Shake(enabled=duration > 0, intensity=intensity, axis=axis) + if duration > 0: + shake.duration = duration + soak = Soak(enabled=soak_duration > 0) + if soak_duration > 0: + soak.duration = soak_duration + await execution.run_steps( + self._runtime, + [ShakeSoak(shake=shake, soak=soak, move_carrier_home=move_carrier_home)], + ) + + async def get_status(self) -> RunStatus: + """Ask what the instrument is doing. + + Returns: + The run state, and the phase and countdown of a running step. + + Raises: + BiotekError: If the instrument reports a fault. + """ + return await execution.status(self._runtime) + + async def get_serial_number(self) -> str: + """Read the instrument's serial number. + + Returns: + The serial number. + + Raises: + BiotekError: If it cannot be read. + """ + command = GetSerialNumber() + return command.parse(await self._runtime.link.request(command, operation="serial number")) + + async def get_firmware_version(self) -> FirmwareVersion: + """Read the instrument's firmware version. + + Returns: + The version record, whose halves are the instrument's two processors. + + Raises: + BiotekError: If it cannot be read. + """ + command = GetFirmwareVersion() + return command.parse(await self._runtime.link.request(command, operation="firmware version")) + + async def self_check(self) -> None: + """Run the instrument's own self-check and wait for it. + + Raises: + BiotekError: If the check does not pass, reporting what failed. + """ + await self._runtime.link.request(RunSelfCheck(), operation="self check") + + async def abort(self) -> None: + """Stop the running step. + + Raises: + BiotekError: If the instrument will not stop. + """ + await execution.abort(self._runtime) + + async def pause(self) -> None: + """Pause the running step. + + Raises: + BiotekError: If the instrument will not pause. + """ + await execution.pause(self._runtime) + + async def resume(self) -> None: + """Resume a paused step. + + Raises: + BiotekError: If the instrument will not resume. + """ + await execution.resume(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/plate_geometry/__init__.py b/pylabrobot/agilent/biotek/lhc/plate_geometry/__init__.py index 2db5c95caf2..d447d950164 100644 --- a/pylabrobot/agilent/biotek/lhc/plate_geometry/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/plate_geometry/__init__.py @@ -9,5 +9,19 @@ find, plates_for, ) +from pylabrobot.agilent.biotek.lhc.plate_geometry.resolution import ( + DEEP_WELL_DEPTH, + SELECTION_ONLY, + resolve, +) -__all__ = ["DISPENSER_PLATES", "WASHER_PLATES", "PlateRecord", "find", "plates_for"] +__all__ = [ + "DEEP_WELL_DEPTH", + "DISPENSER_PLATES", + "SELECTION_ONLY", + "WASHER_PLATES", + "PlateRecord", + "find", + "plates_for", + "resolve", +] diff --git a/pylabrobot/agilent/biotek/lhc/plate_geometry/resolution.py b/pylabrobot/agilent/biotek/lhc/plate_geometry/resolution.py new file mode 100644 index 00000000000..cf48ac19a0a --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/plate_geometry/resolution.py @@ -0,0 +1,152 @@ +"""Resolving a labware resource to the plate an instrument works it as. + +An instrument is told what sits on its carrier as one of a fixed set of formats, so a +:class:`~pylabrobot.resources.Plate` has to be matched to one of them before anything can run. The +match is made on the resource's own geometry -- how many columns and rows it has, and how deep its +wells are -- and never on a nearest fit: labware that does not land on exactly one format is an +error naming the formats it could have been, so that the caller says which one it is rather than +this module deciding for them. +""" + +from __future__ import annotations + +from typing import Iterable + +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord +from pylabrobot.agilent.biotek.lhc.plate_geometry.plates import find, plates_for +from pylabrobot.resources import Plate + +DEEP_WELL_DEPTH = 20.0 +"""From how deep, in mm, a well counts as a deep well. + +This separates the two formats that differ in nothing else: a plate of a given number of wells has +both a standard and a deep-well format, and a well twice as deep as any standard plate's is what +tells them apart. Nothing else is decided by a threshold. +""" + +SELECTION_ONLY: frozenset[PlateType] = frozenset( + { + PlateType.PLATE_96_HALF_WELL, + PlateType.PLATE_96_MINI_TUBES, + PlateType.PLATE_384_WELL_PCR, + PlateType.PLATE_1536_FLANGE, + PlateType.TUBES_20_12X75, + PlateType.TUBES_20_13X100, + PlateType.TEST_PLATE_96_WELL_MB, + PlateType.TEST_PLATE_96_WELL_BD, + } +) +"""The formats that are never resolved from a resource, only named outright. + +Each of them shares its column and row count with an ordinary plate and differs in something the +resource does not carry -- a well shape, a flange, a tube, or that it is calibration labware -- so +resolving one would be a guess. +""" + + +def resolve( + plate: Plate, + family: InstrumentFamily, + plate_type: PlateType | None = None, +) -> PlateRecord: + """The format an instrument works a plate as. + + Args: + plate: The labware on the carrier. + family: Which instrument model this is, which decides the formats on offer and the heights they + are worked at. + plate_type: The format to use, for labware this cannot resolve on its own or that is to be + worked as something other than what it resolves to. Checked against the formats the family + offers, and otherwise used as given. + + Returns: + The record the instrument works that format by. + + Raises: + ValueError: If ``plate_type`` is a format the family does not offer, if the plate's columns and + rows match no format it offers, or if they match more than one. The message names the + candidates, which are what ``plate_type`` may be set to. + """ + offered = plates_for(family) + if plate_type is not None: + named = find(plate_type, family) + if named is None: + raise ValueError( + f"{family.name} does not offer {plate_type.name}; it offers " + f"{_names(record.plate_type for record in offered)}" + ) + return named + + columns, rows = plate.num_items_x, plate.num_items_y + shaped = [record for record in offered if record.columns == columns and record.rows == rows] + candidates = [record for record in shaped if record.plate_type not in SELECTION_ONLY] + if len(candidates) > 1: + candidates = _by_depth(candidates, _well_depth(plate)) + + if len(candidates) == 1: + return candidates[0] + if not shaped: + raise ValueError( + f"{family.name} works no {columns}x{rows} plate; it offers " + f"{_names(record.plate_type for record in offered)}" + ) + raise ValueError( + f"a {columns}x{rows} plate {plate.get_item(0).get_size_z():g} mm deep could be any of " + f"{_names(record.plate_type for record in shaped)} on a {family.name}; name one as plate_type" + ) + + +def _by_depth(candidates: list[PlateRecord], depth: float) -> list[PlateRecord]: + """Narrow candidates of one shape to the ones matching how deep the wells are. + + Args: + candidates: The records of one column and row count. + depth: How deep a well is, in mm. + + Returns: + The candidates whose format is a deep-well one when the wells are deep, and the rest when they + are not. Unchanged when that leaves nothing, so the caller reports an ambiguity rather than an + absence. + """ + deep = depth >= DEEP_WELL_DEPTH + narrowed = [record for record in candidates if _is_deep_well(record.plate_type) == deep] + return narrowed if narrowed else candidates + + +def _is_deep_well(plate_type: PlateType) -> bool: + """Whether a format is a deep-well one. + + Args: + plate_type: The format to ask about. + + Returns: + Whether it is. + """ + return plate_type in (PlateType.PLATE_96_DEEP_WELL, PlateType.PLATE_384_DEEP_WELL) + + +def _well_depth(plate: Plate) -> float: + """How deep a plate's wells are, in mm. + + Args: + plate: The labware to measure. + + Returns: + The depth of its first well, or the plate's own height for labware carrying no wells. + """ + wells = plate.get_all_items() + return wells[0].get_size_z() if wells else plate.get_size_z() + + +def _names(plate_types: Iterable[PlateType]) -> str: + """Format types for an error message. + + Args: + plate_types: The types to name, in the order they should be read. + + Returns: + Their names, comma separated. + """ + return ", ".join(plate_type.name for plate_type in plate_types) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/definition.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/definition.py index a42e0977174..9c8369444a0 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/definition.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/definition.py @@ -8,6 +8,8 @@ from __future__ import annotations +from typing import Callable + from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TRAVEL_RATE_TO_BYTE, TravelRate @@ -23,6 +25,38 @@ _MARKER_VERSION = 103.0 +def version_of(found: list[str]) -> float | None: + """Which format version a definition's marker names. + + Args: + found: The fields of a definition. + + Returns: + The version, or None when the definition carries no marker. An unmarked definition predates the + marker, so it is older than any version that has one. + """ + if not found or not found[0].startswith(_MARKER_PREFIX): + return None + return float(found[0][len(_MARKER_PREFIX) :]) + + +def is_older_format(found: list[str]) -> bool: + """Whether a definition was written by a release older than the one this package writes. + + Only such a definition may be short: each version appended fields to the end of a layout, so an + older one is missing a tail. A definition of the current version that is short is not missing a + tail -- it is another product's layout, whose fields do not line up at all. + + Args: + found: The fields of a definition. + + Returns: + Whether it is older. + """ + version = version_of(found) + return version is None or version < _MARKER_VERSION + + def fields(text: str, empty_ok: bool = False) -> tuple[list[str], int]: """Split a step definition and say where its step-type field sits. @@ -50,7 +84,13 @@ def fields(text: str, empty_ok: bool = False) -> tuple[list[str], int]: return found, 1 -def own_fields(text: str, step_type: StepType, count: int, empty_ok: bool = False) -> list[str]: +def own_fields( + text: str, + step_type: StepType, + count: int, + empty_ok: bool = False, + defaults: Callable[[], str] | None = None, +) -> list[str]: """The fields belonging to a step type whose layout is a fixed length. Args: @@ -58,22 +98,31 @@ def own_fields(text: str, step_type: StepType, count: int, empty_ok: bool = Fals step_type: The step type the layout belongs to, named in the error message. count: How many fields the layout has, counting the step-type field. empty_ok: Whether an empty field holds its place. + defaults: What the type's defaults look like as a definition, for filling in the tail an older + release did not write. Without it a short definition is an error. Returns: The fields after the step-type field, ``count - 1`` of them. Raises: - ValueError: If the definition has a different number of fields. A definition one field short - is another instrument family's, not a repairable one. + ValueError: If the definition has more fields than the layout, or fewer without being an older + release's. """ found, start = fields(text, empty_ok) - if len(found) - start != count: - raise ValueError(f"{step_type.name} expects {count} fields, got {len(found) - start}: {text!r}") - return found[start + 1 :] + present = len(found) - start + if present == count: + return found[start + 1 :] + if present < count and defaults is not None and is_older_format(found): + return _filled(found, start, count, step_type, defaults, empty_ok, text) + raise ValueError(f"{step_type.name} expects {count} fields, got {present}: {text!r}") def own_fields_at_least( - text: str, step_type: StepType, minimum: int, empty_ok: bool = False + text: str, + step_type: StepType, + minimum: int, + empty_ok: bool = False, + defaults: Callable[[], str] | None = None, ) -> list[str]: """The fields belonging to a step type whose layout ends in an optional tail. @@ -84,19 +133,65 @@ def own_fields_at_least( step_type: The step type the layout belongs to, named in the error message. minimum: The shortest the layout can be, counting the step-type field. empty_ok: Whether an empty field holds its place. + defaults: What the type's defaults look like as a definition, for filling in the tail an older + release did not write. Without it a short definition is an error. Returns: The fields after the step-type field, at least ``minimum - 1`` of them. Raises: - ValueError: If the definition has fewer fields than that. + ValueError: If the definition has fewer fields than that without being an older release's. """ found, start = fields(text, empty_ok) - if len(found) - start < minimum: + present = len(found) - start + if present >= minimum: + return found[start + 1 :] + if defaults is not None and is_older_format(found): + return _filled(found, start, minimum, step_type, defaults, empty_ok, text) + raise ValueError(f"{step_type.name} expects at least {minimum} fields, got {present}: {text!r}") + + +def _filled( + found: list[str], + start: int, + count: int, + step_type: StepType, + defaults: Callable[[], str], + empty_ok: bool, + text: str, +) -> list[str]: + """Fill a definition written by an older release out to the current layout's full length. + + Each version appended its new fields to the end of a layout, so a definition an older release + wrote is the current one minus a tail: the fields it does carry mean what they mean at the + positions they are in, and the rest are taken from the type's defaults. That is why only an older + definition may be filled -- a short definition of the *current* version is another product's + layout, where a field can be missing from the middle and nothing after it lines up. + + Args: + found: The fields the definition carries. + start: Where its step-type field sits. + count: How many fields the layout has, counting the step-type field. + step_type: The step type the layout belongs to, named in the error message. + defaults: What the type's defaults look like as a definition. + empty_ok: Whether an empty field holds its place. + text: The definition, named in the error message. + + Returns: + The fields after the step-type field, ``count - 1`` of them. + + Raises: + ValueError: If the defaults are themselves shorter than the layout, which means they cannot say + what the absent fields are. + """ + present = len(found) - start + filled, filled_start = fields(defaults(), empty_ok) + if len(filled) - filled_start < count: raise ValueError( - f"{step_type.name} expects at least {minimum} fields, got {len(found) - start}: {text!r}" + f"{step_type.name} expects {count} fields, got {present}, and its defaults describe " + f"{len(filled) - filled_start}: {text!r}" ) - return found[start + 1 :] + return found[start + 1 :] + filled[filled_start + present : filled_start + count] def flag(field: str) -> bool: diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py index a487c702465..8137ad71b3e 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_interface.py @@ -47,6 +47,20 @@ def from_definition(cls, text: str) -> Step: ValueError: If the definition does not have this type's layout. """ + @classmethod + def default_definition(cls) -> str: + """The definition text of this type with every field at its default. + + Read when a definition stops short of the layout's full length: the fields it does not carry + are taken from here, which keeps the defaults in one place -- the dataclass -- rather than + repeating them as text. + + Returns: + The definition of a default step, up to the first sub-step. Only the type's own fields are + ever filled in this way; a composite's sub-steps carry their own. + """ + return cls().to_definition().split("#")[0] + @abc.abstractmethod def to_bytes(self, settings: InstrumentSettings) -> bytes: """Encode the step as the payload of the command that runs it. diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py index ce5ea428096..06c8894bb2f 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py @@ -83,7 +83,9 @@ def from_definition(cls, text: str) -> ManifoldAspirate: ValueError: If the definition does not have this step type's layout, or names a travel rate that does not exist. """ - own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS) + own = definition.own_fields_at_least( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) ( vacuum, travel_rate, diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py index 2e2de8b0a75..f5040e6d993 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_auto_clean.py @@ -59,7 +59,9 @@ def from_definition(cls, text: str) -> ManifoldAutoClean: ValueError: If the definition does not have this step type's layout, or names a buffer that does not exist. """ - buffer, duration = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + buffer, duration = definition.own_fields( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) if buffer not in definition.BUFFERS: raise ValueError(f"unknown buffer: {buffer!r}") return cls(buffer=definition.BUFFERS[buffer], duration=parse_hours_minutes(duration)) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py index 81d49722d84..fd317653f8b 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py @@ -92,7 +92,9 @@ def from_definition(cls, text: str) -> ManifoldDispense: pre_flow_rate, vacuum_enabled, vacuum_volume, - ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + ) = definition.own_fields( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) if buffer not in definition.BUFFERS: raise ValueError(f"unknown buffer: {buffer!r}") return cls( diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py index 8f65d62dda2..b648b6977cd 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_prime.py @@ -73,7 +73,9 @@ def from_definition(cls, text: str) -> ManifoldPrime: low_flow_volume, submerge, duration, - ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + ) = definition.own_fields( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) if buffer not in definition.BUFFERS: raise ValueError(f"unknown buffer: {buffer!r}") return cls( diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py index 1a87ab7a884..c212d2d6531 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_wash.py @@ -98,7 +98,10 @@ def from_definition(cls, text: str) -> ManifoldWash: raise ValueError(f"{cls.step_type.name} expects {_DEFINITION_PARTS} parts, got {len(parts)}") head, bottom_wash, aspirate, dispense, shake_soak, final_aspirate = parts wash_format, sectors, cycles, *stages = definition.own_fields( - head, cls.step_type, _DEFINITION_FIELDS + head, + cls.step_type, + _DEFINITION_FIELDS, + defaults=cls.default_definition, ) if wash_format not in definition.WASH_FORMATS: raise ValueError(f"unknown wash format: {wash_format!r}") diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py index 2084bcbf2d2..8f32f43621a 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py @@ -116,7 +116,9 @@ def from_definition(cls, text: str) -> PeriDispense: columns, rows, pump, - ) = definition.own_fields(text, cls.step_type, DEFINITION_FIELDS) + ) = definition.own_fields( + text, cls.step_type, DEFINITION_FIELDS, defaults=cls.default_definition + ) if flow_rate not in PERI_FLOW_RATES: raise ValueError(f"unknown peristaltic flow rate: {flow_rate!r}") if int(cassette) != NO_CASSETTE_REQUIREMENT and int(cassette) not in BYTE_TO_CASSETTE_TYPE: diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py index 9c976851715..1b637dd4030 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_prime.py @@ -100,7 +100,9 @@ def from_definition(cls, text: str) -> PeriPrime: ValueError: If the definition does not have this step type's layout, or names a flow rate, cassette or pump that does not exist. """ - own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS) + own = definition.own_fields_at_least( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) fixed_volume, volume, duration, flow_rate, home, cassette, pump = own[:7] tail = own[7:] if flow_rate not in _PERI_FLOW_RATES: diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py index 433aaa41f25..2a112393af9 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py @@ -88,7 +88,9 @@ def from_definition(cls, text: str) -> PeriRandomAccessDispense: ValueError: If the definition does not have this step type's layout, or names a flow rate, cassette or pump that does not exist. """ - own = definition.own_fields(text, cls.step_type, DEFINITION_FIELDS + _TAIL_FIELDS) + own = definition.own_fields( + text, cls.step_type, DEFINITION_FIELDS + _TAIL_FIELDS, defaults=cls.default_definition + ) ( volume, flow_rate, diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py index e910a469cce..a244e1cc75a 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py @@ -75,7 +75,10 @@ def from_definition(cls, text: str) -> PeriWashAspirate: does not exist. """ volume, flow_rate, z, x, y, pump, columns, rows = definition.own_fields( - text, cls.step_type, _DEFINITION_FIELDS + text, + cls.step_type, + _DEFINITION_FIELDS, + defaults=cls.default_definition, ) if int(pump) != _NO_PUMP and int(pump) not in _BYTE_TO_PERI_PUMP: raise ValueError(f"unknown peristaltic pump: {pump!r}") diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py index 7554dba1241..d4029d5bab0 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py @@ -94,7 +94,9 @@ def from_definition(cls, text: str) -> PeriWashDispense: pre_count, columns, rows, - ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + ) = definition.own_fields( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) if int(pump) != _NO_PUMP and int(pump) not in _BYTE_TO_PERI_PUMP: raise ValueError(f"unknown peristaltic pump: {pump!r}") return cls( diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py index f5d333a24a1..1083dcd58e2 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/shake_soak.py @@ -75,7 +75,9 @@ def from_definition(cls, text: str) -> ShakeSoak: intensity, soak_enabled, soak_duration, - ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + ) = definition.own_fields( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) return cls( move_carrier_home=definition.flag(move_carrier_home), shake=Shake.from_definition(shake_enabled, shake_duration, axis, intensity), diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py index a951c6e2a11..17f749b7a90 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py @@ -82,7 +82,9 @@ def from_definition(cls, text: str) -> StripAspirate: ValueError: If the definition does not have this step type's layout, or names a travel rate that does not exist. """ - own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS, empty_ok=True) + own = definition.own_fields_at_least( + text, cls.step_type, _DEFINITION_FIELDS, empty_ok=True, defaults=cls.default_definition + ) travel_rate, delay, z, x, y, pattern, secondary_z, secondary_x, secondary_y = own[:9] masks = own[9:] if travel_rate not in definition.TRAVEL_RATES: diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py index 0f49ab12f34..68498adedba 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py @@ -95,7 +95,9 @@ def from_definition(cls, text: str) -> StripDispense: Raises: ValueError: If the definition does not have this step type's layout. """ - own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS, empty_ok=True) + own = definition.own_fields_at_least( + text, cls.step_type, _DEFINITION_FIELDS, empty_ok=True, defaults=cls.default_definition + ) ( volume, flow_rate, diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py index 2082b9b49dc..060c0f20f2a 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_prime.py @@ -62,7 +62,11 @@ def from_definition(cls, text: str) -> StripPrime: ValueError: If the definition does not have this step type's layout. """ volume, flow_rate, cycles, submerge, duration = definition.own_fields( - text, cls.step_type, _DEFINITION_FIELDS, empty_ok=True + text, + cls.step_type, + _DEFINITION_FIELDS, + empty_ok=True, + defaults=cls.default_definition, ) return cls( volume=definition.number(volume, 16), diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py index 9fae1d00fdd..0886dfaf177 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_wash.py @@ -97,7 +97,9 @@ def from_definition(cls, text: str) -> StripWash: if len(parts) != _DEFINITION_PARTS: raise ValueError(f"{cls.step_type.name} expects {_DEFINITION_PARTS} parts, got {len(parts)}") head, bottom_wash, aspirate, dispense, shake_soak, final_aspirate = parts - own = definition.own_fields(head, cls.step_type, _DEFINITION_FIELDS, empty_ok=True) + own = definition.own_fields( + head, cls.step_type, _DEFINITION_FIELDS, empty_ok=True, defaults=cls.default_definition + ) wash_format, cycles = own[0], own[1] if wash_format not in definition.WASH_FORMATS: raise ValueError(f"unknown wash format: {wash_format!r}") diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py index 3807bc523d4..e3d7596db85 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py @@ -95,7 +95,9 @@ def from_definition(cls, text: str) -> SyringeDispense: ValueError: If the definition does not have this step type's layout, or names a syringe or bottle that does not exist. """ - own = definition.own_fields_at_least(text, cls.step_type, _DEFINITION_FIELDS) + own = definition.own_fields_at_least( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) ( syringe, volume, diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py index 4b1666b33ad..3c1fb08cec5 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_prime.py @@ -89,7 +89,9 @@ def from_definition(cls, text: str) -> SyringePrime: submerge, duration, bottle, - ) = definition.own_fields(text, cls.step_type, _DEFINITION_FIELDS) + ) = definition.own_fields( + text, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) if int(syringe) not in _BYTE_TO_SYRINGE: raise ValueError(f"unknown syringe: {syringe!r}") if int(bottle) not in _BYTE_TO_SYRINGE_BOTTLE: diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py index 761cfa4ab1a..34226fe2261 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py @@ -111,7 +111,9 @@ def from_definition(cls, text: str) -> Wash1536: between, final, shake_after, - ) = definition.own_fields(head, cls.step_type, _DEFINITION_FIELDS) + ) = definition.own_fields( + head, cls.step_type, _DEFINITION_FIELDS, defaults=cls.default_definition + ) if wash_format not in definition.WASH_FORMATS: raise ValueError(f"unknown wash format: {wash_format!r}") return cls( diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/__init__.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/__init__.py index 3b4b599d962..17eff6301bc 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/validation/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/__init__.py @@ -8,6 +8,16 @@ StepReport, ValidationReport, ) -from pylabrobot.agilent.biotek.lhc.protocols.validation.reservations import Reservations +from pylabrobot.agilent.biotek.lhc.protocols.validation.reservations import ( + Reservations, + check_head_fits, +) -__all__ = ["Rejection", "Reservations", "StepReport", "ValidationReport", "validate"] +__all__ = [ + "Rejection", + "Reservations", + "StepReport", + "ValidationReport", + "check_head_fits", + "validate", +] diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/configuration.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/configuration.py index 864b65023d4..7ed30c4e99a 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/validation/configuration.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/configuration.py @@ -23,10 +23,10 @@ from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import SyringeBottle from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import PlateRecord from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_auto_clean import ( ManifoldAutoClean, ) -from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_wash import ManifoldWash @@ -109,6 +109,49 @@ class Commitments: bottle_both: SyringeBottle | None = None +def available_step_types(settings: InstrumentSettings) -> list[StepType]: + """Which step types the fitted hardware can carry out at all. + + This is the hardware half of what a device offers: a step type whose hardware is not fitted can + never run, whatever plate is on the carrier or which firmware is installed. Everything that + depends on the plate stays with the checks above, and the firmware restriction belongs to the + model, so a device narrows this by its own palette and its own firmware variant. + + Args: + settings: What the instrument has fitted. + + Returns: + The step types, in the order they are numbered. + """ + washes = settings.washer_manifold is not WasherManifold.NOT_INSTALLED + syringes = ( + settings.syringe_box is not SyringeBoxType.NOT_INSTALLED + and settings.syringe_manifold is not SyringeManifold.NOT_INSTALLED + ) + strips = settings.strip_washer_manifold is not StripWasherManifold.NOT_INSTALLED + fitted: dict[StepType, bool] = { + StepType.PERI_DISPENSE: settings.peri_pump, + StepType.PERI_PRIME: settings.peri_pump, + StepType.PERI_PURGE: settings.peri_pump, + StepType.SYRINGE_DISPENSE: syringes, + StepType.SYRINGE_PRIME: syringes, + StepType.MANIFOLD_WASH: washes, + StepType.MANIFOLD_ASPIRATE: washes, + StepType.MANIFOLD_DISPENSE: washes, + StepType.MANIFOLD_PRIME: washes, + StepType.MANIFOLD_AUTO_CLEAN: washes and settings.ultrasonic, + StepType.SHAKE_SOAK: True, + StepType.WASH_1536: washes and syringes, + StepType.STRIP_WASH: strips, + StepType.STRIP_ASPIRATE: strips, + StepType.STRIP_DISPENSE: strips, + StepType.STRIP_PRIME: strips, + StepType.PERI_WASH_ASPIRATE: settings.peri_wash_enabled, + StepType.PERI_WASH_DISPENSE: settings.peri_wash_enabled, + } + return [step_type for step_type, available in fitted.items() if available] + + def check_configuration( step: Step, settings: InstrumentSettings, diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/protocol_pass.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/protocol_pass.py index 563d7391fd2..2e1c5599475 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/validation/protocol_pass.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/protocol_pass.py @@ -12,10 +12,13 @@ from __future__ import annotations -from pylabrobot.agilent.biotek.lhc.devices.build_rules import BASECODE_STEP_TYPES, BuildRules -from pylabrobot.agilent.biotek.lhc.devices.build_rules import basecode_for -from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode +from pylabrobot.agilent.biotek.lhc.devices.build_rules import ( + BASECODE_STEP_TYPES, + BuildRules, + basecode_for, +) from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType from pylabrobot.agilent.biotek.lhc.enums.plates.plate_restriction import PlateRestriction from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/reservations.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/reservations.py index ea9b7c3d1ed..a49a4e84368 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/validation/reservations.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/reservations.py @@ -198,14 +198,31 @@ def uses_random_access(step: Step) -> bool: def check_cassette_head( step: PeriRandomAccessDispense, plate: PlateRecord, absent: frozenset[int] +) -> Rejection | None: + """Check the dispense head a step names against the plate it would dispense into. + + Args: + step: The random-access dispense. + plate: The plate the protocol runs on. + absent: Rules this model does not have. + + Returns: + A rejection, or None. + """ + return check_head_fits(step.cassette_head, plate, absent) + + +def check_head_fits( + head: CassetteHead | None, plate: PlateRecord, absent: frozenset[int] ) -> Rejection | None: """Check a dispense head against the plate it would dispense into. A head that feeds several tubes at once cannot serve more than 24 wells, and a single-tube head - cannot serve more than 384. + cannot serve more than 384. Opening a batch checks the reserved head this way, having no step to + hand. Args: - step: The random-access dispense. + head: The head to check, or None for the single-tube head that is used when none is named. plate: The plate the protocol runs on. absent: Rules this model does not have. @@ -214,10 +231,10 @@ def check_cassette_head( """ if HEAD_PLATE_MISMATCH in absent: return None - head = step.cassette_head if step.cassette_head is not None else "1 tube to 1 well" - if head in _MULTI_TUBE_HEADS and plate.wells > 24: + fitted = head if head is not None else "1 tube to 1 well" + if fitted in _MULTI_TUBE_HEADS and plate.wells > 24: return Rejection(HEAD_PLATE_MISMATCH) - if head == "1 tube to 1 well" and plate.wells > 384: + if fitted == "1 tube to 1 well" and plate.wells > 384: return Rejection(HEAD_PLATE_MISMATCH) return None From fb86328f71a516d8726e882ea828bb417ec5613b Mon Sep 17 00:00:00 2001 From: StefanMa Date: Mon, 31 Aug 2026 16:51:49 +0200 Subject: [PATCH 06/19] extended and quickened testing --- .../agilent/biotek/lhc/devices/__init__.py | 15 +- .../agilent/biotek/lhc/devices/el406.py | 88 ++- .../agilent/biotek/lhc/devices/execution.py | 11 +- .../agilent/biotek/lhc/devices/multiflo.py | 88 ++- .../agilent/biotek/lhc/devices/multiflo_fx.py | 87 ++- .../agilent/biotek/lhc/devices/runtime.py | 4 + .../biotek/lhc/devices/settings_comparison.py | 146 +++++ .../biotek/lhc/devices/settings_query.py | 6 +- .../biotek/lhc/devices/washer_405ts.py | 88 ++- .../read_write_utilities/protocol_file.py | 31 +- .../serialization/commands/configuration.py | 22 +- .../agilent/biotek/lhc/tests/__init__.py | 1 + .../agilent/biotek/lhc/tests/corpus_tests.py | 220 ++++++++ .../biotek/lhc/tests/definition_tests.py | 241 ++++++++ .../agilent/biotek/lhc/tests/device_tests.py | 285 ++++++++++ .../biotek/lhc/tests/error_handling_tests.py | 132 +++++ .../biotek/lhc/tests/execution_tests.py | 261 +++++++++ .../biotek/lhc/tests/hardware_tests.py | 83 +++ .../agilent/biotek/lhc/tests/helpers.py | 278 +++++++++ .../agilent/biotek/lhc/tests/link_tests.py | 234 ++++++++ .../biotek/lhc/tests/payload_bytes_tests.py | 273 +++++++++ .../biotek/lhc/tests/plate_geometry_tests.py | 105 ++++ .../biotek/lhc/tests/protocol_file_tests.py | 153 +++++ .../biotek/lhc/tests/settings_tests.py | 222 ++++++++ .../biotek/lhc/tests/step_payload_tests.py | 189 +++++++ .../biotek/lhc/tests/test_data/payloads.tsv | 530 ++++++++++++++++++ .../405_TS_and_LS/001_W-CORNING_FLAT_96.LHC | Bin 0 -> 3568 bytes .../005_W-LUMINEX_MAG_FLAT_96.LHC | Bin 0 -> 3440 bytes .../405_TS_and_LS/W-LUMINEX_MAG_384.LHC | Bin 0 -> 3624 bytes .../W-LUMINEX_MAG_384_select.LHC | Bin 0 -> 3624 bytes .../405_TS_and_LS/W-LUMINEX_VAC_384.LHC | Bin 0 -> 3016 bytes .../405_TS_and_LS/_W-LONG_SHUTDOWN.LHC | Bin 0 -> 6064 bytes .../protocols/406_FX/_S-DECONTAMINATE_A.LHC | Bin 0 -> 4256 bytes .../50TS/07_LUMINEX_MAG_ROUND_96.LHC | Bin 0 -> 14304 bytes .../protocols/50TS/DECONTAMINATION.LHC | Bin 0 -> 16064 bytes .../test_data/protocols/50TS/VAC30_TEST.LHC | Bin 0 -> 4432 bytes .../protocols/EL406/003_P-1UL_CASS_RINSE.LHC | Bin 0 -> 2336 bytes .../protocols/EL406/020_SA-1536_DISP_TST.LHC | Bin 0 -> 2808 bytes .../protocols/EL406/S-DAY_RINSE_A&B.LHC | Bin 0 -> 2696 bytes .../protocols/EL406/W&P-96_CELL_WASH.LHC | Bin 0 -> 3000 bytes .../protocols/EL406/W-CLEAN_w-BUFFER.LHC | Bin 0 -> 3048 bytes .../protocols/ELx405/OVERNIGHT_LOOP.LHC | Bin 0 -> 2240 bytes .../MultiFlo/011_SA-1536_DISP_TST.LHC | Bin 0 -> 2816 bytes .../protocols/MultiFlo/P-10UL_CASS_RINSE.LHC | Bin 0 -> 2504 bytes .../Example HitList 96 Template.LHC | Bin 0 -> 3520 bytes .../MultiFloFX/P-10UL_RAD_CASS_RINSE.LHC | Bin 0 -> 4664 bytes .../protocols/MultiFloFX/P-384_DILUTION.LHC | Bin 0 -> 9272 bytes .../protocols/MultiFloFX/PW-CORNING_96.LHC | Bin 0 -> 12176 bytes .../protocols/MultiFloFX/QC__96_DISP_TEST.LHC | Bin 0 -> 3544 bytes .../protocols/MultiFloFX/QC__96_EVAC_TEST.LHC | Bin 0 -> 3744 bytes .../protocols/fixtures/W-CORNING_FLAT_96.LHC | Bin 0 -> 2680 bytes .../protocols/fixtures/multiflow_test.LHC | Bin 0 -> 4800 bytes .../biotek/lhc/tests/validation_tests.py | 323 +++++++++++ 53 files changed, 4092 insertions(+), 24 deletions(-) create mode 100644 pylabrobot/agilent/biotek/lhc/devices/settings_comparison.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/__init__.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/corpus_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/definition_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/device_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/error_handling_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/execution_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/hardware_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/helpers.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/link_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/payload_bytes_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/plate_geometry_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/protocol_file_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/settings_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/step_payload_tests.py create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/payloads.tsv create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/001_W-CORNING_FLAT_96.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/005_W-LUMINEX_MAG_FLAT_96.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_MAG_384.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_MAG_384_select.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_VAC_384.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/_W-LONG_SHUTDOWN.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/406_FX/_S-DECONTAMINATE_A.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/50TS/07_LUMINEX_MAG_ROUND_96.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/50TS/DECONTAMINATION.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/50TS/VAC30_TEST.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/003_P-1UL_CASS_RINSE.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/020_SA-1536_DISP_TST.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/S-DAY_RINSE_A&B.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/W&P-96_CELL_WASH.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/W-CLEAN_w-BUFFER.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/ELx405/OVERNIGHT_LOOP.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFlo/011_SA-1536_DISP_TST.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFlo/P-10UL_CASS_RINSE.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/Example HitList 96 Template.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/P-10UL_RAD_CASS_RINSE.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/P-384_DILUTION.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/PW-CORNING_96.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/QC__96_DISP_TEST.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/QC__96_EVAC_TEST.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/fixtures/W-CORNING_FLAT_96.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/fixtures/multiflow_test.LHC create mode 100644 pylabrobot/agilent/biotek/lhc/tests/validation_tests.py diff --git a/pylabrobot/agilent/biotek/lhc/devices/__init__.py b/pylabrobot/agilent/biotek/lhc/devices/__init__.py index e7e0666342b..e148e8e3194 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/devices/__init__.py @@ -18,5 +18,18 @@ from pylabrobot.agilent.biotek.lhc.devices.build_rules import BuildRules, basecode_for, rules_for from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.devices.settings_comparison import ( + Difference, + SettingsComparison, + compare, +) -__all__ = ["BuildRules", "InstrumentSettings", "basecode_for", "rules_for"] +__all__ = [ + "BuildRules", + "Difference", + "InstrumentSettings", + "SettingsComparison", + "basecode_for", + "compare", + "rules_for", +] diff --git a/pylabrobot/agilent/biotek/lhc/devices/el406.py b/pylabrobot/agilent/biotek/lhc/devices/el406.py index cf4b7ad9542..b352d19321d 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/el406.py +++ b/pylabrobot/agilent/biotek/lhc/devices/el406.py @@ -9,7 +9,7 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport from pylabrobot.agilent.biotek.lhc.devices import batch as batching -from pylabrobot.agilent.biotek.lhc.devices import execution, settings_query +from pylabrobot.agilent.biotek.lhc.devices import execution, settings_document, settings_query from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( PeristalticDispenser, @@ -18,7 +18,13 @@ from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.devices.settings_comparison import ( + SettingsComparison, + compare, +) from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.motion.motor import Motor +from pylabrobot.agilent.biotek.lhc.enums.motion.motor_home_type import MotorHomeType from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity @@ -31,7 +37,11 @@ from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak from pylabrobot.agilent.biotek.lhc.protocols.validation.configuration import available_step_types from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport -from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import RunSelfCheck +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import ( + HomeVerifyMotors, + ResetInstrument, + RunSelfCheck, +) from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( FirmwareVersion, GetFirmwareVersion, @@ -127,6 +137,20 @@ def settings(self) -> InstrumentSettings: """What the instrument reported as fitted when :meth:`setup` last ran.""" return self._runtime.settings + @property + def settle(self) -> float: + """How long to wait after the instrument accepts a step before polling it, in seconds. + + The first poll of a step that has only just started can still report the instrument idle, so + this is what stops a step being called finished before it began. It is paid once per step, so a + long protocol on a quick instrument is where lowering it is worth something. + """ + return self._runtime.settle + + @settle.setter + def settle(self, seconds: float) -> None: + self._runtime.settle = seconds + @property def plate(self) -> PlateRecord | None: """The plate the instrument is set to work, or None while none has been set.""" @@ -185,12 +209,43 @@ def get_available_steps(self) -> list[StepType]: """Which operations this instrument can carry out as it is fitted. Returns: - The step types, in the order they are numbered. A type this model is never built to run is + The step types, in the order this model offers them, which is the order its palette above + lists rather than the order they are numbered. A type this model is never built to run is absent whatever is fitted, and so is one whose hardware is missing. """ fitted = set(available_step_types(self._runtime.settings)) return [step_type for step_type in PALETTE if step_type in fitted] + def compare_settings(self, protocol: Protocol | InstrumentSettings) -> SettingsComparison: + """Compare the options a protocol was written for with the ones this instrument reports. + + Nothing calls this on its own: a protocol runs against the instrument as it actually is, and + whether it can run is what :meth:`can_run` answers. This is for the rarer question of whether a + protocol was written for a differently equipped machine, which is worth asking before running + one that came from elsewhere. + + Args: + protocol: The protocol, whose fitted-options document is read, or a settings record to + compare directly. + + Returns: + The comparison, truthy when the protocol was written for an instrument equipped like this + one, and printing as the list of options that differ. + + Raises: + ValueError: If the protocol carries no fitted-options document, which is how the oldest + releases wrote a file. There is nothing to compare against in that case. + """ + if isinstance(protocol, InstrumentSettings): + return compare(protocol, self._runtime.settings) + if not protocol.instrument_settings_xml.strip(): + raise ValueError( + f"{protocol.protocol_name or 'the protocol'} carries no fitted-options document, so there " + "is nothing to compare against what the instrument reports" + ) + declared = settings_document.from_xml(protocol.instrument_settings_xml) + return compare(declared, self._runtime.settings) + async def can_run(self, protocol: Protocol | list[Step]) -> ValidationReport: """Check whether a protocol can run on the instrument as it is. @@ -339,6 +394,33 @@ async def self_check(self) -> None: """ await self._runtime.link.request(RunSelfCheck(), operation="self check") + async def reset(self) -> None: + """Reset the instrument, and wait for it to come back. + + Returns it to the state it is in after power-on: motion stopped, motors dereferenced. What is + fitted does not change, so the record :meth:`setup` read still stands. + + Raises: + BiotekError: If the instrument does not come back. + """ + await self._runtime.link.request(ResetInstrument(), operation="reset") + + async def home(self, motor: Motor | None = None) -> None: + """Drive the transport to its home position and confirm it arrived. + + Args: + motor: One motor to home, or None to home the carrier and the heads together. A motor this + instrument does not have is refused by the instrument rather than here. + + Raises: + BiotekError: If a motor does not reach its home position. + """ + home_type = MotorHomeType.HOME_MOTOR if motor is not None else MotorHomeType.HOME_XYZ_MOTORS + await self._runtime.link.request( + HomeVerifyMotors(int(home_type), int(motor) if motor is not None else 0), + operation="home", + ) + async def abort(self) -> None: """Stop the running step. diff --git a/pylabrobot/agilent/biotek/lhc/devices/execution.py b/pylabrobot/agilent/biotek/lhc/devices/execution.py index b4c7f66b9a9..4a7d5a07dfd 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/execution.py +++ b/pylabrobot/agilent/biotek/lhc/devices/execution.py @@ -52,9 +52,6 @@ STEP_TIMEOUT = 3600.0 """How long to wait for a step to finish, in seconds. A wash with a long soak is minutes of it.""" -_SETTLE = 0.5 -"""How long to wait after a step is accepted before polling, in seconds.""" - _RUNNING = (RunState.BUSY, RunState.PAUSED) @@ -145,7 +142,7 @@ async def run_step( command = RunStep(command_for_step(step), plate_type, step.to_bytes(runtime.settings)) await runtime.link.request(command, operation=step.step_type.name) logger.info("running %s on %s", step.step_type.name, runtime.link.name) - await asyncio.sleep(_SETTLE) + await asyncio.sleep(runtime.settle) await _wait_for_step(runtime, step, timeout, interval) @@ -323,12 +320,14 @@ async def _optional_byte(runtime: Runtime, number: CommandNumber) -> int | None: number: Which query to send. Returns: - The byte, or None when the instrument would not answer. + The byte, or None when the instrument would not answer -- either refusing the query, or + acknowledging it and sending no value back, which older firmware does for a query it does + not implement. """ command = ByteQuery(number) try: return command.parse(await runtime.link.request(command, operation=number.name)) - except BiotekError as error: + except (BiotekError, ValueError) as error: logger.debug("%s does not answer %s: %s", runtime.link.name, number.name, error) return None diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py index fbab5a3633a..bd599034fb3 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py @@ -9,7 +9,7 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport from pylabrobot.agilent.biotek.lhc.devices import batch as batching -from pylabrobot.agilent.biotek.lhc.devices import execution, settings_query +from pylabrobot.agilent.biotek.lhc.devices import execution, settings_document, settings_query from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( PeristalticDispenser, @@ -17,7 +17,13 @@ from pylabrobot.agilent.biotek.lhc.devices.components.syringe_dispenser import SyringeDispenser from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.devices.settings_comparison import ( + SettingsComparison, + compare, +) from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.motion.motor import Motor +from pylabrobot.agilent.biotek.lhc.enums.motion.motor_home_type import MotorHomeType from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity @@ -30,7 +36,11 @@ from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak from pylabrobot.agilent.biotek.lhc.protocols.validation.configuration import available_step_types from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport -from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import RunSelfCheck +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import ( + HomeVerifyMotors, + ResetInstrument, + RunSelfCheck, +) from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( FirmwareVersion, GetFirmwareVersion, @@ -115,6 +125,20 @@ def settings(self) -> InstrumentSettings: """What the instrument reported as fitted when :meth:`setup` last ran.""" return self._runtime.settings + @property + def settle(self) -> float: + """How long to wait after the instrument accepts a step before polling it, in seconds. + + The first poll of a step that has only just started can still report the instrument idle, so + this is what stops a step being called finished before it began. It is paid once per step, so a + long protocol on a quick instrument is where lowering it is worth something. + """ + return self._runtime.settle + + @settle.setter + def settle(self, seconds: float) -> None: + self._runtime.settle = seconds + @property def plate(self) -> PlateRecord | None: """The plate the instrument is set to work, or None while none has been set.""" @@ -173,12 +197,43 @@ def get_available_steps(self) -> list[StepType]: """Which operations this instrument can carry out as it is fitted. Returns: - The step types, in the order they are numbered. A type this model is never built to run is + The step types, in the order this model offers them, which is the order its palette above + lists rather than the order they are numbered. A type this model is never built to run is absent whatever is fitted, and so is one whose hardware is missing. """ fitted = set(available_step_types(self._runtime.settings)) return [step_type for step_type in PALETTE if step_type in fitted] + def compare_settings(self, protocol: Protocol | InstrumentSettings) -> SettingsComparison: + """Compare the options a protocol was written for with the ones this instrument reports. + + Nothing calls this on its own: a protocol runs against the instrument as it actually is, and + whether it can run is what :meth:`can_run` answers. This is for the rarer question of whether a + protocol was written for a differently equipped machine, which is worth asking before running + one that came from elsewhere. + + Args: + protocol: The protocol, whose fitted-options document is read, or a settings record to + compare directly. + + Returns: + The comparison, truthy when the protocol was written for an instrument equipped like this + one, and printing as the list of options that differ. + + Raises: + ValueError: If the protocol carries no fitted-options document, which is how the oldest + releases wrote a file. There is nothing to compare against in that case. + """ + if isinstance(protocol, InstrumentSettings): + return compare(protocol, self._runtime.settings) + if not protocol.instrument_settings_xml.strip(): + raise ValueError( + f"{protocol.protocol_name or 'the protocol'} carries no fitted-options document, so there " + "is nothing to compare against what the instrument reports" + ) + declared = settings_document.from_xml(protocol.instrument_settings_xml) + return compare(declared, self._runtime.settings) + async def can_run(self, protocol: Protocol | list[Step]) -> ValidationReport: """Check whether a protocol can run on the instrument as it is. @@ -327,6 +382,33 @@ async def self_check(self) -> None: """ await self._runtime.link.request(RunSelfCheck(), operation="self check") + async def reset(self) -> None: + """Reset the instrument, and wait for it to come back. + + Returns it to the state it is in after power-on: motion stopped, motors dereferenced. What is + fitted does not change, so the record :meth:`setup` read still stands. + + Raises: + BiotekError: If the instrument does not come back. + """ + await self._runtime.link.request(ResetInstrument(), operation="reset") + + async def home(self, motor: Motor | None = None) -> None: + """Drive the transport to its home position and confirm it arrived. + + Args: + motor: One motor to home, or None to home the carrier and the heads together. A motor this + instrument does not have is refused by the instrument rather than here. + + Raises: + BiotekError: If a motor does not reach its home position. + """ + home_type = MotorHomeType.HOME_MOTOR if motor is not None else MotorHomeType.HOME_XYZ_MOTORS + await self._runtime.link.request( + HomeVerifyMotors(int(home_type), int(motor) if motor is not None else 0), + operation="home", + ) + async def abort(self) -> None: """Stop the running step. diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py index e41f8fa22ee..87889c8e7c2 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py @@ -9,7 +9,7 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport from pylabrobot.agilent.biotek.lhc.devices import batch as batching -from pylabrobot.agilent.biotek.lhc.devices import execution, settings_query +from pylabrobot.agilent.biotek.lhc.devices import execution, settings_document, settings_query from pylabrobot.agilent.biotek.lhc.devices.build_rules import ( BASECODE_STEP_TYPES, basecode_for, @@ -22,7 +22,13 @@ from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.devices.settings_comparison import ( + SettingsComparison, + compare, +) from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.motion.motor import Motor +from pylabrobot.agilent.biotek.lhc.enums.motion.motor_home_type import MotorHomeType from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity @@ -35,7 +41,11 @@ from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak from pylabrobot.agilent.biotek.lhc.protocols.validation.configuration import available_step_types from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport -from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import RunSelfCheck +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import ( + HomeVerifyMotors, + ResetInstrument, + RunSelfCheck, +) from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( FirmwareVersion, GetFirmwareVersion, @@ -134,6 +144,20 @@ def settings(self) -> InstrumentSettings: """What the instrument reported as fitted when :meth:`setup` last ran.""" return self._runtime.settings + @property + def settle(self) -> float: + """How long to wait after the instrument accepts a step before polling it, in seconds. + + The first poll of a step that has only just started can still report the instrument idle, so + this is what stops a step being called finished before it began. It is paid once per step, so a + long protocol on a quick instrument is where lowering it is worth something. + """ + return self._runtime.settle + + @settle.setter + def settle(self, seconds: float) -> None: + self._runtime.settle = seconds + @property def plate(self) -> PlateRecord | None: """The plate the instrument is set to work, or None while none has been set.""" @@ -196,13 +220,43 @@ def get_available_steps(self) -> list[StepType]: is fitted, and by what the firmware knows. Returns: - The step types, in the order they are numbered. + The step types, in the order this model offers them. """ settings = self._runtime.settings fitted = set(available_step_types(settings)) in_firmware = BASECODE_STEP_TYPES[basecode_for(settings)] return [step_type for step_type in PALETTE if step_type in fitted and step_type in in_firmware] + def compare_settings(self, protocol: Protocol | InstrumentSettings) -> SettingsComparison: + """Compare the options a protocol was written for with the ones this instrument reports. + + Nothing calls this on its own: a protocol runs against the instrument as it actually is, and + whether it can run is what :meth:`can_run` answers. This is for the rarer question of whether a + protocol was written for a differently equipped machine, which is worth asking before running + one that came from elsewhere. + + Args: + protocol: The protocol, whose fitted-options document is read, or a settings record to + compare directly. + + Returns: + The comparison, truthy when the protocol was written for an instrument equipped like this + one, and printing as the list of options that differ. + + Raises: + ValueError: If the protocol carries no fitted-options document, which is how the oldest + releases wrote a file. There is nothing to compare against in that case. + """ + if isinstance(protocol, InstrumentSettings): + return compare(protocol, self._runtime.settings) + if not protocol.instrument_settings_xml.strip(): + raise ValueError( + f"{protocol.protocol_name or 'the protocol'} carries no fitted-options document, so there " + "is nothing to compare against what the instrument reports" + ) + declared = settings_document.from_xml(protocol.instrument_settings_xml) + return compare(declared, self._runtime.settings) + async def can_run(self, protocol: Protocol | list[Step]) -> ValidationReport: """Check whether a protocol can run on the instrument as it is. @@ -351,6 +405,33 @@ async def self_check(self) -> None: """ await self._runtime.link.request(RunSelfCheck(), operation="self check") + async def reset(self) -> None: + """Reset the instrument, and wait for it to come back. + + Returns it to the state it is in after power-on: motion stopped, motors dereferenced. What is + fitted does not change, so the record :meth:`setup` read still stands. + + Raises: + BiotekError: If the instrument does not come back. + """ + await self._runtime.link.request(ResetInstrument(), operation="reset") + + async def home(self, motor: Motor | None = None) -> None: + """Drive the transport to its home position and confirm it arrived. + + Args: + motor: One motor to home, or None to home the carrier and the heads together. A motor this + instrument does not have is refused by the instrument rather than here. + + Raises: + BiotekError: If a motor does not reach its home position. + """ + home_type = MotorHomeType.HOME_MOTOR if motor is not None else MotorHomeType.HOME_XYZ_MOTORS + await self._runtime.link.request( + HomeVerifyMotors(int(home_type), int(motor) if motor is not None else 0), + operation="home", + ) + async def abort(self) -> None: """Stop the running step. diff --git a/pylabrobot/agilent/biotek/lhc/devices/runtime.py b/pylabrobot/agilent/biotek/lhc/devices/runtime.py index 2f783356d61..9faa646621f 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/runtime.py +++ b/pylabrobot/agilent/biotek/lhc/devices/runtime.py @@ -44,6 +44,9 @@ class Runtime: makes the hardware match. Empty until a pass has run. plate_restriction: Which plates the instrument accepts, once it has been asked. carrier_type: Which carrier is fitted, once the instrument has been asked. + settle: How long to wait after the instrument accepts a step before asking whether it has + finished, in seconds. The first poll of a step that has only just started can still report the + instrument idle, so this is what stops a step being called finished before it began. in_batch: Whether a batch is open, which is what makes the batch context re-entrant. port: Held for as long as a batch is open, so two callers cannot interleave runs on one instrument. @@ -58,6 +61,7 @@ class Runtime: reservations: Reservations = field(default_factory=Reservations) plate_restriction: PlateRestriction | None = None carrier_type: CarrierType | None = None + settle: float = 0.5 in_batch: bool = False port: asyncio.Lock = field(default_factory=asyncio.Lock) diff --git a/pylabrobot/agilent/biotek/lhc/devices/settings_comparison.py b/pylabrobot/agilent/biotek/lhc/devices/settings_comparison.py new file mode 100644 index 00000000000..32c2873deca --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/settings_comparison.py @@ -0,0 +1,146 @@ +"""Measuring what a protocol says the instrument is against what the instrument says it is. + +A protocol file carries the options the instrument had fitted when the protocol was written. Nothing +in this package runs against that record -- steps are encoded, and protocols checked, against what +the instrument reports now -- so its one use is telling a caller that the two disagree, which is +worth knowing before running a protocol written for another machine. + +Only the options a protocol file actually stores are compared. Two fields of +:class:`~.instrument_settings.InstrumentSettings` are not in the document at all -- how many bottles +the syringe box holds, and whether the dispensers take the wider offset range -- so comparing them +would report a difference against a default that was never declared. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings + + +def _fitted(present: bool) -> str: + """Describe an option that is either fitted or not. + + Args: + present: Whether it is fitted. + + Returns: + The description. + """ + return "fitted" if present else "not fitted" + + +def _enabled(on: bool) -> str: + """Describe an option that is either switched on or not. + + Args: + on: Whether it is on. + + Returns: + The description. + """ + return "enabled" if on else "not enabled" + + +OPTIONS: tuple[tuple[str, Callable[[InstrumentSettings], str]], ...] = ( + ("instrument model", lambda settings: settings.family.name), + ("wash manifold", lambda settings: settings.washer_manifold.name), + ("syringe box", lambda settings: settings.syringe_box.name), + ("syringe manifold", lambda settings: settings.syringe_manifold.name), + ("buffer switching module", lambda settings: _fitted(settings.buffer_switching)), + ("valve box", lambda settings: settings.valve_box.name), + ("vacuum filtration", lambda settings: _fitted(settings.vacuum_filtration)), + ("primary peristaltic pump", lambda settings: _fitted(settings.peri_pump)), + ("secondary peristaltic pump", lambda settings: _fitted(settings.peri_pump_2)), + ("ultrasonic module", lambda settings: _fitted(settings.ultrasonic)), + ("cell washing module", lambda settings: _fitted(settings.cell_washing)), + ("Y axis", lambda settings: _fitted(settings.y_axis_installed)), + ("half microlitre volumes", lambda settings: _enabled(settings.half_ul_enabled)), + ("strip wash manifold", lambda settings: settings.strip_washer_manifold.name), + ("single-well dispensing", lambda settings: _enabled(settings.single_well_enabled)), + ("peristaltic washing", lambda settings: _enabled(settings.peri_wash_enabled)), +) +"""Every option a protocol file declares, and how to read it out of a settings record. + +In the order the document stores them, so a comparison reads in the same order as the file. +""" + + +@dataclass(frozen=True) +class Difference: + """One option a protocol declares differently from what the instrument reports. + + Attributes: + option: Which option this is. + declared: What the protocol says it is. + actual: What the instrument says it is. + """ + + option: str + declared: str + actual: str + + def __str__(self) -> str: + """The difference as one line. + + Returns: + The option, what was declared, and what is fitted. + """ + return f"{self.option}: protocol says {self.declared}, instrument reports {self.actual}" + + +@dataclass +class SettingsComparison: + """How a protocol's declared options stand against the instrument's own. + + Truthy when they agree, so it reads as an answer to the question. Printing it lists only what + differs. + + A difference is not on its own a reason not to run: whether a protocol can run is what + :func:`~..protocols.validation.protocol_pass.validate` answers, and it measures every step + against the instrument as it actually is. This says only that the protocol was written for a + differently equipped machine. + + Attributes: + differences: The options that do not match, in the order a protocol file stores them. + """ + + differences: list[Difference] = field(default_factory=list) + + def __bool__(self) -> bool: + """Whether the protocol was written for an instrument equipped like this one.""" + return not self.differences + + def __str__(self) -> str: + """The comparison as text. + + Returns: + One line saying whether the two agree, followed by a line per option that does not. + """ + if not self.differences: + return "the protocol was written for an instrument equipped like this one" + lines = [ + f"the protocol was written for a differently equipped instrument, in {len(self.differences)} respects" + ] + lines += [f" {difference}" for difference in self.differences] + return "\n".join(lines) + + +def compare(declared: InstrumentSettings, actual: InstrumentSettings) -> SettingsComparison: + """Compare the options a protocol declares with the ones an instrument reports. + + Args: + declared: What the protocol was written for. + actual: What the instrument has fitted. + + Returns: + The comparison, truthy when the two agree. + """ + return SettingsComparison( + [ + Difference(option, read(declared), read(actual)) + for option, read in OPTIONS + if read(declared) != read(actual) + ] + ) diff --git a/pylabrobot/agilent/biotek/lhc/devices/settings_query.py b/pylabrobot/agilent/biotek/lhc/devices/settings_query.py index 5c74723a89d..f85456b8422 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/settings_query.py +++ b/pylabrobot/agilent/biotek/lhc/devices/settings_query.py @@ -273,11 +273,13 @@ async def _optional_byte(link: Link, number: CommandNumber) -> int | None: number: Which option to read. Returns: - The byte, or None when the instrument would not answer. + The byte, or None when the instrument would not answer -- either refusing the query, or + acknowledging it and sending no value back, which older firmware does for a query it does + not implement. """ try: return await _byte(link, number) - except BiotekError as error: + except (BiotekError, ValueError) as error: logger.debug("%s does not answer %s: %s", link.name, number.name, error) return None diff --git a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py index 9d3ae45a1bd..0b6f6cd7e73 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py +++ b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py @@ -9,12 +9,18 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport from pylabrobot.agilent.biotek.lhc.devices import batch as batching -from pylabrobot.agilent.biotek.lhc.devices import execution, settings_query +from pylabrobot.agilent.biotek.lhc.devices import execution, settings_document, settings_query from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.devices.settings_comparison import ( + SettingsComparison, + compare, +) from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.motion.motor import Motor +from pylabrobot.agilent.biotek.lhc.enums.motion.motor_home_type import MotorHomeType from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity @@ -27,7 +33,11 @@ from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak from pylabrobot.agilent.biotek.lhc.protocols.validation.configuration import available_step_types from pylabrobot.agilent.biotek.lhc.protocols.validation.report import ValidationReport -from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import RunSelfCheck +from pylabrobot.agilent.biotek.lhc.serialization.commands.diagnostics import ( + HomeVerifyMotors, + ResetInstrument, + RunSelfCheck, +) from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( FirmwareVersion, GetFirmwareVersion, @@ -105,6 +115,20 @@ def settings(self) -> InstrumentSettings: """What the instrument reported as fitted when :meth:`setup` last ran.""" return self._runtime.settings + @property + def settle(self) -> float: + """How long to wait after the instrument accepts a step before polling it, in seconds. + + The first poll of a step that has only just started can still report the instrument idle, so + this is what stops a step being called finished before it began. It is paid once per step, so a + long protocol on a quick instrument is where lowering it is worth something. + """ + return self._runtime.settle + + @settle.setter + def settle(self, seconds: float) -> None: + self._runtime.settle = seconds + @property def plate(self) -> PlateRecord | None: """The plate the instrument is set to work, or None while none has been set.""" @@ -163,12 +187,43 @@ def get_available_steps(self) -> list[StepType]: """Which operations this instrument can carry out as it is fitted. Returns: - The step types, in the order they are numbered. A type this model is never built to run is + The step types, in the order this model offers them, which is the order its palette above + lists rather than the order they are numbered. A type this model is never built to run is absent whatever is fitted, and so is one whose hardware is missing. """ fitted = set(available_step_types(self._runtime.settings)) return [step_type for step_type in PALETTE if step_type in fitted] + def compare_settings(self, protocol: Protocol | InstrumentSettings) -> SettingsComparison: + """Compare the options a protocol was written for with the ones this instrument reports. + + Nothing calls this on its own: a protocol runs against the instrument as it actually is, and + whether it can run is what :meth:`can_run` answers. This is for the rarer question of whether a + protocol was written for a differently equipped machine, which is worth asking before running + one that came from elsewhere. + + Args: + protocol: The protocol, whose fitted-options document is read, or a settings record to + compare directly. + + Returns: + The comparison, truthy when the protocol was written for an instrument equipped like this + one, and printing as the list of options that differ. + + Raises: + ValueError: If the protocol carries no fitted-options document, which is how the oldest + releases wrote a file. There is nothing to compare against in that case. + """ + if isinstance(protocol, InstrumentSettings): + return compare(protocol, self._runtime.settings) + if not protocol.instrument_settings_xml.strip(): + raise ValueError( + f"{protocol.protocol_name or 'the protocol'} carries no fitted-options document, so there " + "is nothing to compare against what the instrument reports" + ) + declared = settings_document.from_xml(protocol.instrument_settings_xml) + return compare(declared, self._runtime.settings) + async def can_run(self, protocol: Protocol | list[Step]) -> ValidationReport: """Check whether a protocol can run on the instrument as it is. @@ -317,6 +372,33 @@ async def self_check(self) -> None: """ await self._runtime.link.request(RunSelfCheck(), operation="self check") + async def reset(self) -> None: + """Reset the instrument, and wait for it to come back. + + Returns it to the state it is in after power-on: motion stopped, motors dereferenced. What is + fitted does not change, so the record :meth:`setup` read still stands. + + Raises: + BiotekError: If the instrument does not come back. + """ + await self._runtime.link.request(ResetInstrument(), operation="reset") + + async def home(self, motor: Motor | None = None) -> None: + """Drive the transport to its home position and confirm it arrived. + + Args: + motor: One motor to home, or None to home the carrier and the heads together. A motor this + instrument does not have is refused by the instrument rather than here. + + Raises: + BiotekError: If a motor does not reach its home position. + """ + home_type = MotorHomeType.HOME_MOTOR if motor is not None else MotorHomeType.HOME_XYZ_MOTORS + await self._runtime.link.request( + HomeVerifyMotors(int(home_type), int(motor) if motor is not None else 0), + operation="home", + ) + async def abort(self) -> None: """Stop the running step. diff --git a/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/protocol_file.py b/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/protocol_file.py index befe78ff8fd..430bf66bb46 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/protocol_file.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/read_write_utilities/protocol_file.py @@ -89,8 +89,19 @@ def from_xml(document: str) -> Protocol: raise ValueError(f"not a protocol document: {error}") from error def text(tag: str, default: str = "") -> str: + """One element's text, with the line endings a protocol file stores. + + Args: + tag: Which element to read. + default: What an absent or empty element means. + + Returns: + The text. + """ element = root.find(tag) - return default if element is None or element.text is None else element.text + if element is None or element.text is None: + return default + return _with_carriage_returns(element.text) protocol = Protocol( instrument_settings_xml=text("InstrumentSettingsXML"), @@ -124,6 +135,24 @@ def text(tag: str, default: str = "") -> str: return protocol +def _with_carriage_returns(document: str) -> str: + """Put back the line endings reading an element's text takes out. + + A protocol file ends every line with a carriage return, including inside the elements whose text + runs to several lines -- the fitted-options document it carries as escaped text, the comments, and + the prose an older release stores its options as. Reading an element's text normalises those away, + so a file read and written again would differ from the one it came from. Writing is not the place + to fix it: by then it is not known whether the line endings were ever there. + + Args: + document: The text as reading it produced. + + Returns: + The text with carriage returns restored. + """ + return document.replace("\r\n", "\n").replace("\n", "\r\n") + + def to_xml(protocol: Protocol) -> str: """Write a protocol as the document a protocol file holds. diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py index 5107781f7a8..260a5311668 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py @@ -31,7 +31,14 @@ def parse(self, answer: bytes) -> int: Returns: The byte. + + Raises: + ValueError: If the reply carried no answer. An instrument that acknowledges a query it does + not implement answers with its status and nothing else, so this is how "it would not say" + reaches a caller that can carry on without knowing. """ + if not answer: + raise ValueError(f"command {self.number} was answered with no value") return answer[0] @@ -46,8 +53,11 @@ def parse_flag(self, answer: bytes) -> bool: Returns: Whether the byte is set. + + Raises: + ValueError: If the reply carried no answer. """ - return bool(answer[0]) + return bool(self.parse(answer)) class SelectorQuery(Command): @@ -70,7 +80,12 @@ def parse(self, answer: bytes) -> int: Returns: The byte. + + Raises: + ValueError: If the reply carried no answer. """ + if not answer: + raise ValueError(f"command {self.number} was answered with no value") return answer[0] def parse_flag(self, answer: bytes) -> bool: @@ -81,8 +96,11 @@ def parse_flag(self, answer: bytes) -> bool: Returns: Whether the byte is set. + + Raises: + ValueError: If the reply carried no answer. """ - return bool(answer[0]) + return bool(self.parse(answer)) class ByteWrite(Command): diff --git a/pylabrobot/agilent/biotek/lhc/tests/__init__.py b/pylabrobot/agilent/biotek/lhc/tests/__init__.py new file mode 100644 index 00000000000..78193162ee8 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the BioTek washer/dispenser family driver.""" diff --git a/pylabrobot/agilent/biotek/lhc/tests/corpus_tests.py b/pylabrobot/agilent/biotek/lhc/tests/corpus_tests.py new file mode 100644 index 00000000000..67bd3c1cb6d --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/corpus_tests.py @@ -0,0 +1,220 @@ +"""Reading the protocol files in ``test_data``. + +Nothing exercises the readers like protocols a real installation ships: they carry every format +version this package reads, every instrument in the family, and step layouts no hand-written +definition would think of. The tree next to this file is a covering subset of one such installation +-- twenty-six files spanning eight instruments, every step-type layout, both older format markers +and every kind of entry a protocol can hold. + +Two instruments in it are deliberately unreadable. Their step layouts are not this package's, and a +protocol written for one of them has to be refused rather than read into the wrong fields. + +Set ``LHC_PROTOCOL_CORPUS`` to sweep a larger tree as well as this one. +""" + +from __future__ import annotations + +import importlib.util +import os +import re +from pathlib import Path + +import pytest + +from pylabrobot.agilent.biotek.lhc.devices import settings_document +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol +from pylabrobot.agilent.biotek.lhc.protocols.read_write_utilities import encryption, protocol_file + +PROTOCOLS = Path(__file__).parent / "test_data" / "protocols" +"""The covering subset that ships with these tests.""" + +EXTRA = os.environ.get("LHC_PROTOCOL_CORPUS", "") +"""A larger tree to sweep as well, for anyone who has one.""" + +UNREADABLE_INSTRUMENTS = ("50TS", "ELx405") +"""The instruments this package has no representation for, by the folder they are in.""" + +HAS_CIPHER = importlib.util.find_spec("Crypto") is not None +"""Whether the cipher a protocol file is stored under is installed.""" + +pytestmark = pytest.mark.skipif( + not HAS_CIPHER, + reason="reading a protocol file needs pycryptodome, which is an optional dependency", +) + + +def files() -> list[Path]: + """Every protocol file to sweep. + + Returns: + The paths, sorted so a failure names the same file every run. + """ + found = sorted(PROTOCOLS.rglob("*.LHC")) + if EXTRA and Path(EXTRA).is_dir(): + found += sorted(Path(EXTRA).rglob("*.LHC")) + return found + + +def readable() -> list[Path]: + """The files written for an instrument this package works. + + Returns: + Those paths. + """ + return [ + path + for path in files() + if not any(instrument in str(path) for instrument in UNREADABLE_INSTRUMENTS) + ] + + +def unreadable() -> list[Path]: + """The files written for an instrument this package has no representation for. + + Returns: + Those paths. + """ + return [ + path + for path in files() + if any(instrument in str(path) for instrument in UNREADABLE_INSTRUMENTS) + ] + + +def test_the_tree_is_there_and_covers_the_family(): + """A file per instrument, so a change that breaks one instrument cannot pass unnoticed.""" + folders = {path.parent.name for path in files()} + assert len(files()) >= 26 + assert {"EL406", "MultiFlo", "MultiFloFX", "405_TS_and_LS", "50TS", "ELx405"} <= folders + + +@pytest.mark.parametrize("path", files(), ids=lambda path: path.name) +def test_every_file_decrypts_and_parses(path: Path): + """Whether its steps can be read or not, a file is readable, writable and inspectable.""" + protocol = protocol_file.read(path) + assert protocol.entries + assert protocol.protocol_name or protocol.instrument_name + + +@pytest.mark.parametrize("path", files(), ids=lambda path: path.name) +def test_the_entries_survive_being_written_again(path: Path): + """The entries that sequence a run -- delays, loops, remarks -- are what a file stores rather + than something this package models, so writing must not drop them.""" + protocol = protocol_file.read(path) + again = protocol_file.from_xml(protocol_file.to_xml(protocol)) + assert again.entries == protocol.entries + + +@pytest.mark.parametrize("path", readable(), ids=lambda path: path.name) +def test_every_step_of_a_supported_instruments_protocol_reads(path: Path): + protocol = protocol_file.read(path) + assert protocol.build_steps() or not protocol.device_entries + + +@pytest.mark.parametrize("path", unreadable(), ids=lambda path: path.name) +def test_an_instrument_without_a_representation_is_refused(path: Path): + """Reading one of these would put values in the wrong fields, so it has to fail.""" + protocol = protocol_file.read(path) + with pytest.raises(ValueError): + protocol.build_steps() + + +@pytest.mark.parametrize("path", readable(), ids=lambda path: path.name) +def test_every_step_that_reads_encodes_and_writes_itself_back(path: Path): + """The two conversions a run needs: to the wire, and back to the text a file stores.""" + protocol = protocol_file.read(path) + settings = _declared_settings(protocol) + for step in protocol.build_steps(): + assert isinstance(step.to_bytes(settings), bytes) + assert step.to_definition() + + +def _canonical(field: str) -> str: + """One field of a definition, as it is written rather than as some release wrote it. + + Two fields are deliberately not carried through unchanged. A shake intensity is stored as free + text and several releases abbreviate it, so it is written back in full. And a sub-step's own type + field is not read at all -- a wash reads its sub-steps by position -- so a file that names the + wrong type there is loaded anyway and written back with the right one. + + Args: + field: The field as the file carried it. + + Returns: + The field with those two normalisations applied, so what is left to compare is the values. + """ + return field.split(" (")[0] + + +@pytest.mark.parametrize("path", readable(), ids=lambda path: path.name) +def test_a_step_read_and_written_keeps_every_field_the_file_carried(path: Path): + """Filling in what an older release did not write must not move a field that is there.""" + protocol = protocol_file.read(path) + for entry, step in zip(protocol.device_entries, protocol.build_steps()): + parts = zip(entry.definition.split("#"), step.to_definition().split("#")) + for index, (original, written) in enumerate(parts): + carried = [field for field in original.split("|") if field] + wrote = [field for field in written.split("|") if field] + start = 1 if carried and carried[0].startswith("DV") else 0 + # A sub-step's own type field is ignored on the way in, so it is not compared on the way out. + first = 1 if index else 0 + assert [_canonical(field) for field in wrote[1 + first : len(carried) - start + 1]] == [ + _canonical(field) for field in carried[start + first :] + ] + + +def _declared_settings(protocol: Protocol) -> InstrumentSettings: + """What the protocol says the instrument was, or a default when it says nothing readable. + + Args: + protocol: The protocol to read. + + Returns: + The settings to encode against. + """ + document = protocol.instrument_settings_xml.strip() + if not document: + return InstrumentSettings() + try: + return settings_document.from_xml(document) + except ValueError: + return InstrumentSettings() + + +SAVED_BY = re.compile(r"[ ]*<(AuditTrail|ArchiveRevision)\b.*?(?:|/>)\r?\n?", re.S) +"""The records of who saved a protocol, from where and when. + +They are read but never written: inventing one would fabricate the record of who touched a protocol, +so a file this package writes carries none. +""" + +NOT_MODELLED = re.compile( + r"[ ]*<(BioStackUseLids|BioStackLidDefinition|BioStackLidName|PlateHeightOverride)\b" + r".*?(?:|/>)\r?\n?", + re.S, +) +"""Elements a later release added that a protocol here has no field for, so they are not written.""" + + +@pytest.mark.parametrize("path", files(), ids=lambda path: path.name) +def test_a_file_is_written_back_exactly_as_it_came(path: Path): + """The document has to match what wrote it, down to the element order, the empty-element form and + the carriage returns, because the instrument's own software has to be able to read it again. + + Two things do not survive, both on purpose and both named above: the records of who saved the + protocol, and the elements a later release added that nothing here models. + """ + document = encryption.decrypt(path.read_bytes()).strip().lstrip("\ufeff") + again = protocol_file.to_xml(protocol_file.from_xml(document)) + assert NOT_MODELLED.sub("", SAVED_BY.sub("", document)) == again + + +@pytest.mark.parametrize("path", files(), ids=lambda path: path.name) +def test_the_multi_line_elements_keep_their_carriage_returns(path: Path): + """Reading an element's text normalises line endings away, and a file stores them, so what is + read has to put them back -- the fitted-options document, the comments and the older prose form of + the options all run to several lines.""" + protocol = protocol_file.read(path) + for text in (protocol.instrument_settings_xml, protocol.comments, protocol.instrument_settings): + assert "\n" not in text.replace("\r\n", ""), path diff --git a/pylabrobot/agilent/biotek/lhc/tests/definition_tests.py b/pylabrobot/agilent/biotek/lhc/tests/definition_tests.py new file mode 100644 index 00000000000..84aad878387 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/definition_tests.py @@ -0,0 +1,241 @@ +"""Reading a step back out of the text a protocol file stores it as. + +Every definition here is one a protocol file really carries, or one this package writes itself. The +framing they all share is what is tested: locating the step-type field, the optional format marker, +the optional tails, and what a definition that is malformed rather than merely invalid does. +""" + +from __future__ import annotations + +import pytest + +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.steps import definition, step_from_definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Submerge +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps import STEP_CLASSES +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_wash import ManifoldWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_random_access_dispense import ( + PeriRandomAccessDispense, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_prime import SyringePrime + +ALL_COLUMNS = "1" * 48 +"""A column selection with every column selected, as a definition spells it.""" + +WASH = ( + "6|Plate|15|3|False|False|False|True|False" + "#8|A|250|7|120|0|0|False|50|9|False|10" + "#8|False|6 CW|0|50|0|0|None|30|0|0|5.0" + "#8|A|500|1|115|-45|0|False|50|9|True|50" + "#11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30" + "#8|False|6 CW|0|50|0|0|None|30|0|0|5.0" +) +"""A wash from a protocol file, whose two aspirate sub-steps both claim to be dispenses.""" + + +def test_the_definition_says_which_step_it_is(): + step = step_from_definition("DV103|9|A|40|9|True|5|True|04:00") + assert isinstance(step, ManifoldPrime) + assert step.step_type is StepType.MANIFOLD_PRIME + + +def test_every_field_lands_where_the_writer_puts_it(): + step = ManifoldPrime.from_definition("DV103|9|C|95|9|True|50|True|00:20") + assert step == ManifoldPrime( + buffer="C", + volume=95_000, + flow_rate=9, + prime_low_flow_path=True, + low_flow_path_volume=50_000, + submerge=Submerge(enabled=True, duration=1200), + ) + + +def test_volumes_are_microlitres_stored_at_millilitre_resolution(): + """A prime's volume is µL in the API and millilitres in the file, so only whole millilitres + survive a round trip -- which is what the instrument runs anyway.""" + assert ManifoldPrime.from_definition("DV103|9|A|95|9|True|50|False|00:05").volume == 95_000 + assert ManifoldPrime(volume=95_400).to_definition().split("|")[3] == "95" + + +@pytest.mark.parametrize( + "text", + [ + "DV103|9|A|300|9|True|200|True|00:20", + "DV101|9|A|300|9|True|200|True|00:20", + "9|A|300|9|True|200|True|00:20", + ], +) +def test_the_format_marker_is_optional_and_is_not_kept(text: str): + """All three shapes are in the protocol files. Writing a step back always writes the current + marker, so the version a definition arrived with is not carried.""" + step = ManifoldPrime.from_definition(text) + assert step.volume == 300_000 + assert step.to_definition() == "DV103|9|A|300|9|True|200|True|00:20" + + +def test_a_newer_marker_is_refused_whole(): + with pytest.raises(ValueError, match="newer than DV103"): + ManifoldPrime.from_definition("DV104|9|A|40|5|True|5|True|04:00") + + +def test_fields_reports_where_the_step_type_sits(): + assert definition.fields("DV103|9|A") == (["DV103", "9", "A"], 1) + assert definition.fields("9|A") == (["9", "A"], 0) + + +@pytest.mark.parametrize( + "found, older", + [ + (["DV103", "9"], False), + (["DV101", "9"], True), + (["9"], True), + ], +) +def test_only_a_definition_older_than_the_current_format_may_be_short( + found: list[str], older: bool +): + assert definition.is_older_format(found) is older + + +def test_a_sub_steps_own_type_field_is_not_read(): + """A composite's sub-steps are read by position, and have to be: both aspirates in this wash say + they are dispenses, and the file is a valid one. Only the outermost type field chooses a class.""" + wash = step_from_definition(WASH) + assert isinstance(wash, ManifoldWash) + assert wash.aspirate.step_type is StepType.MANIFOLD_ASPIRATE + assert wash.aspirate.travel_rate == "6 CW" + assert wash.aspirate.in_wash + + +def test_every_step_type_reads_back_as_its_own_class(): + for step_type, cls in STEP_CLASSES.items(): + text = cls().to_definition() + assert type(step_from_definition(text)) is cls, step_type.name + + +# --- the optional tails: for several step types the field count is itself information. + + +def test_a_random_access_tail_makes_it_a_different_step(): + """Three more fields -- the flag, the head and the per-well volumes -- are a dispense into + individually chosen wells, which runs as a different command.""" + plain = step_from_definition(f"DV103|1|2|High|1|333|0|0|True|10|4|{ALL_COLUMNS}|1111|1") + assert type(plain) is PeriDispense + + with_tail = step_from_definition( + f"DV103|1|2|High|1|333|0|0|True|10|4|{ALL_COLUMNS}|1111|1|True|3|" + "02" * 48 + ) + assert type(with_tail) is PeriRandomAccessDispense + assert with_tail.cassette_head == "1 tube to 1 well" + assert with_tail.well_volumes.values[0] == [2, 2, 2] + + +def test_a_row_selection_is_what_the_field_count_means(): + """Only the instruments that select rows store one, so the count records whether the instrument + that wrote the definition did.""" + without = SyringeDispense.from_definition(f"DV103|4|1|50|2|336|0|0|True|50|2|0|{ALL_COLUMNS}|1") + assert not without.selects_rows + + with_rows = SyringeDispense.from_definition( + f"DV103|4|1|50|2|336|0|0|True|50|2|0|{ALL_COLUMNS}|1|1111" + ) + assert with_rows.selects_rows + assert with_rows.rows.to_definition() == "1111" + + +def test_a_step_that_stands_alone_keeps_its_column_selection(): + """A wash selects wells itself, so the aspirate it owns stores none -- and the count is what + carries that across a save and a load.""" + standalone = ManifoldAspirate.from_definition( + f"DV103|7|False|4|0|32|-50|8|None|29|0|0|0|{ALL_COLUMNS}" + ) + assert not standalone.in_wash + assert standalone.columns.to_definition() == ALL_COLUMNS + + in_wash = ManifoldAspirate.from_definition("DV103|7|False|4|0|32|-50|8|None|29|0|0|0") + assert in_wash.in_wash + assert in_wash.columns.to_definition() == ALL_COLUMNS + + +# --- filling in what an older release did not write. + + +def _tail_is_all_that_was_added(text: str, written: str) -> bool: + """Whether writing a step back reproduces every field the definition it came from carried. + + Args: + text: The definition that was read. + written: The definition the step wrote. + + Returns: + Whether the fields present line up, which is what makes filling a tail safe. + """ + original = [part for part in text.split("|") if part] + start = 1 if original[0].startswith("DV") else 0 + return written.split("|")[1 : len(original) - start + 1] == original[start:] + + +def test_an_older_syringe_prime_is_filled_from_the_defaults(): + """An instrument with one syringe box writes no submerge pair and no bottle, so the three fields + the current layout ends with are absent.""" + text = "5|1|8000|5|5|0|True" + step = SyringePrime.from_definition(text) + assert (step.volume, step.flow_rate, step.cycles) == (8000, 5, 5) + assert step.syringe == "A" + assert step.syringe_bottle == SyringePrime().syringe_bottle + assert _tail_is_all_that_was_added(text, step.to_definition()) + + +def test_an_older_peristaltic_dispense_is_filled_from_the_defaults(): + """An instrument with a single peristaltic pump writes no row selection and no pump selector.""" + text = f"DV101|1|6|Low|1|254|0|0|True|6|2|{ALL_COLUMNS}" + step = PeriDispense.from_definition(text) + assert step.volume == 6 + assert step.flow_rate == "Low" + assert step.peri_pump == PeriDispense().peri_pump + assert step.rows.to_definition() == PeriDispense().rows.to_definition() + assert _tail_is_all_that_was_added(text, step.to_definition()) + + +def test_a_short_definition_of_the_current_format_is_another_products(): + """Six fields under the current marker is a different model's layout, which is short in the + middle rather than at the end, so filling a tail would read every later field as the wrong one.""" + with pytest.raises(ValueError, match="expects 8 fields, got 6"): + ManifoldPrime.from_definition("DV103|9|A|60|5|True|00:05") + + +def test_a_definition_at_full_length_is_untouched(): + full = "DV103|5|1|5000|5|5|0|True|False|00:05|1" + assert SyringePrime.from_definition(full).to_definition() == full + + +# --- what makes a definition unreadable rather than merely invalid. + + +@pytest.mark.parametrize( + "text, problem", + [ + ("DV103|9|A|40|5|True|5|True|04:00|1", "expects 8 fields"), + ("DV103|9|A|70000|5|True|5|True|04:00", "does not fit in 16"), + ("DV103|9|A|40|500|True|5|True|04:00", "does not fit in 8"), + ("DV103|9|A|40|5|1|5|True|04:00", "not a boolean"), + ("DV103|9|E|40|5|True|5|True|04:00", "unknown buffer"), + ], +) +def test_a_field_that_will_not_read_raises(text: str, problem: str): + """Reading has to fail rather than leave the step holding a default: a step half read from a + protocol would run something the protocol did not ask for.""" + with pytest.raises(ValueError, match=problem): + ManifoldPrime.from_definition(text) + + +def test_an_empty_field_does_not_hold_its_place(): + """An empty field is a missing one for most step types, so it shifts every field after it and the + count is what catches it.""" + with pytest.raises(ValueError, match="expects 8 fields, got 7"): + ManifoldPrime.from_definition("DV103|9|A||5|True|5|True|04:00") diff --git a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py new file mode 100644 index 00000000000..752dd10f56c --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py @@ -0,0 +1,285 @@ +"""What a user holds: one class per model, and the capability objects it exposes. + +Each model is built against a fake instrument, so setup, the plate, the palette and the capability +objects are exercised without hardware. What a capability method does is one step run as a one-step +protocol, and that is what is checked here -- the payloads themselves are the cross-reference's job. +""" + +from __future__ import annotations + +import unittest + +from pylabrobot.agilent.biotek.lhc import EL406, MultiFlo, MultiFloFX, Washer405TS +from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( + PeristalticDispenser, +) +from pylabrobot.agilent.biotek.lhc.devices.components.syringe_dispenser import SyringeDispenser +from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol, ProtocolEntry +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber +from pylabrobot.agilent.biotek.lhc.tests.helpers import ( + ACCEPTS_EVERY_PLATE, + FakeInstrument, + make_plate, +) + +ANSWERS = { + CommandNumber.GET_SYRINGE_MANIFOLD_INSTALLED: bytes([1]), + CommandNumber.GET_SYRINGE_BOX_INFO: bytes([1, 2]), + CommandNumber.GET_SELECTED_PERI_INSTALLED: bytes([1]), + CommandNumber.GET_WASHER_MANIFOLD_INSTALLED: bytes([0]), + CommandNumber.GET_EXT_VALVE_MODULE_INSTALLED: bytes([1]), + CommandNumber.GET_VACUUM_FILTRATION_INSTALLED: bytes([0]), + CommandNumber.GET_ULTRASONIC_CLEANER_INSTALLED: bytes([1]), + CommandNumber.GET_CELL_WASHING_INSTALLED: bytes([1]), + CommandNumber.GET_IS_PERI_HALF_UL_SUPPORTED: bytes([1]), + CommandNumber.GET_Y_AXIS_INSTALLED: bytes([1]), + CommandNumber.GET_SERIAL_NUMBER: b"SN0001".ljust(24), + CommandNumber.GET_BASECODE_VERSION: ( + b"7100000" + b"2.22.6 " + b"ABCD" + b"DCBA" + b"1.000" + b"1.0" + b"2.0" + b" " * 12 + ), + CommandNumber.IS_STRIP_WASHER_BOX_CONNECTED: bytes([0]), + CommandNumber.GET_STRIP_WASHER_HW_INSTALLED: bytes([0]), + CommandNumber.GET_WHICH_BASECODE_IS_INSTALLED: bytes([0]), + CommandNumber.GET_FLUID_TRACKING_ENABLED: bytes([1]), + **ACCEPTS_EVERY_PLATE, +} +"""What the fake instrument answers, which is a fully equipped instrument of the original model.""" + + +class DeviceTestCase(unittest.IsolatedAsyncioTestCase): + """A model built onto a fake instrument.""" + + async def build(self, cls, wells: int = 96, answers: dict | None = None): + """Build a model, set it up and give it a plate. + + Args: + cls: Which model to build. + wells: How many wells the plate has. + answers: What the instrument answers, defaulting to a fully equipped one. + + Returns: + The device and the fake instrument behind it. + """ + io = FakeInstrument(answers=ANSWERS if answers is None else answers) + device = cls(port="fake", io=io) + # The fake instrument answers at once, so none of the pacing a real one needs is wanted here. + device.settle = 0 + await device.setup() + device.set_plate(make_plate(wells)) + return device, io + + +class TestTheLifecycle(DeviceTestCase): + """Opening a device, and what it learns while doing so.""" + + async def test_setup_reads_what_is_fitted(self): + device, _ = await self.build(EL406) + self.assertIs(device.settings.family, InstrumentFamily.EL406) + self.assertTrue(device.settings.peri_pump) + + async def test_setup_proves_something_is_listening_before_reading_anything(self): + _, io = await self.build(EL406) + self.assertEqual(io.sent[0], int(CommandNumber.PING)) + + async def test_a_device_reports_its_own_name(self): + device, _ = await self.build(EL406) + self.assertEqual(device.name, "EL406") + + async def test_stopping_closes_the_link(self): + device, io = await self.build(EL406) + await device.stop() + self.assertFalse(io.is_open) + + async def test_the_serial_number_and_the_firmware_can_be_read(self): + device, _ = await self.build(EL406) + self.assertEqual(await device.get_serial_number(), "SN0001") + self.assertEqual((await device.get_firmware_version()).software_version.strip(), "2.22.6") + + +class TestThePlate(DeviceTestCase): + """Telling a device what is on its carrier.""" + + async def test_a_plate_is_resolved_to_a_format_the_model_works(self): + device, _ = await self.build(EL406) + self.assertIsNotNone(device.plate) + self.assertIs(device.plate.plate_type, PlateType.PLATE_96_WELL) + + async def test_a_format_can_be_named_outright(self): + device, _ = await self.build(EL406) + device.set_plate(make_plate(384), plate_type=PlateType.PLATE_384_WELL_PCR) + self.assertIs(device.plate.plate_type, PlateType.PLATE_384_WELL_PCR) + + async def test_a_plate_the_model_does_not_work_is_an_error_naming_what_it_does(self): + device, _ = await self.build(Washer405TS) + with self.assertRaisesRegex(ValueError, "it offers"): + device.set_plate(make_plate(6)) + + async def test_forgetting_the_plate_stops_everything(self): + device, _ = await self.build(EL406) + device.clear_plate() + self.assertIsNone(device.plate) + with self.assertRaises(Exception): + await device.run_step(ManifoldPrime()) + + +class TestThePalette(DeviceTestCase): + """What a model offers, which is its own list narrowed by what is fitted.""" + + async def test_a_wash_only_model_offers_no_dispensing(self): + device, _ = await self.build(Washer405TS) + offered = device.get_available_steps() + self.assertIn(StepType.MANIFOLD_WASH, offered) + self.assertNotIn(StepType.SYRINGE_DISPENSE, offered) + self.assertNotIn(StepType.PERI_DISPENSE, offered) + + async def test_a_dispenser_only_model_offers_no_washing(self): + device, _ = await self.build(MultiFlo) + offered = device.get_available_steps() + self.assertIn(StepType.PERI_DISPENSE, offered) + self.assertNotIn(StepType.MANIFOLD_WASH, offered) + + async def test_hardware_that_is_not_fitted_is_not_offered(self): + """The instrument reporting no peristaltic pump takes those step types out of the palette.""" + answers = dict(ANSWERS) + answers[CommandNumber.GET_SELECTED_PERI_INSTALLED] = bytes([0]) + device, _ = await self.build(EL406, answers=answers) + self.assertFalse(device.settings.peri_pump) + self.assertNotIn(StepType.PERI_DISPENSE, device.get_available_steps()) + + async def test_the_palette_is_in_the_order_the_model_offers_them(self): + """Not the order the types are numbered: a model lists what it is for first, which is what a + user reading the palette wants to see.""" + device, _ = await self.build(EL406) + offered = device.get_available_steps() + self.assertEqual(offered[0], StepType.MANIFOLD_WASH) + self.assertNotEqual(offered, sorted(offered, key=lambda member: member.value)) + self.assertEqual(len(set(offered)), len(offered)) + + +class TestTheCapabilityObjects(DeviceTestCase): + """Which capability objects each model exposes, and what calling one does.""" + + async def test_a_wash_only_model_exposes_only_a_washer(self): + device, _ = await self.build(Washer405TS) + self.assertIsInstance(device.washer, PlateWasher) + with self.assertRaises(AttributeError): + device.syringe_dispenser # noqa: B018 - the point is that it is not there + + async def test_a_dispenser_only_model_exposes_no_washer(self): + device, _ = await self.build(MultiFlo) + self.assertIsInstance(device.syringe_dispenser, SyringeDispenser) + self.assertIsInstance(device.peristaltic_dispenser, PeristalticDispenser) + with self.assertRaises(AttributeError): + device.washer # noqa: B018 - the point is that it is not there + + async def test_the_newest_model_exposes_all_three(self): + device, _ = await self.build(MultiFloFX) + self.assertIsInstance(device.washer, PlateWasher) + self.assertIsInstance(device.syringe_dispenser, SyringeDispenser) + self.assertIsInstance(device.peristaltic_dispenser, PeristalticDispenser) + + async def test_one_call_brackets_itself_in_a_batch(self): + device, io = await self.build(EL406) + await device.washer.prime(volume=40_000) + self.assertEqual(io.sent.count(int(CommandNumber.INIT_PROTOCOL)), 1) + self.assertEqual(io.sent.count(int(CommandNumber.EXIT_PROTOCOL)), 1) + self.assertIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) + + async def test_several_calls_in_one_batch_share_the_opening(self): + device, io = await self.build(EL406) + async with device.batch(): + await device.washer.prime(volume=40_000) + await device.washer.auto_clean(duration=60) + self.assertEqual(io.sent.count(int(CommandNumber.INIT_PROTOCOL)), 1) + self.assertEqual(io.sent.count(int(CommandNumber.EXIT_PROTOCOL)), 1) + + async def test_a_capability_method_sends_the_step_it_names(self): + device, io = await self.build(EL406) + await device.washer.dispense(volume=100, buffer="B", flow_rate=5) + self.assertIn(int(CommandNumber.MANIFOLD_DISPENSE), io.sent) + + +class TestRunningAProtocol(DeviceTestCase): + """Handing a device a whole protocol rather than one operation.""" + + async def test_a_list_of_steps_runs_in_one_batch(self): + device, io = await self.build(EL406) + await device.run_protocol([ManifoldPrime(volume=40_000), ManifoldPrime(volume=10_000)]) + self.assertEqual(io.sent.count(int(CommandNumber.INIT_PROTOCOL)), 1) + self.assertEqual(io.sent.count(int(CommandNumber.MANIFOLD_PRIME)), 2) + + async def test_a_protocol_object_has_its_steps_built_from_its_entries(self): + device, io = await self.build(EL406) + protocol = Protocol( + entries=[ + ProtocolEntry( + action=StepAction.CUSTOM, + step_type=StepType.MANIFOLD_PRIME, + definition="DV103|9|A|40|9|True|5|False|00:05", + ) + ] + ) + await device.run_protocol(protocol) + self.assertIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) + + async def test_the_check_can_be_asked_for_on_its_own(self): + device, _ = await self.build(EL406) + self.assertTrue(await device.can_run([ManifoldPrime(volume=40_000)])) + + async def test_a_step_built_outright_can_be_run(self): + device, io = await self.build(EL406) + await device.run_step(ManifoldPrime(volume=40_000)) + self.assertIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) + + +class TestTheServiceSurface(DeviceTestCase): + """The operations that are about the instrument rather than about liquid.""" + + async def test_resetting_sends_its_own_command(self): + device, io = await self.build(EL406) + await device.reset() + self.assertIn(int(CommandNumber.RESET_INSTRUMENT), io.sent) + + async def test_homing_everything_is_the_default(self): + device, io = await self.build(EL406) + await device.home() + self.assertIn(int(CommandNumber.HOME_VERIFY_MOTORS), io.sent) + + async def test_the_self_check_sends_its_own_command(self): + device, io = await self.build(EL406) + await device.self_check() + self.assertIn(int(CommandNumber.RUN_SELF_CHECK), io.sent) + + async def test_shaking_runs_as_a_step(self): + device, io = await self.build(EL406) + await device.shake(duration=5, soak_duration=30) + self.assertIn(int(CommandNumber.SHAKE_SOAK), io.sent) + + +class TestComparingWhatAProtocolExpects(DeviceTestCase): + """The one use a protocol's declared options are put to, and only when asked.""" + + async def test_a_settings_record_can_be_compared_directly(self): + device, _ = await self.build(EL406) + self.assertTrue(device.compare_settings(device.settings)) + + async def test_a_protocol_without_a_document_is_an_explicit_error(self): + device, _ = await self.build(EL406) + with self.assertRaisesRegex(ValueError, "no fitted-options document"): + device.compare_settings(Protocol(protocol_name="rinse")) + + async def test_a_protocol_written_for_another_instrument_still_runs(self): + """The comparison is never consulted on the way to running something: a protocol is measured + against the instrument as it is, which is what the check does.""" + device, io = await self.build(EL406) + declared = InstrumentSettings(family=InstrumentFamily.MULTIFLO_FX) + self.assertFalse(device.compare_settings(declared)) + await device.run_protocol([ManifoldPrime(volume=40_000)]) + self.assertIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) diff --git a/pylabrobot/agilent/biotek/lhc/tests/error_handling_tests.py b/pylabrobot/agilent/biotek/lhc/tests/error_handling_tests.py new file mode 100644 index 00000000000..0b7e6b4bc57 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/error_handling_tests.py @@ -0,0 +1,132 @@ +"""What an error code means, and which exception it becomes. + +A code is a bit field rather than an index: parts of it name which motor or which link failed, and +what a code means depends on the instrument family. So the table is checked structurally -- every +code classifies, the ranges classify as the kind of thing they are, and the same code can read +differently on two models. +""" + +from __future__ import annotations + +import pytest + +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.error_handling import ( + NO_ERROR, + NOT_ACKNOWLEDGED, + PORT_WOULD_NOT_OPEN, + REPLY_TIMED_OUT, + WRITE_FAILED, + BiotekError, + ErrorKind, + LinkError, + RejectedError, + classify, + describe, + error_message, + fail, + info_for, + normalize, + raise_for_status, +) + +TRANSPORT_CODES = (WRITE_FAILED, NOT_ACKNOWLEDGED, REPLY_TIMED_OUT, PORT_WOULD_NOT_OPEN) +"""The failures this package finds itself, which share the table with the instrument's own.""" + + +class TestEveryCode: + """Properties that have to hold across the whole space, since a code is a bit field.""" + + def test_every_code_classifies(self): + """A code with no row still has to say what kind of thing it is, so a caller can decide what to + do without a table lookup of its own.""" + for code in range(0, 0x10000, 7): + assert isinstance(classify(code), ErrorKind) + + def test_every_code_has_a_message(self): + for code in range(0, 0x10000, 101): + assert isinstance(error_message(code), str) + + def test_a_code_with_no_row_has_no_message_rather_than_a_made_up_one(self): + assert error_message(0x0001) == "" + + def test_success_is_not_an_error(self): + assert classify(NO_ERROR) is ErrorKind.NONE + raise_for_status(NO_ERROR) + + @pytest.mark.parametrize("code, unsigned", [(-1, 0xFFFF), (-2, 0xFFFE)]) + def test_a_negative_code_is_read_unsigned(self, code: int, unsigned: int): + """Some commands report the status as a signed word, and the table is indexed unsigned.""" + assert normalize(code) == unsigned + + @pytest.mark.parametrize( + "code, kind", + [ + (0x0201, ErrorKind.MOTOR), + (0x8110, ErrorKind.LINK), + (0x6029, ErrorKind.REJECTED), + (0x4001, ErrorKind.STORAGE), + ], + ) + def test_a_range_classifies_as_the_kind_of_thing_it_is(self, code: int, kind: ErrorKind): + """Which range a code falls in is what says whether a retry, a person or a different request is + what it needs.""" + assert classify(code) is kind + + +class TestWhichExceptionACodeBecomes: + """The class is the caller's decision, so the mapping is what matters.""" + + def test_success_raises_nothing(self): + raise_for_status(0) + + def test_a_refused_request_is_its_own_kind(self): + with pytest.raises(RejectedError): + raise_for_status(0x6029) + + @pytest.mark.parametrize("code", TRANSPORT_CODES) + def test_a_transport_failure_is_a_link_failure(self, code: int): + """These share the numbering space with the instrument's own faults, and they are the one kind + where sending the same thing again is reasonable.""" + with pytest.raises(LinkError): + raise_for_status(code) + + def test_every_exception_is_one_of_ours(self): + for code in range(0x6000, 0x6100, 3): + try: + raise_for_status(code) + except BiotekError as error: + assert isinstance(error, BiotekError) + assert error.code == normalize(code) + + def test_a_failure_this_package_found_itself_carries_no_code(self): + error = fail(ErrorKind.LINK, "the port went away", operation="write") + assert error.code == NO_ERROR + assert "the port went away" in str(error) + assert "write" in str(error) + + +class TestWhatAFailureSays: + """The text a caller sees, which has to name what was being attempted.""" + + def test_a_failure_names_the_operation_and_the_code(self): + info = info_for(0x6029, InstrumentFamily.EL406, operation="opening the batch") + assert "opening the batch" in str(info) + assert "0x6029" in str(info) + + def test_a_description_carries_the_code_and_its_message(self): + assert "6029" in describe(0x6029) or "24617" in describe(0x6029) + + def test_the_family_changes_what_a_motor_code_means(self): + """Part of a motor code is which motor, and the models do not number their motors the same way, + so the same code names different hardware on different instruments.""" + original = error_message(0x0202, InstrumentFamily.EL406) + wash_only = error_message(0x0202, InstrumentFamily.MODEL_405_TS) + assert "Dispense Head" in original + assert "Washer Head" in wash_only + assert original != wash_only + + def test_most_motor_codes_read_the_same_on_every_model(self): + """Only the motors a model does not have are renamed, so a shared one reads alike.""" + families = list(InstrumentFamily) + assert len({error_message(0x0201, family) for family in families}) == 1 diff --git a/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py b/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py new file mode 100644 index 00000000000..094c3431284 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py @@ -0,0 +1,261 @@ +"""Running a step, and the batch it runs inside. + +The instrument here is a fake that answers real frames, so what is exercised is the whole path above +the transport: the batch bracket, the send-and-poll strategy, the status word turning into an +exception, and the check that decides whether a protocol may run at all. +""" + +from __future__ import annotations + +import unittest + +from pylabrobot.agilent.biotek.lhc.devices import execution +from pylabrobot.agilent.biotek.lhc.devices.batch import batch +from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType +from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold +from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState +from pylabrobot.agilent.biotek.lhc.error_handling import BiotekError, RejectedError +from pylabrobot.agilent.biotek.lhc.plate_geometry.resolution import resolve +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_prime import SyringePrime +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber +from pylabrobot.agilent.biotek.lhc.tests.helpers import ( + ACCEPTS_EVERY_PLATE, + FakeInstrument, + fake_link, + make_plate, +) + +FAMILY = InstrumentFamily.EL406 + + +class ExecutionTestCase(unittest.IsolatedAsyncioTestCase): + """A device's state wired to a fake instrument, opened and ready to run steps.""" + + async def opened( + self, + busy_polls: int = 0, + status: int = 0, + with_plate: bool = True, + busy_after_step: bool = False, + answers: dict | None = None, + ) -> tuple[Runtime, FakeInstrument]: + """Build the state and open the link. + + Args: + busy_polls: How many polls report a running step before one reports it finished. + status: The status word the instrument answers with. Anything but zero is a failure. + with_plate: Whether a plate is on the carrier. + busy_after_step: Whether a step, once sent, never finishes. + answers: What the instrument answers, defaulting to one that accepts every plate. + + Returns: + The state and the fake instrument behind it. + """ + link, io = fake_link( + answers=ACCEPTS_EVERY_PLATE if answers is None else answers, + busy_polls=busy_polls, + status=status, + family=FAMILY, + busy_after_step=busy_after_step, + ) + state = Runtime(link=link, family=FAMILY, rules=rules_for(FAMILY), settle=0) + if with_plate: + state.plate = resolve(make_plate(96), FAMILY) + await link.setup() + return state, io + + def count(self, io: FakeInstrument, command: CommandNumber) -> int: + """How many times a command was sent. + + Args: + io: The fake instrument. + command: The command to count. + + Returns: + The count. + """ + return io.sent.count(int(command)) + + +class TestRunningAStep(ExecutionTestCase): + """Sending one step and waiting for it.""" + + async def test_a_step_is_sent_with_the_plate_in_front_of_it(self): + state, io = await self.opened() + async with batch(state): + await execution.run_step(state, ManifoldPrime(volume=40_000)) + payload = io.payload_of(CommandNumber.MANIFOLD_PRIME) + self.assertEqual(payload[0], int(state.plate_type)) + self.assertEqual(payload[1:], ManifoldPrime(volume=40_000).to_bytes(state.settings)) + + async def test_a_step_is_polled_until_it_stops_being_busy(self): + state, io = await self.opened(busy_polls=2) + async with batch(state): + await execution.run_step(state, ManifoldPrime(), interval=0) + # One poll finds the instrument idle before sending, then two report it busy and one finished. + self.assertGreaterEqual(self.count(io, CommandNumber.GET_PROTOCOL_STATUS), 4) + + async def test_a_step_that_never_finishes_gives_up(self): + """The instrument takes the step and then reports it running forever.""" + state, _ = await self.opened(busy_after_step=True) + async with batch(state): + with self.assertRaisesRegex(BiotekError, "has not finished"): + await execution.run_step(state, ManifoldPrime(), timeout=0, interval=0) + + async def test_an_instrument_that_never_goes_idle_is_not_sent_a_step(self): + """A step is only sent to an idle instrument, so one that stays busy gives up first -- and the + step never reaches the wire.""" + state, io = await self.opened(busy_polls=1000) + async with batch(state): + with self.assertRaisesRegex(BiotekError, "still busy"): + await execution.wait_until_idle(state, timeout=0, interval=0) + self.assertNotIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) + + async def test_a_status_the_instrument_reports_becomes_an_exception(self): + """A non-zero status word is the instrument refusing or failing, and it is raised as the kind of + failure it is rather than returned.""" + state, _ = await self.opened(status=0x6029) + with self.assertRaises(RejectedError): + await execution.status(state) + + async def test_nothing_runs_without_a_plate(self): + state, _ = await self.opened(with_plate=False) + with self.assertRaisesRegex(RejectedError, "no plate"): + await execution.run_step(state, ManifoldPrime()) + + async def test_the_status_reports_what_the_instrument_is_doing(self): + state, _ = await self.opened() + self.assertIs((await execution.status(state)).state, RunState.READY) + + +class TestTheBatchBracket(ExecutionTestCase): + """Opening and closing the batch a step has to run inside.""" + + async def test_a_batch_opens_and_closes_around_the_block(self): + state, io = await self.opened() + async with batch(state): + self.assertTrue(state.in_batch) + self.assertFalse(state.in_batch) + self.assertLess( + io.sent.index(int(CommandNumber.INIT_PROTOCOL)), + io.sent.index(int(CommandNumber.EXIT_PROTOCOL)), + ) + + async def test_a_batch_inside_a_batch_does_nothing(self): + state, io = await self.opened() + async with batch(state): + async with batch(state): + self.assertTrue(state.in_batch) + # The inner block must not have closed the outer one. + self.assertTrue(state.in_batch) + self.assertEqual(self.count(io, CommandNumber.EXIT_PROTOCOL), 0) + self.assertEqual(self.count(io, CommandNumber.INIT_PROTOCOL), 1) + self.assertEqual(self.count(io, CommandNumber.EXIT_PROTOCOL), 1) + + async def test_a_batch_closes_even_when_the_block_fails(self): + state, io = await self.opened() + with self.assertRaises(RuntimeError): + async with batch(state): + raise RuntimeError("the step went wrong") + self.assertEqual(self.count(io, CommandNumber.EXIT_PROTOCOL), 1) + self.assertFalse(state.in_batch) + self.assertFalse(state.port.locked()) + + async def test_the_instrument_is_released_when_the_batch_cannot_open(self): + state, _ = await self.opened(status=0x6029) + with self.assertRaises(BiotekError): + async with batch(state): + pass + self.assertFalse(state.port.locked()) + self.assertFalse(state.in_batch) + + async def test_homing_before_the_close_is_asked_for_not_assumed(self): + state, io = await self.opened() + async with batch(state): + pass + self.assertEqual(self.count(io, CommandNumber.HOME_VERIFY_MOTORS), 0) + + state, io = await self.opened() + async with batch(state, home_on_close=True): + pass + self.assertLess( + io.sent.index(int(CommandNumber.HOME_VERIFY_MOTORS)), + io.sent.index(int(CommandNumber.EXIT_PROTOCOL)), + ) + + +class TestCheckingAProtocol(ExecutionTestCase): + """The pass that decides whether a protocol may run, and what skipping it costs.""" + + async def test_a_protocol_is_checked_before_the_batch_opens(self): + state, io = await self.opened() + await execution.run_steps(state, [ManifoldPrime(volume=40_000)]) + # What the instrument accepts is read as part of the check, so before the open. + self.assertLess( + io.sent.index(int(CommandNumber.GET_PLATE_RESTRICTION)), + io.sent.index(int(CommandNumber.INIT_PROTOCOL)), + ) + + async def test_a_protocol_that_cannot_run_is_refused_before_anything_moves(self): + """A syringe prime on an instrument with no syringe box cannot run, and the batch is never + opened for it.""" + state, io = await self.opened() + state.settings = InstrumentSettings( + family=FAMILY, + syringe_box=SyringeBoxType.NOT_INSTALLED, + syringe_manifold=SyringeManifold.NOT_INSTALLED, + ) + with self.assertRaises(RejectedError): + await execution.run_steps(state, [SyringePrime()]) + self.assertEqual(self.count(io, CommandNumber.INIT_PROTOCOL), 0) + + async def test_skipping_the_check_gives_up_what_the_check_reserved(self): + """The check is also what works out which cassette each pump needs, so a run that skips it + opens its batch against whatever is fitted.""" + state, _ = await self.opened() + await execution.run_steps(state, [ManifoldPrime(volume=40_000)], check=False) + self.assertIsNone(state.reservations.cassette_primary) + self.assertFalse(state.reservations.uses_primary) + + async def test_the_check_reports_rather_than_raises(self): + state, _ = await self.opened() + report = await execution.can_run(state, [ManifoldPrime(volume=40_000)]) + self.assertTrue(report) + self.assertIn("can run", str(report)) + + async def test_what_the_instrument_accepts_is_read_once(self): + state, io = await self.opened() + await execution.can_run(state, [ManifoldPrime()]) + await execution.can_run(state, [ManifoldPrime()]) + self.assertEqual(self.count(io, CommandNumber.GET_PLATE_RESTRICTION), 1) + + async def test_a_new_plate_makes_the_next_check_ask_again(self): + state, io = await self.opened() + await execution.can_run(state, [ManifoldPrime()]) + state.forget_instrument_facts() + await execution.can_run(state, [ManifoldPrime()]) + self.assertEqual(self.count(io, CommandNumber.GET_PLATE_RESTRICTION), 2) + + +class TestRunControl(ExecutionTestCase): + """Stopping a running step, and letting it go on.""" + + async def test_abort_sends_its_own_command(self): + state, io = await self.opened() + await execution.abort(state) + self.assertEqual(io.sent, [int(CommandNumber.ABORT_STEP)]) + + async def test_pause_sends_its_own_command(self): + state, io = await self.opened() + await execution.pause(state) + self.assertEqual(io.sent, [int(CommandNumber.PAUSE_STEP)]) + + async def test_resume_sends_its_own_command(self): + state, io = await self.opened() + await execution.resume(state) + self.assertEqual(io.sent, [int(CommandNumber.RESUME_STEP)]) diff --git a/pylabrobot/agilent/biotek/lhc/tests/hardware_tests.py b/pylabrobot/agilent/biotek/lhc/tests/hardware_tests.py new file mode 100644 index 00000000000..03e26667f5b --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/hardware_tests.py @@ -0,0 +1,83 @@ +"""Tests that need an instrument on the other end of a port. + +Deselected by default. Run them with ``-m hardware`` and ``LHC_INSTRUMENT_PORT`` set to the port the +instrument is on, and read what each one does first: opening a batch homes the motors, so the +carrier moves, and the self-check moves more than that. Nothing here dispenses, and nothing runs a +protocol. + +The point is to prove against real firmware the one thing a fake instrument cannot: that the link, +the identity queries, the fitted-options read and the batch bracket work as they do here. +""" + +from __future__ import annotations + +import os +import unittest + +import pytest + +from pylabrobot.agilent.biotek.lhc import EL406 +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily + +PORT = os.environ.get("LHC_INSTRUMENT_PORT", "") +"""The port an instrument is on, if there is one to talk to.""" + +MODEL = os.environ.get("LHC_INSTRUMENT_MODEL", InstrumentFamily.EL406.name) +"""Which model is attached, so what it reports can be checked against what it should be.""" + +pytestmark = [ + pytest.mark.hardware, + pytest.mark.skipif(not PORT, reason="set LHC_INSTRUMENT_PORT to the instrument's port"), +] + + +class TestAgainstRealFirmware(unittest.IsolatedAsyncioTestCase): + """One instrument, opened for each test and closed again.""" + + async def asyncSetUp(self) -> None: + """Open the instrument.""" + self.device = EL406(port=PORT) + await self.device.setup() + + async def asyncTearDown(self) -> None: + """Close the instrument, whatever the test did.""" + await self.device.stop() + + async def test_the_instrument_answers_and_says_what_it_is(self): + """The whole link in one test: it opened, it answered, and it named itself.""" + self.assertTrue(await self.device.get_serial_number()) + self.assertTrue((await self.device.get_firmware_version()).software_version) + + async def test_the_fitted_options_read_back(self): + """What setup already did, asserted: a record read half way would encode every step wrongly.""" + self.assertEqual(self.device.settings.family.name, MODEL) + self.assertTrue(self.device.get_available_steps()) + + async def test_the_status_can_be_read_without_a_batch_open(self): + self.assertIsNotNone((await self.device.get_status()).state) + + async def test_a_batch_opens_and_closes(self): + """Opening a batch homes the motors, so the carrier moves. Nothing is dispensed.""" + from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb + + self.device.set_plate(cor_96_wellplate_360uL_Fb("plate")) + async with self.device.batch(): + self.assertIsNotNone((await self.device.get_status()).state) + + async def test_the_self_check_passes(self): + """The instrument's own check of itself, which takes a while and moves things.""" + await self.device.self_check() + + async def test_a_protocol_this_instrument_cannot_run_is_refused_rather_than_attempted(self): + """The check, against a real fitted-options record: a step whose hardware is absent is refused + before anything moves.""" + from pylabrobot.agilent.biotek.lhc.protocols.steps.steps import STEP_CLASSES + from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb + + self.device.set_plate(cor_96_wellplate_360uL_Fb("plate")) + offered = set(self.device.get_available_steps()) + absent = [step_type for step_type in STEP_CLASSES if step_type not in offered] + if not absent: + self.skipTest("this instrument offers every step type, so there is nothing it cannot run") + step = STEP_CLASSES[absent[0]]() + self.assertFalse(await self.device.can_run([step])) diff --git a/pylabrobot/agilent/biotek/lhc/tests/helpers.py b/pylabrobot/agilent/biotek/lhc/tests/helpers.py new file mode 100644 index 00000000000..6a1c5f2cf31 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/helpers.py @@ -0,0 +1,278 @@ +"""What the tests around here share: labware to work, and an instrument that is not there. + +The fake instrument answers a framed request the way a real one does -- acknowledge, header, +payload, checksum -- so everything above the transport is exercised for real: framing, the status +word, the error hierarchy, the send-and-poll strategy and the batch bracket. What it answers is a +table the test sets, so a test can say what is fitted, how long a step takes to finish, or that a +command fails. +""" + +from __future__ import annotations + +from functools import lru_cache + +from pylabrobot.agilent.biotek.lhc.comm.link import ACK, Link +from pylabrobot.agilent.biotek.lhc.comm.transport import Transport +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import ( + STEP_TYPE_TO_COMMAND, + CommandNumber, +) +from pylabrobot.agilent.biotek.lhc.serialization.frame import HEADER_LENGTH, Header, checksum +from pylabrobot.resources import Plate, Well +from pylabrobot.resources.utils import create_ordered_items_2d + +STEP_COMMANDS = frozenset(int(command) for command in STEP_TYPE_TO_COMMAND.values()) +"""Every command that runs a step, so the fake can tell one from a query.""" + +_GRIDS: dict[int, tuple[int, int]] = { + 6: (3, 2), + 24: (6, 4), + 96: (12, 8), + 384: (24, 16), + 1536: (48, 32), +} +_PITCHES: dict[int, float] = {6: 39.0, 24: 19.3, 96: 9.0, 384: 4.5, 1536: 2.25} + + +@lru_cache(maxsize=None) +def _built(wells: int, well_depth: float, name: str) -> Plate: + """Build the labware, once per shape. + + Args: + wells: How many wells it has. + well_depth: How deep a well is, in mm. + name: The resource's name. + + Returns: + The plate. + """ + columns, rows = _GRIDS[wells] + pitch = _PITCHES[wells] + return Plate( + name=name, + size_x=127.0, + size_y=85.0, + size_z=max(14.0, well_depth + 3.0), + ordered_items=create_ordered_items_2d( + Well, + num_items_x=columns, + num_items_y=rows, + dx=10.0, + dy=7.0, + dz=1.0, + item_dx=pitch, + item_dy=pitch, + size_x=pitch * 0.75, + size_y=pitch * 0.75, + size_z=well_depth, + ), + ) + + +def make_plate(wells: int = 96, well_depth: float = 10.9, name: str = "plate") -> Plate: + """Labware of a given well count for a test. + + Args: + wells: How many wells it has, which decides its columns and rows. + well_depth: How deep a well is, in mm. Past the deep-well threshold this is what makes the + plate resolve to a deep-well format. + name: The resource's name. + + Returns: + The plate. + + Raises: + KeyError: If no grid is defined for that well count. + """ + return _built(wells, well_depth, name) + + +def fitted_el406() -> InstrumentSettings: + """A fully equipped instrument of the family's original model. + + Returns: + The record, which is what the step encoders and the rule set are measured against. + """ + return InstrumentSettings(family=InstrumentFamily.EL406) + + +class FakeInstrument(Transport): + """A transport that answers framed requests without an instrument behind it. + + Args: + answers: What to answer each command with, as the payload after the status word. A command not + named here is answered with the status word alone. + busy_polls: How many status polls report a running step before one reports it finished. + status: The status word to answer with. Anything but zero is an error the link raises for. + busy_after_step: Whether to report a running step forever once one has been sent, which is what + a step that never finishes looks like. The polls before it still report the instrument idle, + so a caller gets as far as sending the step. + + Attributes: + sent: Every command number written, in order, so a test can assert what was sent and when. + payloads: The payload of every command written, in the same order. + """ + + def __init__( + self, + answers: dict[CommandNumber, bytes] | None = None, + busy_polls: int = 0, + status: int = 0, + busy_after_step: bool = False, + ) -> None: + super().__init__(port="fake", timeout=1.0) + self.answers = dict(answers) if answers else {} + self.busy_polls = busy_polls + self.status = status + self.busy_after_step = busy_after_step + self._step_sent = False + self.sent: list[int] = [] + self.payloads: list[bytes] = [] + self.is_open = False + self._pending: Header | None = None + self._out = bytearray() + self._polls = 0 + + async def setup(self) -> None: + """Open the fake link.""" + self.is_open = True + + async def stop(self) -> None: + """Close the fake link.""" + self.is_open = False + + async def purge(self) -> None: + """Discard whatever is waiting to be read.""" + self._out.clear() + + async def write(self, data: bytes) -> None: + """Take a header, then a payload if the header declared one, and queue the answer. + + Args: + data: The bytes written. + """ + if self._pending is None: + header = Header.from_bytes(data[:HEADER_LENGTH]) + if header.payload_length: + self._pending = header + return + self._answer(header, b"") + return + header, self._pending = self._pending, None + self._answer(header, bytes(data)) + + async def read(self, num_bytes: int = 1) -> bytes: + """Read from what has been queued. + + Args: + num_bytes: The most bytes to return. + + Returns: + What was waiting, up to that many bytes. + """ + taken = bytes(self._out[:num_bytes]) + del self._out[:num_bytes] + return taken + + def _answer(self, header: Header, payload: bytes) -> None: + """Queue the reply to one command. + + Args: + header: The request's header. + payload: The request's payload. + """ + self.sent.append(header.number) + self.payloads.append(payload) + self._out += bytes([ACK]) + self._reply(header.number) + + def _reply(self, number: int) -> bytes: + """Frame the reply to one command. + + Args: + number: Which command is being answered. + + Returns: + The reply header followed by its payload. + """ + answer = self._answer_for(number) + payload = self.status.to_bytes(2, "little") + answer + header = Header(number=number, payload_length=len(payload)) + header.check = checksum(header.to_bytes(), payload) + return header.to_bytes() + payload + + def _answer_for(self, number: int) -> bytes: + """What to answer a command with, after its status word. + + Args: + number: Which command is being answered. + + Returns: + The answer, which is empty for a command that reports only a status. + """ + if number in STEP_COMMANDS: + self._step_sent = True + if number == CommandNumber.GET_PROTOCOL_STATUS: + self._polls += 1 + running = self._polls <= self.busy_polls or (self.busy_after_step and self._step_sent) + state = RunState.BUSY if running else RunState.READY + return int(state).to_bytes(2, "little") + (0).to_bytes(4, "little") + bytes([0]) + for command, answer in self.answers.items(): + if int(command) == number: + return answer + return b"" + + def payload_of(self, command: CommandNumber) -> bytes: + """The payload of the first request for a command. + + Args: + command: Which command to look for. + + Returns: + Its payload. + + Raises: + AssertionError: If that command was never sent. + """ + for number, payload in zip(self.sent, self.payloads): + if number == int(command): + return payload + raise AssertionError(f"{command.name} was never sent; sent {self.sent}") + + +ACCEPTS_EVERY_PLATE: dict[CommandNumber, bytes] = { + CommandNumber.GET_PLATE_RESTRICTION: bytes([0]), + CommandNumber.GET_CARRIER_TYPE: bytes([0]), +} +"""An instrument that accepts every plate and has the ordinary carrier fitted. + +Both are read by every check, so a fake that does not answer them is an instrument whose firmware +does not implement those queries -- which is worth testing, but not what most tests are about. +""" + + +def fake_link( + answers: dict[CommandNumber, bytes] | None = None, + busy_polls: int = 0, + status: int = 0, + family: InstrumentFamily = InstrumentFamily.EL406, + busy_after_step: bool = False, +) -> tuple[Link, FakeInstrument]: + """A link onto a fake instrument, and the instrument itself. + + Args: + answers: What to answer each command with. + busy_polls: How many polls report a running step before one reports it finished. + status: The status word to answer with. + family: Which family the link decodes error codes for. + busy_after_step: Whether a step, once sent, never finishes. + + Returns: + The link, unopened, and the transport behind it. + """ + io = FakeInstrument( + answers=answers, busy_polls=busy_polls, status=status, busy_after_step=busy_after_step + ) + return Link(port="fake", family=family, name="fake instrument", timeout=1.0, io=io), io diff --git a/pylabrobot/agilent/biotek/lhc/tests/link_tests.py b/pylabrobot/agilent/biotek/lhc/tests/link_tests.py new file mode 100644 index 00000000000..dc4c02cb550 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/link_tests.py @@ -0,0 +1,234 @@ +"""One framed request and its reply. + +What the link owns is the shape of an exchange and nothing else: write the header, write the payload, +read the acknowledgement, read the reply, check it, and turn a status word into the kind of failure +it names. The transport underneath is a fake, so every one of those steps runs for real. +""" + +from __future__ import annotations + +import importlib.util +import unittest + +import pytest + +from pylabrobot.agilent.biotek.lhc.comm.connection import transport_for +from pylabrobot.agilent.biotek.lhc.comm.ftdi_transport import FtdiTransport, is_ftdi_port +from pylabrobot.agilent.biotek.lhc.comm.link import ACK, NAK, Link +from pylabrobot.agilent.biotek.lhc.comm.serial_transport import SerialTransport +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.error_handling import LinkError, RejectedError +from pylabrobot.agilent.biotek.lhc.serialization.command import Command +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber +from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import GetSerialNumber, Ping +from pylabrobot.agilent.biotek.lhc.serialization.frame import HEADER_LENGTH, Header, checksum +from pylabrobot.agilent.biotek.lhc.tests.helpers import FakeInstrument, fake_link + +HAS_USB_DRIVER = importlib.util.find_spec("pylibftdi") is not None +"""Whether the driver a USB bridge needs is installed. It is an optional dependency.""" + + +class TestChoosingATransport: + """Which kind of link a port string names, which is decided in one place.""" + + @pytest.mark.parametrize( + "port, usb", + [ + ("USB 405 TS/LS sn:13010914", True), + ("ftdi:FT1ABCDE", True), + ("/dev/ttyUSB0", False), + ("/dev/ttyS4", False), + ("COM3", False), + ], + ) + def test_a_port_string_names_its_own_kind(self, port: str, usb: bool): + """A string carrying a device serial number is a USB bridge; anything else is handed to the + operating system as it stands, which is why no name pattern is tested for.""" + assert is_ftdi_port(port) is usb + + @pytest.mark.parametrize( + "port, expected", + [ + ("USB 405 TS/LS sn:13010914", FtdiTransport), + ("ftdi:FT1ABCDE", FtdiTransport), + ("/dev/ttyUSB0", SerialTransport), + ("/dev/ttyS4", SerialTransport), + ("COM3", SerialTransport), + ], + ) + def test_the_port_string_alone_picks_the_transport(self, port: str, expected: type): + """Nothing above this knows which kind it got, and nothing chooses one by hand.""" + if expected is FtdiTransport and not HAS_USB_DRIVER: + pytest.skip("a USB bridge needs pylibftdi, which is an optional dependency") + assert isinstance(transport_for(port), expected) + + def test_a_serial_port_is_passed_through_unexamined(self): + """A serial port is whatever the operating system calls one, so nothing here validates it.""" + assert transport_for("/dev/ttyS4").port == "/dev/ttyS4" + + def test_no_port_is_an_error(self): + with pytest.raises(ValueError, match="no port"): + transport_for("") + + def test_a_link_needs_either_a_port_or_a_transport(self): + with pytest.raises(ValueError, match="either a port or a transport"): + Link() + + +class TestTheFrame: + """The bytes around a payload, which every command shares.""" + + def test_the_checksum_covers_the_header_and_the_payload(self): + header = Header(number=115, payload_length=2) + assert checksum(header.to_bytes(), b"\x01\x02") != checksum(header.to_bytes(), b"\x01\x03") + + def test_a_header_packs_and_unpacks(self): + header = Header(number=141, payload_length=1, check=0x1234) + assert len(header.to_bytes()) == HEADER_LENGTH + assert Header.from_bytes(header.to_bytes()) == header + + def test_a_command_frames_itself_as_a_header_and_its_payload(self): + command = Command(number=141, payload=b"\x04") + framed = command.to_bytes() + assert len(framed) == HEADER_LENGTH + 1 + assert Header.from_bytes(framed).number == 141 + assert framed[HEADER_LENGTH:] == b"\x04" + + def test_a_reply_carries_its_status_before_its_answer(self): + command = Command(number=256) + assert command.parse_reply((0).to_bytes(2, "little") + b"abc") == (0, b"abc") + + def test_a_reply_that_reports_an_error_has_no_answer(self): + """A caller cannot mistake a failure for a short answer.""" + command = Command(number=256) + assert command.parse_reply((0x6029).to_bytes(2, "little") + b"abc") == (0x6029, b"") + + +class TestOneExchange(unittest.IsolatedAsyncioTestCase): + """Sending a command over the link and reading what comes back.""" + + async def test_a_closed_link_refuses_to_send(self): + link, _ = fake_link() + with self.assertRaisesRegex(LinkError, "not open"): + await link.request(Ping()) + + async def test_opening_twice_does_nothing(self): + link, io = fake_link() + await link.setup() + await link.setup() + self.assertTrue(link.is_open) + self.assertTrue(io.is_open) + + async def test_closing_a_closed_link_does_nothing(self): + link, _ = fake_link() + await link.stop() + self.assertFalse(link.is_open) + + async def test_the_header_and_the_payload_are_written_separately(self): + """The instrument expects them as two writes, so a command carrying a payload is two.""" + link, io = fake_link() + await link.setup() + await link.request(Command(number=int(CommandNumber.INIT_PROTOCOL), payload=b"\x04")) + self.assertEqual(io.payload_of(CommandNumber.INIT_PROTOCOL), b"\x04") + + async def test_an_answer_comes_back_with_the_status_split_off(self): + link, _ = fake_link(answers={CommandNumber.GET_SERIAL_NUMBER: b"SN0001".ljust(24)}) + await link.setup() + command = GetSerialNumber() + self.assertEqual(command.parse(await link.request(command)), "SN0001") + + async def test_a_status_the_instrument_reports_is_raised_as_what_failed(self): + link, _ = fake_link(status=0x6029) + await link.setup() + with self.assertRaises(RejectedError): + await link.request(Ping()) + + async def test_a_refused_command_is_a_link_failure(self): + """The instrument answering that it will not take the command at all is different from it + answering that the command failed.""" + + class Refusing(FakeInstrument): + """A fake instrument that refuses whatever it is sent.""" + + def _answer(self, header: Header, payload: bytes) -> None: + """Answer with a refusal rather than a reply. + + Args: + header: The request's header. + payload: The request's payload. + """ + self.sent.append(header.number) + self.payloads.append(payload) + self._out += bytes([NAK]) + + io = Refusing() + link = Link(port="fake", family=InstrumentFamily.EL406, name="fake", timeout=0.05, io=io) + await link.setup() + with self.assertRaisesRegex(LinkError, "refused"): + await link.request(Ping()) + + async def test_an_instrument_that_never_acknowledges_times_out(self): + class Silent(FakeInstrument): + """A fake instrument that takes a command and says nothing.""" + + def _answer(self, header: Header, payload: bytes) -> None: + """Take the request and answer nothing. + + Args: + header: The request's header. + payload: The request's payload. + """ + self.sent.append(header.number) + self.payloads.append(payload) + + link = Link(port="fake", family=InstrumentFamily.EL406, name="fake", timeout=0.05, io=Silent()) + await link.setup() + with self.assertRaisesRegex(LinkError, "did not acknowledge"): + await link.request(Ping()) + + async def test_a_reply_that_stops_part_way_times_out(self): + class Truncating(FakeInstrument): + """A fake instrument whose reply stops after the acknowledgement.""" + + def _answer(self, header: Header, payload: bytes) -> None: + """Acknowledge and then send nothing. + + Args: + header: The request's header. + payload: The request's payload. + """ + self.sent.append(header.number) + self.payloads.append(payload) + self._out += bytes([ACK]) + + link = Link( + port="fake", family=InstrumentFamily.EL406, name="fake", timeout=0.05, io=Truncating() + ) + await link.setup() + # Not a ping: that carries a timeout of its own, and this is about the link's. + with self.assertRaisesRegex(LinkError, "header bytes"): + await link.request(Command(number=int(CommandNumber.PING))) + + async def test_a_reply_that_does_not_add_up_is_refused(self): + class Corrupting(FakeInstrument): + """A fake instrument whose replies do not match their own checksum.""" + + def _reply(self, number: int) -> bytes: + """Frame a reply with a checksum that does not cover it. + + Args: + number: Which command is being answered. + + Returns: + The reply. + """ + payload = (0).to_bytes(2, "little") + header = Header(number=number, payload_length=len(payload), check=0) + return header.to_bytes() + payload + + link = Link( + port="fake", family=InstrumentFamily.EL406, name="fake", timeout=0.05, io=Corrupting() + ) + await link.setup() + with self.assertRaisesRegex(LinkError, "did not arrive intact"): + await link.request(Ping()) diff --git a/pylabrobot/agilent/biotek/lhc/tests/payload_bytes_tests.py b/pylabrobot/agilent/biotek/lhc/tests/payload_bytes_tests.py new file mode 100644 index 00000000000..3ad3fa0f4b2 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/payload_bytes_tests.py @@ -0,0 +1,273 @@ +"""The exact bytes each step type sends. + +Every payload is frozen: the table in ``test_data/payloads.tsv`` holds a definition and the payload +the step it reads must send, and each row was checked against an independent implementation of the +same wire format before it was written down. A change to these bytes is a change to what reaches an +instrument, not a change to a test fixture. + +Most rows are definitions out of the protocol files in ``test_data`` -- real parameter combinations, +which is what makes the table worth having. The rest are constructed to reach what those files do +not: every member of each vocabulary, signed offsets at their limits, volume boundaries, and the two +step types no protocol in the tree happens to use. + +Structural properties of the payloads -- their lengths, what the fitted options move, what a step a +wash owns leaves out -- are in ``step_payload_tests.py``. This file is only about exact bytes. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import pytest + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.steps.secondary_aspirate_pattern import ( + SECONDARY_ASPIRATE_PATTERN_TO_BYTE, +) +from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TravelRate +from pylabrobot.agilent.biotek.lhc.protocols.steps import step_from_definition +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( + PreDispense, + SecondaryAspirate, + Sectors, + Shake, + Soak, + VacuumDelay, + WashStages, +) +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps import STEP_CLASSES +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_wash import ManifoldWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak + +SETTINGS = InstrumentSettings() +"""What these payloads are encoded against: a plainly equipped instrument of the first model.""" + +TABLE = Path(__file__).parent / "test_data" / "payloads.tsv" +"""Where the frozen payloads live: one step class, definition and payload per line.""" + + +def frozen() -> list[tuple[str, str, str]]: + """Every frozen payload. + + Returns: + The step class, the definition, and the payload it must send, one per row. + """ + rows = [] + for line in TABLE.read_text().splitlines(): + if not line.strip() or line.startswith("#"): + continue + kind, definition, payload = line.split("\t") + rows.append((kind, definition, payload)) + return rows + + +ROWS = frozen() +"""The table, read once.""" + + +def test_the_table_covers_every_step_type(): + """A step type absent from the table is one whose bytes nothing here would notice changing.""" + covered = {kind for kind, _, _ in ROWS} + assert {cls.__name__ for cls in STEP_CLASSES.values()} <= covered + + +def test_the_table_has_no_repeated_definitions(): + definitions = [definition for _, definition, _ in ROWS] + assert len(set(definitions)) == len(definitions) + + +@pytest.mark.parametrize( + "definition, expected", + [(definition, payload) for _, definition, payload in ROWS], + ids=[f"{kind}-{index}" for index, (kind, _, _) in enumerate(ROWS)], +) +def test_a_step_sends_the_bytes_it_should(definition: str, expected: str): + assert step_from_definition(definition).to_bytes(SETTINGS).hex() == expected + + +def test_every_secondary_aspirate_pattern_has_a_wire_value(): + """Two of them are values the instrument's own editor cannot produce, but the wire value for all + four is known, so a caller building a step outright can reach them.""" + assert SECONDARY_ASPIRATE_PATTERN_TO_BYTE == {"None": 0, "Point": 1, "Circle": 2, "Square": 3} + + +# --- a wash, whose payload is five steps of its own and where every offset has somewhere to go. +# +# The four arguments below are the ones worth being explicit about: where in the well each of the +# two aspirates works, and where a second aspirate would go. They belong to different sections of +# the payload, and getting them the wrong way round puts a height into the field of a different +# step -- which is the sort of mistake that only shows up as a manifold in the wrong place. + + +def wash( + asp_x: int = 0, + asp_y: int = 0, + asp_z: int = 29, + sec_z: int = 29, + final_sec_z: int = 29, + cycles: int = 3, + volume: int = 300, + flow: int = 7, + disp_z: int = 121, + travel: str = "3", +) -> ManifoldWash: + """Build a wash from the arguments the cases below vary. + + Args: + asp_x: Offset across the well for both aspirates. + asp_y: Offset along the well for both aspirates. + asp_z: Aspirating height for both aspirates. + sec_z: Height a second aspirate would work at, on the aspirate that starts each cycle. + final_sec_z: The same, on the aspirate after the last cycle. + cycles: How many wash cycles to run. + volume: Volume per well in µL. + flow: How fast to dispense. + disp_z: Dispensing height. + travel: How fast the tips descend. + + Returns: + The wash. + """ + + def aspirate(secondary_z: int) -> ManifoldAspirate: + """One of the wash's two aspirates. + + Args: + secondary_z: Where a second aspirate would go. + + Returns: + The aspirate, marked as one a wash owns. + """ + return ManifoldAspirate( + in_wash=True, + travel_rate=cast(TravelRate, travel), + delay=0, + positioning=Positioning(z=asp_z, x=asp_x, y=asp_y), + secondary=SecondaryAspirate(pattern="None", positioning=Positioning(z=secondary_z)), + ) + + def dispense() -> ManifoldDispense: + """One of the wash's two dispenses. + + Returns: + The dispense. + """ + return ManifoldDispense( + buffer="A", + volume=volume, + flow_rate=flow, + positioning=Positioning(z=disp_z), + pre_dispense=PreDispense(enabled=False, volume=0, flow_rate=9), + vacuum=VacuumDelay(enabled=False, volume=0), + ) + + return ManifoldWash( + wash_format="Plate", + sectors=Sectors(), + cycles=cycles, + stages=WashStages(final_aspirate=True), + bottom_wash=dispense(), + aspirate=aspirate(sec_z), + dispense=dispense(), + shake_soak=ShakeSoak( + shake=Shake(enabled=False, duration=0), + soak=Soak(enabled=False, duration=0), + move_carrier_home=False, + ), + final_aspirate=aspirate(final_sec_z), + ) + + +WASHES: list[tuple[str, dict[str, Any], str]] = [ + ( + "every argument at its default", + {}, + "0001000f0003412c01070000790000000900000000000000000000000300001d000000001d0000000000000000000000000300001d000000001d000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), + ( + "asp_x=8", + {"asp_x": 8}, + "0001000f0003412c01070000790000000900000000000000000000000308001d000000001d0000000000000000000000000308001d000000001d000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), + ( + "asp_y=-4", + {"asp_y": -4}, + "0001000f0003412c01070000790000000900000000000000000000000300fc1d000000001d0000000000000000000000000300fc1d000000001d000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), + ( + "asp_z=25", + {"asp_z": 25}, + "0001000f0003412c010700007900000009000000000000000000000003000019000000001d00000000000000000000000003000019000000001d000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), + ( + "sec_z=12", + {"sec_z": 12}, + "0001000f0003412c01070000790000000900000000000000000000000300001d000000001d0000000000000000000000000300001d000000000c000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), + ( + "final_sec_z=14", + {"final_sec_z": 14}, + "0001000f0003412c01070000790000000900000000000000000000000300001d000000000e0000000000000000000000000300001d000000001d000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), + ( + "cycles=5", + {"cycles": 5}, + "0001000f0005412c01070000790000000900000000000000000000000300001d000000001d0000000000000000000000000300001d000000001d000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), + ( + "volume=250", + {"volume": 250}, + "0001000f000341fa00070000790000000900000000000000000000000300001d000000001d0000000000000000000000000300001d000000001d00000000000000000041fa0007000079000000090000000000000000000000030000000000000000000000", + ), + ( + "flow=3", + {"flow": 3}, + "0001000f0003412c01030000790000000900000000000000000000000300001d000000001d0000000000000000000000000300001d000000001d000000000000000000412c0103000079000000090000000000000000000000030000000000000000000000", + ), + ( + "travel=5", + {"travel": "5"}, + "0001000f0003412c01070000790000000900000000000000000000000500001d000000001d0000000000000000000000000500001d000000001d000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), + ( + "disp_z=118", + {"disp_z": 118}, + "0001000f0003412c01070000760000000900000000000000000000000300001d000000001d0000000000000000000000000300001d000000001d000000000000000000412c0107000076000000090000000000000000000000030000000000000000000000", + ), + ( + "asp_x=8, asp_y=-4, asp_z=25, sec_z=12, final_sec_z=14", + {"asp_x": 8, "asp_y": -4, "asp_z": 25, "sec_z": 12, "final_sec_z": 14}, + "0001000f0003412c01070000790000000900000000000000000000000308fc19000000000e0000000000000000000000000308fc19000000000c000000000000000000412c0107000079000000090000000000000000000000030000000000000000000000", + ), +] +"""Each wash, and the payload it sends.""" + + +@pytest.mark.parametrize( + "arguments, expected", + [(arguments, expected) for _, arguments, expected in WASHES], + ids=[label for label, _, _ in WASHES], +) +def test_a_wash_sends_the_bytes_it_should(arguments: dict[str, Any], expected: str): + assert wash(**arguments).to_bytes(SETTINGS).hex() == expected + + +def test_each_aspirate_carries_its_own_secondary_height(): + """The two heights land in different sections, so setting one must not move the other.""" + first = wash(sec_z=12).to_bytes(SETTINGS) + last = wash(final_sec_z=12).to_bytes(SETTINGS) + assert first != last + assert first != wash().to_bytes(SETTINGS) + assert last != wash().to_bytes(SETTINGS) + + +def test_the_aspirate_offsets_reach_both_aspirates(): + """Both aspirates work the same place in the well, so one offset changes two sections.""" + plain = wash().to_bytes(SETTINGS) + offset = wash(asp_x=8).to_bytes(SETTINGS) + differing = [index for index, (a, b) in enumerate(zip(plain, offset)) if a != b] + assert len(differing) == 2, differing diff --git a/pylabrobot/agilent/biotek/lhc/tests/plate_geometry_tests.py b/pylabrobot/agilent/biotek/lhc/tests/plate_geometry_tests.py new file mode 100644 index 00000000000..be6712c7540 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/plate_geometry_tests.py @@ -0,0 +1,105 @@ +"""Resolving labware to the format an instrument works it as. + +The match is made on the resource's own geometry and never on a nearest fit, so what is tested is +both what resolves and what deliberately refuses to. +""" + +from __future__ import annotations + +import pytest + +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +from pylabrobot.agilent.biotek.lhc.plate_geometry import ( + DEEP_WELL_DEPTH, + SELECTION_ONLY, + find, + plates_for, + resolve, +) +from pylabrobot.agilent.biotek.lhc.tests.helpers import make_plate + +EVERY_FAMILY = list(InstrumentFamily) + + +class TestTheOfferedPlates: + """Which formats each model works, and at what heights.""" + + @pytest.mark.parametrize("family", EVERY_FAMILY) + def test_every_model_offers_something(self, family: InstrumentFamily): + offered = plates_for(family) + assert offered + assert len({record.plate_type for record in offered}) == len(offered) + + def test_a_dispenser_only_model_works_a_plate_at_one_height(self): + """It has no wash manifold, so its two dispensing heights are the same number.""" + for record in plates_for(InstrumentFamily.MULTIFLO_FX): + assert record.dispenser_height == record.manifold_dispense_height + + def test_a_model_with_a_wash_manifold_measures_two_heights(self): + record = find(PlateType.PLATE_96_WELL, InstrumentFamily.EL406) + assert record is not None + assert record.dispenser_height != record.manifold_dispense_height + + def test_a_format_a_model_does_not_offer_is_not_found(self): + assert find(PlateType.PLATE_6_WELL, InstrumentFamily.EL406) is None + + def test_a_record_knows_how_many_wells_it_has(self): + record = find(PlateType.PLATE_384_WELL, InstrumentFamily.EL406) + assert record is not None + assert record.wells == 384 + assert record.columns * record.rows == record.wells + + +class TestResolvingLabware: + """Turning a resource into one of those formats.""" + + @pytest.mark.parametrize( + "wells, plate_type", + [ + (96, PlateType.PLATE_96_WELL), + (384, PlateType.PLATE_384_WELL), + (1536, PlateType.PLATE_1536_WELL), + ], + ) + def test_columns_and_rows_decide_the_format(self, wells: int, plate_type: PlateType): + assert resolve(make_plate(wells), InstrumentFamily.EL406).plate_type is plate_type + + def test_well_depth_separates_a_deep_well_plate_from_a_standard_one(self): + """The one place a threshold decides anything, and only between two formats that differ in + nothing else.""" + family = InstrumentFamily.MULTIFLO + shallow = resolve(make_plate(96, well_depth=DEEP_WELL_DEPTH - 5), family) + deep = resolve(make_plate(96, well_depth=DEEP_WELL_DEPTH + 5), family) + assert shallow.plate_type is PlateType.PLATE_96_WELL + assert deep.plate_type is PlateType.PLATE_96_DEEP_WELL + + def test_a_format_can_be_named_instead_of_resolved(self): + resolved = resolve( + make_plate(384), InstrumentFamily.EL406, plate_type=PlateType.PLATE_384_WELL_PCR + ) + assert resolved.plate_type is PlateType.PLATE_384_WELL_PCR + + def test_a_named_format_the_model_does_not_offer_is_refused(self): + with pytest.raises(ValueError, match="does not offer"): + resolve(make_plate(96), InstrumentFamily.EL406, plate_type=PlateType.PLATE_6_WELL) + + def test_labware_no_format_matches_is_refused_naming_what_is_offered(self): + with pytest.raises(ValueError, match="works no 3x2 plate"): + resolve(make_plate(6), InstrumentFamily.EL406) + + def test_the_formats_that_share_a_shape_are_only_ever_named(self): + """Each of them differs from an ordinary plate in something a resource does not carry -- a well + shape, a flange, a tube -- so resolving one would be a guess.""" + assert PlateType.PLATE_96_HALF_WELL in SELECTION_ONLY + assert PlateType.PLATE_384_WELL_PCR in SELECTION_ONLY + assert PlateType.PLATE_1536_FLANGE in SELECTION_ONLY + assert PlateType.PLATE_96_WELL not in SELECTION_ONLY + + def test_the_same_labware_is_worked_at_different_heights_by_different_models(self): + """The format is the same; the heights it is worked at are the model's own.""" + plate = make_plate(96) + washer = resolve(plate, InstrumentFamily.EL406) + dispenser = resolve(plate, InstrumentFamily.MULTIFLO_FX) + assert washer.plate_type is dispenser.plate_type + assert washer.manifold_aspirate_height != dispenser.manifold_aspirate_height diff --git a/pylabrobot/agilent/biotek/lhc/tests/protocol_file_tests.py b/pylabrobot/agilent/biotek/lhc/tests/protocol_file_tests.py new file mode 100644 index 00000000000..a69dd5b0883 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/protocol_file_tests.py @@ -0,0 +1,153 @@ +"""Reading a protocol out of the document a protocol file holds, and writing one back. + +The document handling needs nothing but text. Encryption is what a real file adds on top, and those +tests skip themselves where the cipher is not installed, since it is an optional dependency. +""" + +from __future__ import annotations + +import pytest + +from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol +from pylabrobot.agilent.biotek.lhc.protocols.read_write_utilities import protocol_file +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime + +PRIME = "DV103|9|A|40|9|True|5|False|00:05" +"""One prime, as a protocol file stores it.""" + +DOCUMENT = ( + '\r\n' + "\r\n' + " 2.22.6\r\n" + " EL406\r\n" + " COM1\r\n" + " RINSE\r\n" + " 96 Well Plate\r\n" + " 4\r\n" + " \r\n" + " eStepActionRemark\r\n" + " -1\r\n" + " Rinse the manifold\r\n" + " \r\n" + " \r\n" + " eStepActionCustom\r\n" + " 9\r\n" + f" {PRIME}\r\n" + " \r\n" + "" +) +"""A protocol carrying one remark and one step that operates the instrument.""" + + +class TestReadingADocument: + """What a protocol file's own records become.""" + + def test_the_protocol_carries_what_the_file_says_about_itself(self): + protocol = protocol_file.from_xml(DOCUMENT) + assert protocol.protocol_name == "RINSE" + assert protocol.instrument_name == "EL406" + assert protocol.lhc_version == "2.22.6" + assert protocol.plate_type == "96 Well Plate" + assert protocol.plate_type_number == 4 + + def test_every_entry_is_kept_including_the_ones_that_do_not_operate_the_instrument(self): + protocol = protocol_file.from_xml(DOCUMENT) + assert len(protocol.entries) == 2 + assert protocol.entries[0].action is StepAction.REMARK + assert protocol.entries[1].action is StepAction.CUSTOM + + def test_only_the_entries_that_operate_the_instrument_are_steps(self): + protocol = protocol_file.from_xml(DOCUMENT) + assert len(protocol.device_entries) == 1 + assert protocol.device_entries[0].step_type is StepType.MANIFOLD_PRIME + + def test_reading_a_file_leaves_the_steps_alone_until_they_are_asked_for(self): + """A file whose steps cannot be read is still readable, writable and inspectable.""" + protocol = protocol_file.from_xml(DOCUMENT) + assert protocol.steps == [] + built = protocol.build_steps() + assert len(built) == 1 + assert isinstance(built[0], ManifoldPrime) + assert protocol.steps == built + + def test_an_entry_that_will_not_read_names_itself(self): + """Which is what a protocol from another instrument family looks like.""" + protocol = protocol_file.from_xml(DOCUMENT.replace(PRIME, "DV103|9|A|40")) + with pytest.raises(ValueError, match="MANIFOLD_PRIME"): + protocol.build_steps() + + def test_an_unknown_action_is_refused(self): + document = DOCUMENT.replace("eStepActionRemark", "eStepActionSomethingElse") + with pytest.raises(ValueError, match="unknown step action"): + protocol_file.from_xml(document) + + def test_text_that_is_not_a_protocol_is_refused(self): + with pytest.raises(ValueError, match="not a protocol document"): + protocol_file.from_xml("\r\n' + "\r\n' + " e406Basic\r\n" + " e96TubeDual\r\n" + " eAutoclavable\r\n" + " e16Tube\r\n" + " true\r\n" + " eWasher\r\n" + " false\r\n" + " true\r\n" + " false\r\n" + " true\r\n" + " true\r\n" + " true\r\n" + " true\r\n" + " eNotInstalled\r\n" + " false\r\n" + " false\r\n" + "" +) +"""A fitted-options document of the shape a protocol file carries.""" + + +class TestReadingTheDocument: + """Turning the text a protocol file stores into a record.""" + + def test_every_option_is_read(self): + settings = settings_document.from_xml(DOCUMENT) + assert settings.family is InstrumentFamily.EL406 + assert settings.washer_manifold is WasherManifold.TUBE_96_DUAL + assert settings.valve_box is ValveBox.WASHER + assert settings.peri_pump + assert not settings.peri_pump_2 + assert settings.strip_washer_manifold is StripWasherManifold.NOT_INSTALLED + + def test_a_document_round_trips(self): + assert settings_document.to_xml(settings_document.from_xml(DOCUMENT)) == DOCUMENT + + def test_the_defaults_round_trip(self): + default = InstrumentSettings() + assert settings_document.from_xml(settings_document.to_xml(default)) == default + + def test_an_element_an_older_release_did_not_write_keeps_its_default(self): + """Documents written before an option existed simply do not carry it.""" + without = DOCUMENT.replace(" false\r\n", "") + assert settings_document.from_xml(without).peri_wash_enabled is False + + @pytest.mark.parametrize("named", ["e50TS", "e406FX"]) + def test_a_model_this_package_does_not_work_with_is_refused(self, named: str): + """Every field below the model is read as that model would mean it, so guessing the model would + quietly misread the rest.""" + document = DOCUMENT.replace("e406Basic", named) + with pytest.raises(ValueError, match=named): + settings_document.from_xml(document) + + def test_text_that_is_not_a_document_is_refused(self): + with pytest.raises(ValueError, match="will not read"): + settings_document.from_xml("not a document") + + def test_the_shorter_buffer_switching_element_is_read_too(self): + """Some releases name that element without its suffix, and both names mean the same option.""" + document = DOCUMENT.replace( + "true", + "false", + ) + assert not settings_document.from_xml(document).buffer_switching + + def test_two_fields_are_not_in_a_document_at_all(self): + """How many bottles the syringe box holds, and whether the dispensers take the wider offsets, + are only ever learned by asking the instrument.""" + settings = settings_document.from_xml(DOCUMENT) + assert settings.syringe_box_size is SyringeBoxSize.UNKNOWN + assert not settings.advanced_dispense_offsets + + +class TestComparingWhatIsDeclaredWithWhatIsFitted: + """The one thing a protocol's declared options are used for.""" + + def test_an_instrument_equipped_the_same_way_agrees(self): + declared = settings_document.from_xml(DOCUMENT) + assert compare(declared, declared) + assert "equipped like this one" in str(compare(declared, declared)) + + def test_each_option_that_differs_is_named(self): + declared = settings_document.from_xml(DOCUMENT) + actual = InstrumentSettings( + family=InstrumentFamily.MULTIFLO_FX, + washer_manifold=WasherManifold.NOT_INSTALLED, + peri_pump_2=True, + ) + result = compare(declared, actual) + assert not result + named = {difference.option for difference in result.differences} + assert "instrument model" in named + assert "wash manifold" in named + assert "secondary peristaltic pump" in named + + def test_a_difference_reads_as_a_sentence(self): + declared = settings_document.from_xml(DOCUMENT) + actual = InstrumentSettings(family=InstrumentFamily.MULTIFLO) + difference = compare(declared, actual).differences[0] + assert "protocol says EL406" in str(difference) + assert "instrument reports MULTIFLO" in str(difference) + + def test_the_two_fields_a_document_omits_are_not_compared(self): + """Comparing them would report a difference against a default nobody declared.""" + declared = settings_document.from_xml(DOCUMENT) + actual = settings_document.from_xml(DOCUMENT) + actual.syringe_box_size = SyringeBoxSize.DOUBLE + actual.advanced_dispense_offsets = True + assert compare(declared, actual) + + +class TestAskingTheInstrument(unittest.IsolatedAsyncioTestCase): + """The query sequence, which differs per model.""" + + ANSWERS = { + CommandNumber.GET_SYRINGE_MANIFOLD_INSTALLED: bytes([1]), + CommandNumber.GET_SYRINGE_BOX_INFO: bytes([1, 2]), + CommandNumber.GET_SELECTED_PERI_INSTALLED: bytes([1]), + CommandNumber.GET_WASHER_MANIFOLD_INSTALLED: bytes([0]), + CommandNumber.GET_EXT_VALVE_MODULE_INSTALLED: bytes([1]), + CommandNumber.GET_VACUUM_FILTRATION_INSTALLED: bytes([0]), + CommandNumber.GET_ULTRASONIC_CLEANER_INSTALLED: bytes([1]), + CommandNumber.GET_CELL_WASHING_INSTALLED: bytes([1]), + CommandNumber.GET_IS_PERI_HALF_UL_SUPPORTED: bytes([1]), + CommandNumber.GET_Y_AXIS_INSTALLED: bytes([1]), + CommandNumber.IS_STRIP_WASHER_BOX_CONNECTED: bytes([1]), + CommandNumber.GET_STRIP_WASHER_HW_INSTALLED: bytes([1]), + CommandNumber.GET_STRIP_WASHER_MANIFOLD_TYPE: bytes([4]), + CommandNumber.GET_SINGLE_WELL_DISPENSER_INSTALLED: bytes([1]), + CommandNumber.GET_WHICH_BASECODE_IS_INSTALLED: bytes([int(Basecode.PERI_WASH)]), + CommandNumber.GET_FLUID_TRACKING_ENABLED: bytes([1]), + } + """What the fake instrument answers each option query with.""" + + async def read(self, family: InstrumentFamily, status: int = 0): + """Read the fitted options off a fake instrument. + + Args: + family: Which model to read as. + status: The status word the instrument answers with. + + Returns: + The settings and the fake instrument behind them. + """ + link, io = fake_link(answers=self.ANSWERS, status=status, family=family) + await link.setup() + return await settings_query.read_settings(link, family), io + + async def test_the_original_model_reads_its_own_options(self): + settings, io = await self.read(InstrumentFamily.EL406) + self.assertIs(settings.family, InstrumentFamily.EL406) + self.assertIs(settings.washer_manifold, WasherManifold.TUBE_96_DUAL) + self.assertIs(settings.valve_box, ValveBox.WASHER) + self.assertTrue(settings.peri_pump) + self.assertIs(settings.syringe_box_size, SyringeBoxSize.DOUBLE) + # It has one peristaltic pump, so the second is never asked about. + self.assertEqual(io.sent.count(int(CommandNumber.GET_SELECTED_PERI_INSTALLED)), 1) + + async def test_a_wash_only_model_is_not_asked_about_dispensers(self): + settings, io = await self.read(InstrumentFamily.MODEL_405_TS) + self.assertNotIn(int(CommandNumber.GET_SYRINGE_BOX_INFO), io.sent) + self.assertNotIn(int(CommandNumber.GET_SELECTED_PERI_INSTALLED), io.sent) + self.assertFalse(settings.peri_pump) + + async def test_a_dispenser_only_model_is_not_asked_about_a_wash_manifold(self): + settings, io = await self.read(InstrumentFamily.MULTIFLO) + self.assertNotIn(int(CommandNumber.GET_WASHER_MANIFOLD_INSTALLED), io.sent) + self.assertIs(settings.washer_manifold, WasherManifold.NOT_INSTALLED) + # It has two pumps, and both are asked about. + self.assertEqual(io.sent.count(int(CommandNumber.GET_SELECTED_PERI_INSTALLED)), 2) + + async def test_the_newest_model_reads_its_strip_washer_and_its_firmware(self): + settings, _ = await self.read(InstrumentFamily.MULTIFLO_FX) + self.assertIs(settings.strip_washer_manifold, StripWasherManifold.PLATE_96_WELL) + self.assertTrue(settings.single_well_enabled) + self.assertTrue(settings.peri_wash_enabled) + self.assertTrue(settings.advanced_dispense_offsets) + + async def test_an_absent_strip_washer_box_stops_the_manifold_being_asked_for(self): + answers = dict(self.ANSWERS) + answers[CommandNumber.IS_STRIP_WASHER_BOX_CONNECTED] = bytes([0]) + link, io = fake_link(answers=answers, family=InstrumentFamily.MULTIFLO_FX) + await link.setup() + settings = await settings_query.read_settings(link, InstrumentFamily.MULTIFLO_FX) + self.assertIs(settings.strip_washer_manifold, StripWasherManifold.NOT_INSTALLED) + self.assertNotIn(int(CommandNumber.GET_STRIP_WASHER_MANIFOLD_TYPE), io.sent) + + async def test_an_option_that_cannot_be_read_is_a_failure_rather_than_a_default(self): + """A record read half way would encode every step against the wrong instrument.""" + with self.assertRaises(BiotekError): + await self.read(InstrumentFamily.EL406, status=0x6029) diff --git a/pylabrobot/agilent/biotek/lhc/tests/step_payload_tests.py b/pylabrobot/agilent/biotek/lhc/tests/step_payload_tests.py new file mode 100644 index 00000000000..929247541f9 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/step_payload_tests.py @@ -0,0 +1,189 @@ +"""What each step type encodes itself as. + +The cross-reference next door compares nine of these against the single-device driver they replace, +which is the stronger check where it reaches. This covers what it cannot: the step types that driver +never had, the lengths every payload is padded to, and the places where what the instrument has +fitted changes the layout rather than the values. +""" + +from __future__ import annotations + +import pytest + +from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import StripWasherManifold +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import ShakeAxis +from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ShakeIntensity +from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe import Syringe +from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import SyringeBottle +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Shake, Soak +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps import STEP_CLASSES +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.shake_soak import ShakeSoak +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_aspirate import StripAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_dispense import StripDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_prime import SyringePrime + +SETTINGS = InstrumentSettings() +"""A plainly equipped instrument, which is what a step encodes against unless a test says otherwise.""" + +WIDER_OFFSETS = InstrumentSettings(advanced_dispense_offsets=True) +"""One whose dispensers take the wider offset range, which changes how an offset is packed.""" + +LENGTHS: dict[StepType, int] = { + StepType.PERI_DISPENSE: 23, + StepType.PERI_PRIME: 10, + StepType.PERI_PURGE: 10, + StepType.SYRINGE_DISPENSE: 25, + StepType.SYRINGE_PRIME: 12, + StepType.MANIFOLD_WASH: 101, + StepType.MANIFOLD_ASPIRATE: 21, + StepType.MANIFOLD_DISPENSE: 19, + StepType.MANIFOLD_PRIME: 12, + StepType.MANIFOLD_AUTO_CLEAN: 7, + StepType.SHAKE_SOAK: 11, + StepType.WASH_1536: 67, + StepType.STRIP_WASH: 108, + StepType.STRIP_ASPIRATE: 27, + StepType.STRIP_DISPENSE: 27, + StepType.STRIP_PRIME: 12, + StepType.PERI_WASH_ASPIRATE: 22, + StepType.PERI_WASH_DISPENSE: 26, +} +"""How many bytes each step type sends. A payload is padded to its length and never truncated.""" + + +STEP_TYPES: list[StepType] = sorted(LENGTHS, key=lambda member: member.value) +"""The step types, in the order they are numbered.""" + + +@pytest.mark.parametrize("step_type", STEP_TYPES) +def test_a_payload_is_the_length_its_step_type_sends(step_type: StepType): + assert len(STEP_CLASSES[step_type]().to_bytes(SETTINGS)) == LENGTHS[step_type] + + +def test_every_step_type_encodes(): + """Nothing is left to a base class that refuses: all eighteen have an encoder.""" + assert set(STEP_CLASSES) == set(LENGTHS) + for step_type, cls in STEP_CLASSES.items(): + assert isinstance(cls().to_bytes(SETTINGS), bytes), step_type.name + + +# --- what is fitted can change the layout, not just the values. + + +def test_the_wider_offset_range_repacks_a_peristaltic_dispense(): + """The offset across the well goes from one byte to two when the dispensers take the wider range, + which moves every field after it. The payload is the same length: the byte the offset grows into + was padding.""" + step = PeriDispense(positioning=Positioning(x=-30)) + narrow = step.to_bytes(SETTINGS) + wide = step.to_bytes(WIDER_OFFSETS) + assert narrow != wide + assert len(narrow) == len(wide) == LENGTHS[StepType.PERI_DISPENSE] + + +def test_the_wider_offset_range_lengthens_a_syringe_dispense(): + """Here there was no padding to grow into, so the payload itself is a byte longer.""" + step = SyringeDispense(positioning=Positioning(x=-30)) + assert len(step.to_bytes(SETTINGS)) == 25 + assert len(step.to_bytes(WIDER_OFFSETS)) == 26 + + +def test_a_strip_dispense_sends_its_vacuum_volume_whatever_manifold_is_fitted(): + fitted = InstrumentSettings(strip_washer_manifold=StripWasherManifold.PLATE_96_WELL) + step = StripDispense(volume=50) + assert len(step.to_bytes(SETTINGS)) == len(step.to_bytes(fitted)) + + +# --- a step a wash owns sends less than the same step standing alone. + + +@pytest.mark.parametrize( + "standalone, in_wash, shorter_by", + [ + (StripDispense(), StripDispense(in_wash=True), 7), + (StripAspirate(), StripAspirate(in_wash=True), 7), + ], +) +def test_a_strip_step_a_wash_owns_sends_seven_bytes_fewer( + standalone: StripDispense | StripAspirate, + in_wash: StripDispense | StripAspirate, + shorter_by: int, +): + """A wash selects the wells itself, so the steps it owns send no selections of their own.""" + assert len(standalone.to_bytes(SETTINGS)) - len(in_wash.to_bytes(SETTINGS)) == shorter_by + + +def test_a_wash_aspirate_sends_no_column_selection(): + """The plate washer's aspirate keeps its length either way and zeroes the selection instead.""" + columns = WellMask([1, 1] + [0] * 46) + standalone = ManifoldAspirate(columns=columns).to_bytes(SETTINGS) + in_wash = ManifoldAspirate(columns=columns, in_wash=True).to_bytes(SETTINGS) + assert len(standalone) == len(in_wash) + assert standalone != in_wash + # Only the selection differs, and only by being dropped. + differing = [i for i, (a, b) in enumerate(zip(standalone, in_wash)) if a != b] + assert all(in_wash[i] == 0 for i in differing) + + +def test_a_column_selection_reaches_the_wire_as_twelve_bits(): + """Forty-eight entries go out as twelve: the instrument reads two per block of eight, and the + rest are copies it never sees.""" + every_other = WellMask([1, 0] * 24) + first_two = WellMask([1, 1] + [0] * 46) + assert every_other.to_bits() != first_two.to_bits() + assert first_two.to_bits() == 0b11 + assert WellMask.all_columns().to_bits() == 0xFFF + + +# --- the fields that are counted differently on the wire than in the API. + + +@pytest.mark.parametrize( + "syringe, bottle, first_byte, bottle_byte", + [ + ("A", "A1", 0, 0), + ("B", "B2", 1, 3), + ("Both", "A1B1", 2, 4), + ], +) +def test_a_syringe_and_its_bottle_go_out_one_less_than_they_are_named( + syringe: Syringe, bottle: SyringeBottle, first_byte: int, bottle_byte: int +): + """Both are numbered from one in the vocabulary and from zero on the wire.""" + payload = SyringePrime(syringe=syringe, syringe_bottle=bottle).to_bytes(SETTINGS) + assert payload[0] == first_byte + assert payload[10] == bottle_byte + + +def test_shake_and_soak_durations_are_seconds(): + payload = ShakeSoak( + shake=Shake(enabled=True, duration=5), soak=Soak(enabled=True, duration=30) + ).to_bytes(SETTINGS) + assert int.from_bytes(payload[1:3], "little") == 5 + assert int.from_bytes(payload[5:7], "little") == 30 + + +@pytest.mark.parametrize( + "intensity, axis, intensity_byte, axis_byte", + [ + ("Variable", "X", 1, 0), + ("Slow", "X", 2, 0), + ("Medium", "X", 3, 0), + ("Fast", "Y", 4, 1), + ], +) +def test_a_shake_names_its_intensity_and_axis_by_number( + intensity: ShakeIntensity, axis: ShakeAxis, intensity_byte: int, axis_byte: int +): + payload = ShakeSoak( + shake=Shake(enabled=True, duration=5, axis=axis, intensity=intensity) + ).to_bytes(SETTINGS) + assert payload[3] == intensity_byte + assert payload[4] == axis_byte diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/payloads.tsv b/pylabrobot/agilent/biotek/lhc/tests/test_data/payloads.tsv new file mode 100644 index 00000000000..d022e18c3f9 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/test_data/payloads.tsv @@ -0,0 +1,530 @@ +# step definition payload +# Every payload below was checked against an independent implementation of the same +# wire format. A change to one of these bytes changes what reaches an instrument. +ManifoldAspirate DV101|7|False|2|0|28|-50|2|None|30|0|0|0|111111111111111111111111111111111111111111111111 00000002ce021c000000001e000000ff0f00000000 +ManifoldAspirate DV101|7|False|2|0|40|0|0|None|30|0|0|5.0|111111111111111111111111111111111111111111111111 00000002000028000000001e000000ff0f00000000 +ManifoldAspirate DV101|7|False|3|0|16|0|0|None|30|0|0|0|111111111111111111111111111111111111111111111111 00000003000010000000001e000000ff0f00000000 +ManifoldAspirate DV103|7|False|2|0|28|-28|0|None|30|0|0|0|111111111111111111111111111111111111111111111111 00000002e4001c000000001e000000ff0f00000000 +ManifoldAspirate DV103|7|False|2|0|28|-50|3|None|29|0|0|0|111111111111111111111111111111111111111111111111 00000002ce031c000000001d000000ff0f00000000 +ManifoldAspirate DV103|7|False|2|0|28|-50|3|None|30|0|0|0|111111111111111111111111111111111111111111111111 00000002ce031c000000001e000000ff0f00000000 +ManifoldAspirate DV103|7|False|2|0|40|0|0|None|42|0|0|0|111111111111111111111111111111111111111111111111 00000002000028000000002a000000ff0f00000000 +ManifoldAspirate DV103|7|False|3 CW|7|13|26|39|Point|12|24|36|0|111111111111111111111111111111111111111111111111 000700091a270d000118240c000000ff0f00000000 +ManifoldAspirate DV103|7|False|3|0|16|0|0|None|22|0|0|0|111111111111111111111111111111111111111111111111 000000030000100000000016000000ff0f00000000 +ManifoldAspirate DV103|7|True|1|5|58|0|0|None|29|0|0|0|111111111111111111111111111111111111111111111111 0105000100003a000000001d000000ff0f00000000 +ManifoldAspirate DV103|7|True|3|10|37|0|0|None|37|0|0|0|111111111111111111111111111111111111111111111111 010a00030000250000000025000000ff0f00000000 +ManifoldAspirate DV103|7|True|3|30|37|0|0|None|37|0|0|0|111111111111111111111111111111111111111111111111 011e00030000250000000025000000ff0f00000000 +ManifoldAutoClean 10|A|00:01 41010000000000 +ManifoldAutoClean 10|B|01:00 423c0000000000 +ManifoldAutoClean DV103|10|A|01:00 413c0000000000 +ManifoldAutoClean DV103|10|B|01:00 423c0000000000 +ManifoldAutoClean DV103|10|C|01:00 433c0000000000 +ManifoldAutoClean DV103|10|D|01:00 443c0000000000 +ManifoldDispense 8|A|300|6|120|0|0|False|50|9|False|10 412c0106000078000000090000000000000000 +ManifoldDispense 8|A|80|7|120|0|0|False|50|9|False|10 41500007000078000000090000000000000000 +ManifoldDispense DV103|8|A|100|7|120|0|0|False|0|9|False|0 41640007000078000000090000000000000000 +ManifoldDispense DV103|8|A|111|8|17|34|60|True|55|9|True|9 416f0008223c11003700090900000000000000 +ManifoldDispense DV103|8|A|200|5|120|0|0|False|50|9|True|0 41c80005000078000000090000000000000000 +ManifoldDispense DV103|8|A|200|7|120|0|0|False|0|9|False|0 41c80007000078000000090000000000000000 +ManifoldDispense DV103|8|A|200|7|120|0|0|False|0|9|True|40 41c80007000078000000092800000000000000 +ManifoldDispense DV103|8|A|300|5|118|-30|12|False|0|9|False|0 412c0105e20c76000000090000000000000000 +ManifoldDispense DV103|8|A|300|5|121|0|0|False|0|9|False|0 412c0105000079000000090000000000000000 +ManifoldDispense DV103|8|A|300|5|1|100|-100|False|0|9|False|0 412c0105649c01000000090000000000000000 +ManifoldDispense DV103|8|A|300|5|32767|-128|127|False|0|9|False|0 412c0105807fff7f0000090000000000000000 +ManifoldDispense DV103|8|A|300|6|120|0|0|False|50|9|True|0 412c0106000078000000090000000000000000 +ManifoldDispense DV103|8|A|300|6|120|0|0|False|50|9|True|10 412c0106000078000000090a00000000000000 +ManifoldDispense DV103|8|A|75|5|120|0|0|False|50|9|True|0 414b0005000078000000090000000000000000 +ManifoldDispense DV103|8|A|80|7|120|0|0|False|50|9|True|0 41500007000078000000090000000000000000 +ManifoldDispense DV103|8|B|100|7|120|0|0|False|0|9|False|0 42640007000078000000090000000000000000 +ManifoldDispense DV103|8|C|100|7|120|0|0|False|0|9|False|0 43640007000078000000090000000000000000 +ManifoldDispense DV103|8|D|100|7|120|0|0|False|0|9|False|0 44640007000078000000090000000000000000 +ManifoldPrime 9|A|175|9|True|25|False|00:01 41af00091900000000000000 +ManifoldPrime 9|A|300|9|True|200|False|00:01 412c0109c800000000000000 +ManifoldPrime 9|A|300|9|True|200|True|00:20 412c0109c800140000000000 +ManifoldPrime 9|A|55|9|True|5|True|04:00 413700090500f00000000000 +ManifoldPrime 9|B|175|9|True|25|False|00:01 42af00091900000000000000 +ManifoldPrime 9|B|300|9|True|200|False|00:01 422c0109c800000000000000 +ManifoldPrime 9|C|175|9|True|25|False|00:01 43af00091900000000000000 +ManifoldPrime 9|D|175|9|True|25|False|00:01 44af00091900000000000000 +ManifoldPrime DV101|9|A|300|9|True|200|True|00:05 412c0109c800050000000000 +ManifoldPrime DV101|9|B|600|9|True|200|True|00:02 42580209c800020000000000 +ManifoldPrime DV101|9|C|600|9|True|200|True|00:02 43580209c800020000000000 +ManifoldPrime DV103|9|A|100|9|True|100|False|00:05 416400096400000000000000 +ManifoldPrime DV103|9|A|100|9|True|100|False|00:20 416400096400000000000000 +ManifoldPrime DV103|9|A|100|9|True|50|True|00:20 416400093200140000000000 +ManifoldPrime DV103|9|A|150|9|True|150|False|00:01 419600099600000000000000 +ManifoldPrime DV103|9|A|150|9|True|150|False|00:05 419600099600000000000000 +ManifoldPrime DV103|9|A|150|9|True|150|True|00:20 419600099600140000000000 +ManifoldPrime DV103|9|A|250|9|True|5|False|00:05 41fa00090500000000000000 +ManifoldPrime DV103|9|A|300|9|True|200|False|00:05 412c0109c800000000000000 +ManifoldPrime DV103|9|A|300|9|True|200|False|00:20 412c0109c800000000000000 +ManifoldPrime DV103|9|A|300|9|True|200|True|00:02 412c0109c800020000000000 +ManifoldPrime DV103|9|A|300|9|True|200|True|00:05 412c0109c800050000000000 +ManifoldPrime DV103|9|A|300|9|True|5|False|00:05 412c01090500000000000000 +ManifoldPrime DV103|9|A|40|9|True|5|False|00:05 412800090500000000000000 +ManifoldPrime DV103|9|A|44|9|True|5|False|00:01 412c00090500000000000000 +ManifoldPrime DV103|9|A|55|9|True|5|True|04:00 413700090500f00000000000 +ManifoldPrime DV103|9|A|600|9|True|200|True|00:02 41580209c800020000000000 +ManifoldPrime DV103|9|A|95|9|False|5|False|00:00 415f00090000000000000000 +ManifoldPrime DV103|9|A|95|9|False|5|True|00:20 415f00090000140000000000 +ManifoldPrime DV103|9|B|100|9|True|25|False|00:01 426400091900000000000000 +ManifoldPrime DV103|9|B|100|9|True|5|False|00:01 426400090500000000000000 +ManifoldPrime DV103|9|B|100|9|True|5|False|00:05 426400090500000000000000 +ManifoldPrime DV103|9|B|40|9|True|5|False|00:05 422800090500000000000000 +ManifoldPrime DV103|9|B|45|9|True|5|False|00:05 422d00090500000000000000 +ManifoldPrime DV103|9|B|45|9|True|5|False|00:20 422d00090500000000000000 +ManifoldPrime DV103|9|B|95|9|True|5|False|00:05 425f00090500000000000000 +ManifoldPrime DV103|9|B|95|9|True|5|False|00:20 425f00090500000000000000 +ManifoldPrime DV103|9|C|100|9|True|25|False|00:01 436400091900000000000000 +ManifoldPrime DV103|9|C|100|9|True|5|False|00:01 436400090500000000000000 +ManifoldPrime DV103|9|C|100|9|True|5|False|00:05 436400090500000000000000 +ManifoldPrime DV103|9|C|40|9|True|5|False|00:05 432800090500000000000000 +ManifoldPrime DV103|9|C|45|9|True|5|False|00:05 432d00090500000000000000 +ManifoldPrime DV103|9|C|45|9|True|5|False|00:20 432d00090500000000000000 +ManifoldPrime DV103|9|C|95|9|True|5|False|00:05 435f00090500000000000000 +ManifoldPrime DV103|9|C|95|9|True|5|False|00:20 435f00090500000000000000 +ManifoldPrime DV103|9|D|100|9|True|25|False|00:01 446400091900000000000000 +ManifoldPrime DV103|9|D|100|9|True|5|False|00:01 446400090500000000000000 +ManifoldPrime DV103|9|D|100|9|True|5|False|00:05 446400090500000000000000 +ManifoldPrime DV103|9|D|40|9|True|5|False|00:05 442800090500000000000000 +ManifoldPrime DV103|9|D|45|9|True|5|False|00:05 442d00090500000000000000 +ManifoldPrime DV103|9|D|45|9|True|5|False|00:20 442d00090500000000000000 +ManifoldPrime DV103|9|D|95|9|True|5|False|00:05 445f00090500000000000000 +ManifoldPrime DV103|9|D|95|9|True|5|False|00:20 445f00090500000000000000 +ManifoldWash 6|Plate|15|3|False|False|False|True|False#8|A|250|7|120|0|0|False|50|9|False|10#8|False|4|0|26|0|1|None|30|0|0|5.0#8|A|100|7|115|0|0|False|50|9|False|10#11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#8|False|4|0|26|0|1|None|30|0|0|5.0 0001000f000341fa00070000780000000900000000000000000000000400011a000000001e0000000000000000000000000400011a000000001e00000000000000000041640007000073000000090000000000000000000000030000000000000000000000 +ManifoldWash 6|Plate|15|3|False|False|False|True|False#8|A|250|7|120|0|0|False|50|9|False|10#8|False|4|0|29|-48|3|None|30|0|0|5.0#8|A|300|9|120|0|0|False|50|9|False|10#11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#8|False|4|0|29|-49|3|None|30|0|0|5.0 0001000f000341fa000700007800000009000000000000000000000004cf031d000000001e00000000000000000000000004d0031d000000001e000000000000000000412c0109000078000000090000000000000000000000030000000000000000000000 +ManifoldWash 6|Plate|15|3|False|False|False|True|False#8|A|250|7|120|0|0|False|50|9|False|10#8|False|4|0|34|0|5|None|30|0|0|5.0#8|A|250|7|120|0|0|False|50|9|False|10#11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#8|False|4|0|34|0|5|None|30|0|0|5.0 0001000f000341fa000700007800000009000000000000000000000004000522000000001e00000000000000000000000004000522000000001e00000000000000000041fa0007000078000000090000000000000000000000030000000000000000000000 +ManifoldWash 6|Plate|15|3|False|False|False|True|False#8|A|250|7|120|0|0|False|50|9|False|10#8|False|4|0|34|0|5|None|30|0|0|5.0#8|A|250|9|120|0|0|False|50|9|False|10#11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#8|False|4|0|34|0|5|None|30|0|0|5.0 0001000f000341fa000700007800000009000000000000000000000004000522000000001e00000000000000000000000004000522000000001e00000000000000000041fa0009000078000000090000000000000000000000030000000000000000000000 +ManifoldWash 6|Plate|15|3|False|False|False|True|False#8|A|250|7|120|0|0|False|50|9|False|10#8|False|4|0|37|0|0|None|30|0|0|5.0#8|A|250|7|120|0|0|False|50|9|False|10#11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#8|False|4|0|37|0|0|None|30|0|0|5.0 0001000f000341fa000700007800000009000000000000000000000004000025000000001e00000000000000000000000004000025000000001e00000000000000000041fa0007000078000000090000000000000000000000030000000000000000000000 +ManifoldWash 6|Plate|15|3|False|False|False|True|False#8|A|250|7|120|0|0|False|50|9|False|10#8|False|6 CW|0|50|0|0|None|30|0|0|5.0#8|A|500|1|115|-45|0|False|50|9|True|50#11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#8|False|6 CW|0|50|0|0|None|30|0|0|5.0 0001000f000341fa000700007800000009000000000000000000000006000032000000001e00000000000000000000000006000032000000001e00000000000000000041f40101d30073000000093200000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|True|1 CW|5|58|0|0|None|29|0|0|0#DV103|8|A|200|5|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium|True|00:30#DV103|7|True|1 CW|5|58|0|0|None|37|0|0|0 0001000f0002412c01070000790000000900000000000000000105000700003a00000000250000000000000000000105000700003a000000001d00000000000000000041c80005000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|True|1 CW|5|58|0|0|None|29|0|0|0#DV103|8|A|75|5|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium|True|00:30#DV103|7|True|1 CW|5|58|0|0|None|37|0|0|0 0001000f0002412c01070000790000000900000000000000000105000700003a00000000250000000000000000000105000700003a000000001d000000000000000000414b0005000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|100|7|120|0|0|False|50|9|False|0#DV103|7|False|1 CW|0|58|0|0|None|22|0|0|0#DV103|8|A|50|9|128|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|True|00:30#DV103|7|False|1 CW|0|58|0|0|None|22|0|0|0 0001000f0002416400070000780000000900000000000000000000000700003a00000000160000000000000000000000000700003a00000000160000000000000000004132000900008000000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|1 CW|0|58|-20|0|None|29|0|0|0#DV103|8|A|100|9|128|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|True|00:30#DV103|7|False|1 CW|0|58|-20|0|None|37|0|0|0 0001000f0002412c010700007900000009000000000000000000000007ec003a000000002500000000000000000000000007ec003a000000001d0000000000000000004164000900008000000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|1 CW|0|58|-20|0|None|29|0|0|0#DV103|8|A|100|9|128|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium|True|00:30#DV103|7|False|1 CW|0|58|-20|0|None|37|0|0|0 0001000f0002412c010700007900000009000000000000000000000007ec003a000000002500000000000000000000000007ec003a000000001d0000000000000000004164000900008000000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|1 CW|0|58|-50|0|None|29|0|0|0#DV103|8|A|100|9|128|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|True|00:30#DV103|7|False|1 CW|0|58|-50|0|None|29|0|0|0 0001000f0002412c010700007900000009000000000000000000000007ce003a000000001d00000000000000000000000007ce003a000000001d0000000000000000004164000900008000000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|1 CW|0|58|-50|0|None|29|0|0|0#DV103|8|A|100|9|128|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium|True|00:30#DV103|7|False|1 CW|0|58|-50|0|None|37|0|0|0 0001000f0002412c010700007900000009000000000000000000000007ce003a000000002500000000000000000000000007ce003a000000001d0000000000000000004164000900008000000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|1 CW|0|58|0|0|None|29|0|0|0#DV103|8|A|50|9|128|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium|True|00:30#DV103|7|False|1 CW|0|58|0|0|None|37|0|0|0 0001000f0002412c01070000790000000900000000000000000000000700003a00000000250000000000000000000000000700003a000000001d0000000000000000004132000900008000000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|6 CW|0|45|0|0|None|29|0|0|0#DV103|8|A|200|5|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|True|00:30#DV103|7|False|6 CW|0|38|0|0|None|29|0|0|0 0001000f0002412c010700007900000009000000000000000000000006000026000000001d0000000000000000000000000600002d000000001d00000000000000000041c8000500007800000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|6 CW|0|45|0|0|None|29|0|0|0#DV103|8|A|200|5|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|True|00:30#DV103|7|False|6 CW|0|38|0|0|None|37|0|0|0 0001000f0002412c01070000790000000900000000000000000000000600002600000000250000000000000000000000000600002d000000001d00000000000000000041c8000500007800000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|2|False|False|False|True|True#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|6 CW|0|45|0|0|None|29|0|0|0#DV103|8|A|200|5|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium|True|00:30#DV103|7|False|6 CW|0|38|0|0|None|37|0|0|0 0001000f0002412c01070000790000000900000000000000000000000600002600000000250000000000000000000000000600002d000000001d00000000000000000041c8000500007800000009000000000000000001000003001e000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|100|7|120|0|0|False|50|9|False|0#DV103|7|False|3|0|26|0|0|None|22|0|0|0#DV103|8|A|100|7|115|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|3|0|26|0|0|None|22|0|0|0 0001000f0003416400070000780000000900000000000000000000000300001a00000000160000000000000000000000000300001a000000001600000000000000000041640007000073000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|100|7|120|0|0|False|50|9|False|0#DV103|7|False|4|0|32|-50|8|None|22|0|0|0#DV103|8|A|300|9|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|4|0|32|-50|8|None|22|0|0|0 0001000f00034164000700007800000009000000000000000000000004ce0820000000001600000000000000000000000004ce08200000000016000000000000000000412c0109000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|3|0|26|0|2|None|29|0|0|0#DV103|8|A|100|7|115|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|3|0|26|0|2|None|37|0|0|0 0001000f0003412c01070000790000000900000000000000000000000300021a00000000250000000000000000000000000300021a000000001d00000000000000000041640007000073000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|3|0|37|0|5|None|29|0|0|0#DV103|8|A|300|7|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|4|0|37|0|5|None|29|0|0|0 0001000f0003412c010700007900000009000000000000000000000004000525000000001d00000000000000000000000003000525000000001d000000000000000000412c0107000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|4|0|29|-48|5|None|29|0|0|0#DV103|8|A|300|9|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|4|0|29|-48|5|None|29|-48|0|0 0001000f0003412c010700007900000009000000000000000000000004d0051d0000d0001d00000000000000000000000004d0051d000000001d000000000000000000412c0109000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|4|0|29|-48|5|None|29|0|0|0#DV103|8|A|300|9|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|4|0|29|-48|5|None|29|0|0|0 0001000f0003412c010700007900000009000000000000000000000004d0051d000000001d00000000000000000000000004d0051d000000001d000000000000000000412c0109000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|4|0|32|-50|8|None|29|0|0|0#DV103|8|A|300|9|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|4|0|32|-50|8|None|34|-48|0|0 0001000f0003412c010700007900000009000000000000000000000004ce08200000d0002200000000000000000000000004ce0820000000001d000000000000000000412c0109000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|4|0|34|0|8|None|29|0|0|0#DV103|8|A|250|7|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|4|0|34|0|8|None|29|0|0|0 0001000f0003412c010700007900000009000000000000000000000004000822000000001d00000000000000000000000004000822000000001d00000000000000000041fa0007000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|4|0|37|0|5|None|29|0|0|0#DV103|8|A|250|7|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|4|0|37|0|5|None|37|0|0|0 0001000f0003412c010700007900000009000000000000000000000004000525000000002500000000000000000000000004000525000000001d00000000000000000041fa0007000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|5|0|29|-20|5|None|29|0|0|0#DV103|8|A|300|8|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|5|0|29|-20|5|None|29|-48|0|0 0001000f0003412c010700007900000009000000000000000000000005ec051d0000d0001d00000000000000000000000005ec051d000000001d000000000000000000412c0108000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|5|0|32|-20|0|None|29|0|0|0#DV103|8|A|300|8|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|5|0|32|-20|0|None|34|-48|0|0 0001000f0003412c010700007900000009000000000000000000000005ec00200000d0002200000000000000000000000005ec0020000000001d000000000000000000412c0108000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|5|0|34|12|0|None|29|0|0|0#DV103|8|A|250|7|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|5|0|34|12|0|None|29|0|0|0 0001000f0003412c0107000079000000090000000000000000000000050c0022000000001d000000000000000000000000050c0022000000001d00000000000000000041fa0007000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|5|0|37|12|0|None|29|0|0|0#DV103|8|A|250|7|120|0|0|False|50|9|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|5|0|37|12|0|None|37|0|0|0 0001000f0003412c0107000079000000090000000000000000000000050c00250000000025000000000000000000000000050c0025000000001d00000000000000000041fa0007000078000000090000000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|6 CW|0|50|0|0|None|29|0|0|0#DV103|8|A|100|1|115|0|0|False|50|9|True|25#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|6 CW|0|50|0|0|None|29|0|0|0 0001000f0003412c010700007900000009000000000000000000000006000032000000001d00000000000000000000000006000032000000001d00000000000000000041640001000073000000091900000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|6 CW|0|50|0|0|None|29|0|0|0#DV103|8|A|100|1|115|0|0|False|50|9|True|25#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|6 CW|0|50|0|0|None|37|0|0|0 0001000f0003412c010700007900000009000000000000000000000006000032000000002500000000000000000000000006000032000000001d00000000000000000041640001000073000000091900000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|6 CW|0|50|0|0|None|29|0|0|0#DV103|8|A|500|1|115|-45|0|False|50|9|True|50#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|6 CW|0|50|0|0|None|29|0|0|0 0001000f0003412c010700007900000009000000000000000000000006000032000000001d00000000000000000000000006000032000000001d00000000000000000041f40101d30073000000093200000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|15|3|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|6 CW|0|50|0|0|None|29|0|0|0#DV103|8|A|500|1|115|-45|0|False|50|9|True|50#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|6 CW|0|50|0|0|None|37|0|0|0 0001000f0003412c010700007900000009000000000000000000000006000032000000002500000000000000000000000006000032000000001d00000000000000000041f40101d30073000000093200000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|8|1|False|False|False|True|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|2 CW|0|50|0|0|None|29|0|0|0#DV103|8|A|50|1|115|0|0|False|50|9|True|25#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|6 CW|0|50|0|0|None|37|0|0|0 000100080001412c010700007900000009000000000000000000000006000032000000002500000000000000000000000008000032000000001d00000000000000000041320001000073000000091900000000000000000000030000000000000000000000 +ManifoldWash DV103|6|Plate|8|2|False|False|False|False|False#DV103|8|A|300|7|121|0|0|False|50|9|False|0#DV103|7|False|2 CW|0|50|0|0|None|29|0|0|0#DV103|8|A|50|2|115|0|0|False|50|9|True|25#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|6 CW|0|50|0|0|None|37|0|0|0 000000080002412c010700007900000009000000000000000000000006000032000000002500000000000000000000000008000032000000001d00000000000000000041320002000073000000091900000000000000000000030000000000000000000000 +PeriDispense 1|100|High|0|336|0|0|False|10|2|000010000000111111111111111111111111111111111111 640002000000500100000210f0ffffffff000100000000 +PeriDispense 1|10|High|0|336|0|0|False|10|2|001000000000000000000000111111111111111111111111 0a00020000005001000002040000ffffff000100000000 +PeriDispense 1|120|High|0|336|0|0|False|10|2|000001000000111111111111111111111111111111111111 780002000000500100000220f0ffffffff000100000000 +PeriDispense 1|140|High|0|336|0|0|False|10|2|000000100000111111111111111111111111111111111111 8c0002000000500100000240f0ffffffff000100000000 +PeriDispense 1|14|High|0|336|0|0|False|10|2|000100000000000000000000111111111111111111111111 0e00020000005001000002080000ffffff000100000000 +PeriDispense 1|160|High|0|336|0|0|False|10|2|000000010000111111111111111111111111111111111111 a00002000000500100000280f0ffffffff000100000000 +PeriDispense 1|180|High|0|336|0|0|False|10|2|000000001000111111111111111111111111111111111111 b40002000000500100000200f1ffffffff000100000000 +PeriDispense 1|18|High|0|336|0|0|False|10|2|000010000000000000000000111111111111111111111111 1200020000005001000002100000ffffff000100000000 +PeriDispense 1|200|High|0|336|0|0|False|10|2|000000000100111111111111111111111111111111111111 c80002000000500100000200f2ffffffff000100000000 +PeriDispense 1|200|High|0|336|0|0|True|10|2|111111111111111111111111111111111111111111111111 c8000200000050010a0002ffffffffffff000100000000 +PeriDispense 1|20|High|0|336|0|0|True|20|2|100000000000111111111111111111111111111111111111 140002000000500114000201f0ffffffff000100000000 +PeriDispense 1|220|High|0|336|0|0|False|10|2|000000000010111111111111111111111111111111111111 dc0002000000500100000200f4ffffffff000100000000 +PeriDispense 1|22|High|0|336|0|0|False|10|2|000001000000000000000000111111111111111111111111 1600020000005001000002200000ffffff000100000000 +PeriDispense 1|240|High|0|336|0|0|False|10|2|000000000001111111111111111111111111111111111111 f00002000000500100000200f8ffffffff000100000000 +PeriDispense 1|26|High|0|336|0|0|False|10|2|000000100000000000000000111111111111111111111111 1a00020000005001000002400000ffffff000100000000 +PeriDispense 1|2|High|1|336|0|0|True|2|4|100000000000000000000000111111111111111111111111 0200020100005001020004010000ffffff000100000000 +PeriDispense 1|30|High|0|336|0|0|False|10|2|000000010000000000000000111111111111111111111111 1e00020000005001000002800000ffffff000100000000 +PeriDispense 1|34|High|0|336|0|0|False|10|2|000000001000000000000000111111111111111111111111 2200020000005001000002000100ffffff000100000000 +PeriDispense 1|38|High|0|336|0|0|False|10|2|000000000100000000000000111111111111111111111111 2600020000005001000002000200ffffff000100000000 +PeriDispense 1|40|High|0|336|0|0|False|10|2|010000000000111111111111111111111111111111111111 280002000000500100000202f0ffffffff000100000000 +PeriDispense 1|42|High|0|336|0|0|False|10|2|000000000010000000000000111111111111111111111111 2a00020000005001000002000400ffffff000100000000 +PeriDispense 1|46|High|0|336|0|0|False|10|2|000000000001000000000000111111111111111111111111 2e00020000005001000002000800ffffff000100000000 +PeriDispense 1|50|High|0|336|0|0|False|10|2|000000000000100000000000111111111111111111111111 3200020000005001000002001000ffffff000100000000 +PeriDispense 1|54|High|0|336|0|0|False|10|2|000000000000010000000000111111111111111111111111 3600020000005001000002002000ffffff000100000000 +PeriDispense 1|58|High|0|336|0|0|False|10|2|000000000000001000000000111111111111111111111111 3a00020000005001000002004000ffffff000100000000 +PeriDispense 1|60|High|0|336|0|0|False|10|2|001000000000111111111111111111111111111111111111 3c0002000000500100000204f0ffffffff000100000000 +PeriDispense 1|62|High|0|336|0|0|False|10|2|000000000000000100000000111111111111111111111111 3e00020000005001000002008000ffffff000100000000 +PeriDispense 1|66|High|0|336|0|0|False|10|2|000000000000000010000000111111111111111111111111 4200020000005001000002000001ffffff000100000000 +PeriDispense 1|6|High|0|336|0|0|False|10|2|010000000000000000000000111111111111111111111111 0600020000005001000002020000ffffff000100000000 +PeriDispense 1|70|High|0|336|0|0|False|10|2|000000000000000001000000111111111111111111111111 4600020000005001000002000002ffffff000100000000 +PeriDispense 1|74|High|0|336|0|0|False|10|2|000000000000000000100000111111111111111111111111 4a00020000005001000002000004ffffff000100000000 +PeriDispense 1|78|High|0|336|0|0|False|10|2|000000000000000000010000111111111111111111111111 4e00020000005001000002000008ffffff000100000000 +PeriDispense 1|80|High|0|336|0|0|False|10|2|000100000000111111111111111111111111111111111111 500002000000500100000208f0ffffffff000100000000 +PeriDispense 1|82|High|0|336|0|0|False|10|2|000000000000000000001000111111111111111111111111 5200020000005001000002000010ffffff000100000000 +PeriDispense 1|86|High|0|336|0|0|False|10|2|000000000000000000000100111111111111111111111111 5600020000005001000002000020ffffff000100000000 +PeriDispense 1|90|High|0|336|0|0|False|10|2|000000000000000000000010111111111111111111111111 5a00020000005001000002000040ffffff000100000000 +PeriDispense 1|94|High|0|336|0|0|False|10|2|000000000000000000000001111111111111111111111111 5e00020000005001000002000080ffffff000100000000 +PeriDispense DV101|1|6|Low|1|254|0|0|True|6|2|010100000000000000111111111111000000000000001010 060000010000fe000600020a00fc3f0050000100000000 +PeriDispense DV103|1|100|High|0|336|0|0|False|10|2|000010000000000000000000000000000000000000000000|1111|1 6400020000005001000002100000000000000100000000 +PeriDispense DV103|1|100|High|0|336|0|0|False|20|2|000010000000000000000000000000000000000000000000|1111|1 6400020000005001000002100000000000000100000000 +PeriDispense DV103|1|100|High|0|336|0|0|True|100|2|000010000000000000000000000000000000000000000000|1111|1 6400020000005001640002100000000000000100000000 +PeriDispense DV103|1|10|High|0|333|0|0|False|10|2|001000000000000000000000000000000000000000000000|1111|1 0a00020000004d01000002040000000000000100000000 +PeriDispense DV103|1|10|High|0|333|0|0|False|10|4|001000000000000000000000000000000000000000000000|1111|1 0a00020000004d01000004040000000000000100000000 +PeriDispense DV103|1|10|High|0|333|0|0|True|10|2|111111111111111111111111111111111111111111111111|1111|1 0a00020000004d010a0002ffffffffffff000100000000 +PeriDispense DV103|1|120|High|0|336|0|0|False|10|2|000001000000000000000000000000000000000000000000|1111|1 7800020000005001000002200000000000000100000000 +PeriDispense DV103|1|120|High|0|336|0|0|False|20|2|000001000000000000000000000000000000000000000000|1111|1 7800020000005001000002200000000000000100000000 +PeriDispense DV103|1|120|High|0|336|0|0|True|120|2|000001000000000000000000000000000000000000000000|1111|1 7800020000005001780002200000000000000100000000 +PeriDispense DV103|1|140|High|0|336|0|0|False|10|2|000000100000000000000000000000000000000000000000|1111|1 8c00020000005001000002400000000000000100000000 +PeriDispense DV103|1|140|High|0|336|0|0|False|20|2|000000100000000000000000000000000000000000000000|1111|1 8c00020000005001000002400000000000000100000000 +PeriDispense DV103|1|140|High|0|336|0|0|True|140|2|000000100000000000000000000000000000000000000000|1111|1 8c000200000050018c0002400000000000000100000000 +PeriDispense DV103|1|14|High|0|333|0|0|False|10|2|000100000000000000000000000000000000000000000000|1111|1 0e00020000004d01000002080000000000000100000000 +PeriDispense DV103|1|14|High|0|333|0|0|False|10|4|000100000000000000000000000000000000000000000000|1111|1 0e00020000004d01000004080000000000000100000000 +PeriDispense DV103|1|160|High|0|336|0|0|False|10|2|000000010000000000000000000000000000000000000000|1111|1 a000020000005001000002800000000000000100000000 +PeriDispense DV103|1|160|High|0|336|0|0|False|20|2|000000010000000000000000000000000000000000000000|1111|1 a000020000005001000002800000000000000100000000 +PeriDispense DV103|1|160|High|0|336|0|0|True|160|2|000000010000000000000000000000000000000000000000|1111|1 a000020000005001a00002800000000000000100000000 +PeriDispense DV103|1|180|High|0|336|0|0|False|10|2|000000001000000000000000000000000000000000000000|1111|1 b400020000005001000002000100000000000100000000 +PeriDispense DV103|1|180|High|0|336|0|0|False|20|2|000000001000000000000000000000000000000000000000|1111|1 b400020000005001000002000100000000000100000000 +PeriDispense DV103|1|180|High|0|336|0|0|True|180|2|000000001000000000000000000000000000000000000000|1111|1 b400020000005001b40002000100000000000100000000 +PeriDispense DV103|1|18|High|0|333|0|0|False|10|2|000010000000000000000000000000000000000000000000|1111|1 1200020000004d01000002100000000000000100000000 +PeriDispense DV103|1|18|High|0|333|0|0|False|10|4|000010000000000000000000000000000000000000000000|1111|1 1200020000004d01000004100000000000000100000000 +PeriDispense DV103|1|200|High|0|336|0|0|False|10|2|000000000100000000000000000000000000000000000000|1111|1 c800020000005001000002000200000000000100000000 +PeriDispense DV103|1|200|High|0|336|0|0|False|20|2|000000000100000000000000000000000000000000000000|1111|1 c800020000005001000002000200000000000100000000 +PeriDispense DV103|1|200|High|0|336|0|0|True|10|2|111111111111111111111111111111111111111111111111|1111|1 c8000200000050010a0002ffffffffffff000100000000 +PeriDispense DV103|1|200|High|0|336|0|0|True|200|2|000000000100000000000000000000000000000000000000|1111|1 c800020000005001c80002000200000000000100000000 +PeriDispense DV103|1|20|High|0|336|0|0|True|20|2|100000000000000000000000000000000000000000000000|1111|1 1400020000005001140002010000000000000100000000 +PeriDispense DV103|1|220|High|0|336|0|0|False|10|2|000000000010000000000000000000000000000000000000|1111|1 dc00020000005001000002000400000000000100000000 +PeriDispense DV103|1|220|High|0|336|0|0|False|20|2|000000000010000000000000000000000000000000000000|1111|1 dc00020000005001000002000400000000000100000000 +PeriDispense DV103|1|220|High|0|336|0|0|True|220|2|000000000010000000000000000000000000000000000000|1111|1 dc00020000005001dc0002000400000000000100000000 +PeriDispense DV103|1|22|High|0|333|0|0|False|10|2|000001000000000000000000000000000000000000000000|1111|1 1600020000004d01000002200000000000000100000000 +PeriDispense DV103|1|22|High|0|333|0|0|False|10|4|000001000000000000000000000000000000000000000000|1111|1 1600020000004d01000004200000000000000100000000 +PeriDispense DV103|1|240|High|0|336|0|0|False|10|2|000000000001000000000000000000000000000000000000|1111|1 f000020000005001000002000800000000000100000000 +PeriDispense DV103|1|240|High|0|336|0|0|False|20|2|000000000001000000000000000000000000000000000000|1111|1 f000020000005001000002000800000000000100000000 +PeriDispense DV103|1|240|High|0|336|0|0|True|240|2|000000000001000000000000000000000000000000000000|1111|1 f000020000005001f00002000800000000000100000000 +PeriDispense DV103|1|26|High|0|333|0|0|False|10|2|000000100000000000000000000000000000000000000000|1111|1 1a00020000004d01000002400000000000000100000000 +PeriDispense DV103|1|26|High|0|333|0|0|False|10|4|000000100000000000000000000000000000000000000000|1111|1 1a00020000004d01000004400000000000000100000000 +PeriDispense DV103|1|2|High|1|333|0|0|True|10|4|100000000000000000000000000000000000000000000000|1111|1 0200020100004d010a0004010000000000000100000000 +PeriDispense DV103|1|2|High|1|333|0|0|True|2|4|100000000000000000000000000000000000000000000000|1111|1 0200020100004d01020004010000000000000100000000 +PeriDispense DV103|1|30|High|0|333|0|0|False|10|2|000000010000000000000000000000000000000000000000|1111|1 1e00020000004d01000002800000000000000100000000 +PeriDispense DV103|1|30|High|0|333|0|0|False|10|4|000000010000000000000000000000000000000000000000|1111|1 1e00020000004d01000004800000000000000100000000 +PeriDispense DV103|1|34|High|0|333|0|0|False|10|2|000000001000000000000000000000000000000000000000|1111|1 2200020000004d01000002000100000000000100000000 +PeriDispense DV103|1|34|High|0|333|0|0|False|10|4|000000001000000000000000000000000000000000000000|1111|1 2200020000004d01000004000100000000000100000000 +PeriDispense DV103|1|38|High|0|333|0|0|False|10|2|000000000100000000000000000000000000000000000000|1111|1 2600020000004d01000002000200000000000100000000 +PeriDispense DV103|1|38|High|0|333|0|0|False|10|4|000000000100000000000000000000000000000000000000|1111|1 2600020000004d01000004000200000000000100000000 +PeriDispense DV103|1|40|High|0|336|0|0|False|10|2|010000000000000000000000000000000000000000000000|1111|1 2800020000005001000002020000000000000100000000 +PeriDispense DV103|1|40|High|0|336|0|0|False|20|2|010000000000000000000000000000000000000000000000|1111|1 2800020000005001000002020000000000000100000000 +PeriDispense DV103|1|40|High|0|336|0|0|True|40|2|010000000000000000000000000000000000000000000000|1111|1 2800020000005001280002020000000000000100000000 +PeriDispense DV103|1|42|High|0|333|0|0|False|10|2|000000000010000000000000000000000000000000000000|1111|1 2a00020000004d01000002000400000000000100000000 +PeriDispense DV103|1|42|High|0|333|0|0|False|10|4|000000000010000000000000000000000000000000000000|1111|1 2a00020000004d01000004000400000000000100000000 +PeriDispense DV103|1|46|High|0|333|0|0|False|10|2|000000000001000000000000000000000000000000000000|1111|1 2e00020000004d01000002000800000000000100000000 +PeriDispense DV103|1|46|High|0|333|0|0|False|10|4|000000000001000000000000000000000000000000000000|1111|1 2e00020000004d01000004000800000000000100000000 +PeriDispense DV103|1|50|High|0|333|0|0|False|10|2|000000000000100000000000000000000000000000000000|1111|1 3200020000004d01000002001000000000000100000000 +PeriDispense DV103|1|50|High|0|333|0|0|False|10|4|000000000000100000000000000000000000000000000000|1111|1 3200020000004d01000004001000000000000100000000 +PeriDispense DV103|1|54|High|0|333|0|0|False|10|2|000000000000010000000000000000000000000000000000|1111|1 3600020000004d01000002002000000000000100000000 +PeriDispense DV103|1|54|High|0|333|0|0|False|10|4|000000000000010000000000000000000000000000000000|1111|1 3600020000004d01000004002000000000000100000000 +PeriDispense DV103|1|58|High|0|333|0|0|False|10|2|000000000000001000000000000000000000000000000000|1111|1 3a00020000004d01000002004000000000000100000000 +PeriDispense DV103|1|58|High|0|333|0|0|False|10|4|000000000000001000000000000000000000000000000000|1111|1 3a00020000004d01000004004000000000000100000000 +PeriDispense DV103|1|60|High|0|336|0|0|False|10|2|001000000000000000000000000000000000000000000000|1111|1 3c00020000005001000002040000000000000100000000 +PeriDispense DV103|1|60|High|0|336|0|0|False|20|2|001000000000000000000000000000000000000000000000|1111|1 3c00020000005001000002040000000000000100000000 +PeriDispense DV103|1|60|High|0|336|0|0|True|60|2|001000000000000000000000000000000000000000000000|1111|1 3c000200000050013c0002040000000000000100000000 +PeriDispense DV103|1|62|High|0|333|0|0|False|10|2|000000000000000100000000000000000000000000000000|1111|1 3e00020000004d01000002008000000000000100000000 +PeriDispense DV103|1|62|High|0|333|0|0|False|10|4|000000000000000100000000000000000000000000000000|1111|1 3e00020000004d01000004008000000000000100000000 +PeriDispense DV103|1|66|High|0|333|0|0|False|10|2|000000000000000010000000000000000000000000000000|1111|1 4200020000004d01000002000001000000000100000000 +PeriDispense DV103|1|66|High|0|333|0|0|False|10|4|000000000000000010000000000000000000000000000000|1111|1 4200020000004d01000004000001000000000100000000 +PeriDispense DV103|1|6|High|0|333|0|0|False|10|2|010000000000000000000000000000000000000000000000|1111|1 0600020000004d01000002020000000000000100000000 +PeriDispense DV103|1|6|High|0|333|0|0|False|10|4|010000000000000000000000000000000000000000000000|1111|1 0600020000004d01000004020000000000000100000000 +PeriDispense DV103|1|6|Low|1|254|0|0|True|6|2|010100000000000000111111111111000000000000000110|1111|1 060000010000fe000600020a00fc3f0060000100000000 +PeriDispense DV103|1|6|Low|1|254|0|0|True|6|2|010100000000000000111111111111000000000000001010|1111|1 060000010000fe000600020a00fc3f0050000100000000 +PeriDispense DV103|1|6|Low|1|254|0|0|True|6|2|010100000000000000111111111111000000000000001010|1111|2 060000010000fe000600020a00fc3f0050000200000000 +PeriDispense DV103|1|70|High|0|333|0|0|False|10|2|000000000000000001000000000000000000000000000000|1111|1 4600020000004d01000002000002000000000100000000 +PeriDispense DV103|1|70|High|0|333|0|0|False|10|4|000000000000000001000000000000000000000000000000|1111|1 4600020000004d01000004000002000000000100000000 +PeriDispense DV103|1|74|High|0|333|0|0|False|10|2|000000000000000000100000000000000000000000000000|1111|1 4a00020000004d01000002000004000000000100000000 +PeriDispense DV103|1|74|High|0|333|0|0|False|10|4|000000000000000000100000000000000000000000000000|1111|1 4a00020000004d01000004000004000000000100000000 +PeriDispense DV103|1|78|High|0|333|0|0|False|10|2|000000000000000000010000000000000000000000000000|1111|1 4e00020000004d01000002000008000000000100000000 +PeriDispense DV103|1|78|High|0|333|0|0|False|10|4|000000000000000000010000000000000000000000000000|1111|1 4e00020000004d01000004000008000000000100000000 +PeriDispense DV103|1|80|High|0|336|0|0|False|10|2|000100000000000000000000000000000000000000000000|1111|1 5000020000005001000002080000000000000100000000 +PeriDispense DV103|1|80|High|0|336|0|0|False|20|2|000100000000000000000000000000000000000000000000|1111|1 5000020000005001000002080000000000000100000000 +PeriDispense DV103|1|80|High|0|336|0|0|True|80|2|000100000000000000000000000000000000000000000000|1111|1 5000020000005001500002080000000000000100000000 +PeriDispense DV103|1|82|High|0|333|0|0|False|10|2|000000000000000000001000000000000000000000000000|1111|1 5200020000004d01000002000010000000000100000000 +PeriDispense DV103|1|82|High|0|333|0|0|False|10|4|000000000000000000001000000000000000000000000000|1111|1 5200020000004d01000004000010000000000100000000 +PeriDispense DV103|1|86|High|0|333|0|0|False|10|2|000000000000000000000100000000000000000000000000|1111|1 5600020000004d01000002000020000000000100000000 +PeriDispense DV103|1|86|High|0|333|0|0|False|10|4|000000000000000000000100000000000000000000000000|1111|1 5600020000004d01000004000020000000000100000000 +PeriDispense DV103|1|90|High|0|333|0|0|False|10|2|000000000000000000000010000000000000000000000000|1111|1 5a00020000004d01000002000040000000000100000000 +PeriDispense DV103|1|90|High|0|333|0|0|False|10|4|000000000000000000000010000000000000000000000000|1111|1 5a00020000004d01000004000040000000000100000000 +PeriDispense DV103|1|94|High|0|333|0|0|False|10|2|000000000000000000000001000000000000000000000000|1111|1 5e00020000004d01000002000080000000000100000000 +PeriDispense DV103|1|94|High|0|333|0|0|False|10|4|000000000000000000000001000000000000000000000000|1111|1 5e00020000004d01000004000080000000000100000000 +PeriPrime 2|True|1390|3|High|True 6e050000020100010000 +PeriPrime 2|True|230|3|High|True e6000000020100010000 +PeriPrime 2|True|800|3|High|True 20030000020100010000 +PeriPrime DV103|2|True|1390|3|High|True|0|1 6e050000020100010000 +PeriPrime DV103|2|True|1390|3|High|True|0|2 6e050000020100020000 +PeriPrime DV103|2|True|1390|3|High|True|3|1 6e050000020103010000 +PeriPrime DV103|2|True|1390|3|High|True|3|1|True|3 6e050000020103010000 +PeriPrime DV103|2|True|1390|3|High|True|3|2 6e050000020103020000 +PeriPrime DV103|2|True|1390|3|High|True|3|2|True|3 6e050000020103020000 +PeriPrime DV103|2|True|230|3|High|True|0|1 e6000000020100010000 +PeriPrime DV103|2|True|230|3|High|True|0|2 e6000000020100020000 +PeriPrime DV103|2|True|230|3|High|True|1|1 e6000000020101010000 +PeriPrime DV103|2|True|230|3|High|True|1|1|True|3 e6000000020101010000 +PeriPrime DV103|2|True|230|3|High|True|1|2 e6000000020101020000 +PeriPrime DV103|2|True|230|3|High|True|1|2|True|3 e6000000020101020000 +PeriPrime DV103|2|True|250|3|High|True|0|1 fa000000020100010000 +PeriPrime DV103|2|True|250|3|High|True|1|1 fa000000020101010000 +PeriPrime DV103|2|True|250|3|High|True|253|1 fa0000000201fd010000 +PeriPrime DV103|2|True|250|3|High|True|254|1 fa0000000201fe010000 +PeriPrime DV103|2|True|250|3|High|True|2|1 fa000000020102010000 +PeriPrime DV103|2|True|250|3|High|True|3|1 fa000000020103010000 +PeriPrime DV103|2|True|300|3|High|True|0|1 2c010000020100010000 +PeriPrime DV103|2|True|300|3|High|True|0|2 2c010000020100020000 +PeriPrime DV103|2|True|300|3|Low|True|0|1 2c010000000100010000 +PeriPrime DV103|2|True|300|3|Low|True|0|2 2c010000000100020000 +PeriPrime DV103|2|True|300|3|Medium|True|0|1 2c010000010100010000 +PeriPrime DV103|2|True|300|3|Medium|True|0|2 2c010000010100020000 +PeriPrime DV103|2|True|800|3|High|True|0|1 20030000020100010000 +PeriPrime DV103|2|True|800|3|High|True|0|2 20030000020100020000 +PeriPrime DV103|2|True|800|3|High|True|2|1 20030000020102010000 +PeriPrime DV103|2|True|800|3|High|True|2|1|True|2 20030000020102010000 +PeriPrime DV103|2|True|800|3|High|True|2|1|True|3 20030000020102010000 +PeriPrime DV103|2|True|800|3|High|True|2|2 20030000020102020000 +PeriPrime DV103|2|True|800|3|High|True|2|2|True|2 20030000020102020000 +PeriPrime DV103|2|True|800|3|High|True|2|2|True|3 20030000020102020000 +PeriPurge DV103|3|False|250|7|High|True|0|1 00000700020100010000 +PeriPurge DV103|3|False|250|7|High|True|1|1 00000700020101010000 +PeriPurge DV103|3|False|250|7|High|True|253|1 000007000201fd010000 +PeriPurge DV103|3|False|250|7|High|True|254|1 000007000201fe010000 +PeriPurge DV103|3|False|250|7|High|True|2|1 00000700020102010000 +PeriPurge DV103|3|False|250|7|High|True|3|1 00000700020103010000 +PeriPurge DV103|3|True|300|3|High|True|0|1 2c010000020100010000 +PeriPurge DV103|3|True|300|3|High|True|0|2 2c010000020100020000 +PeriPurge DV103|3|True|300|3|Low|True|0|1 2c010000000100010000 +PeriPurge DV103|3|True|300|3|Low|True|0|2 2c010000000100020000 +PeriPurge DV103|3|True|300|3|Medium|True|0|1 2c010000010100010000 +PeriPurge DV103|3|True|300|3|Medium|True|0|2 2c010000010100020000 +PeriRandomAccessDispense DV103|1|100|High|0|378|0|0|True|10|2|111111111111111111111111111111111111111111111111|1111|1|True|3|000000470600A90A00A90A00E70A00A50A00A90600000000000000000000000000000000000000000000000000000000 6400020000007a010a0002000000470600a90a00a90a00e70a00a50a00a9060000000000000000000000000000000000000000000000000000000001000000000000 +PeriRandomAccessDispense DV103|1|100|High|0|378|0|0|True|10|2|111111111111111111111111111111111111111111111111|1111|2|True|3|000000470600A90A00A90A00E70A00A50A00A90600000000000000000000000000000000000000000000000000000000 6400020000007a010a0002000000470600a90a00a90a00e70a00a50a00a9060000000000000000000000000000000000000000000000000000000002000000000000 +PeriRandomAccessDispense DV103|1|10|High|0|378|0|0|True|10|2|111111111111111111111111111111111111111111111111|1111|2|True|255|010000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 0a00020000007a010a000201000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000 +PeriRandomAccessDispense DV103|1|1120|Low|2|125|0|0|False|10|2|111111111111111111111111111111111111111111111111|1111|1|True|2|2100000C00000C0000210000000000000000000000000000000000000000000000000000000000000000000000000000 6004000000007d000000022100000c00000c000021000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000 +PeriRandomAccessDispense DV103|1|1120|Low|2|125|0|0|False|10|2|111111111111111111111111111111111111111111111111|1111|2|True|2|2100000C00000C0000210000000000000000000000000000000000000000000000000000000000000000000000000000 6004000000007d000000022100000c00000c000021000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000 +PeriRandomAccessDispense DV103|1|50|High|0|375|0|0|False|10|2|111111111111111111111111111111111111111111111111|1111|1|True|3|00000077E71C77492277492200472277492277492277E71C00000080EF4500222400221400E20C00221400222400E245 320002000000770100000200000077e71c77492277492200472277492277492277e71c00000080ef4500222400221400e20c00221400222400e24501000000000000 +PeriRandomAccessDispense DV103|1|50|High|0|375|0|0|False|10|2|111111111111111111111111111111111111111111111111|1111|2|True|3|00000077E71C77492277492200472277492277492277E71C00000080EF4500222400221400E20C00221400222400E245 320002000000770100000200000077e71c77492277492200472277492277492277e71c00000080ef4500222400221400e20c00221400222400e24502000000000000 +PeriWashAspirate DV103|17|0|2|30|0|0|1|111111111111111111111111111111111111111111111111|1111 0000020000001e00ffffffffffff0001000000000000 +PeriWashAspirate DV103|17|100|0|125|50|0|2|111111111111111111111111111111111111111111111111|1111 6400003200007d00ffffffffffff0002000000000000 +PeriWashAspirate DV103|17|100|0|134|50|0|2|111111111111111111111111111111111111111111111111|1111 6400003200008600ffffffffffff0002000000000000 +PeriWashAspirate DV103|17|1|2|30|0|0|1|111111111111111111111111111111111111111111111111|1111 0100020000001e00ffffffffffff0001000000000000 +PeriWashAspirate DV103|17|50|2|30|0|0|1|111111111111111111111111111111111111111111111111|1111 3200020000001e00ffffffffffff0001000000000000 +PeriWashAspirate DV103|17|65535|2|30|0|0|1|111111111111111111111111111111111111111111111111|1111 ffff020000001e00ffffffffffff0001000000000000 +PeriWashAspirate DV103|17|999|2|30|0|0|1|111111111111111111111111111111111111111111111111|1111 e703020000001e00ffffffffffff0001000000000000 +PeriWashDispense DV103|18|0|2|30|0|0|1|True|25|2|111111111111111111111111111111111111111111111111|1111 0000020000001e00190002ffffffffffff000100000000000000 +PeriWashDispense DV103|18|100|0|111|50|0|1|True|25|2|111111111111111111111111111111111111111111111111|1111 6400003200006f00190002ffffffffffff000100000000000000 +PeriWashDispense DV103|18|100|0|119|50|0|1|True|25|2|111111111111111111111111111111111111111111111111|1111 6400003200007700190002ffffffffffff000100000000000000 +PeriWashDispense DV103|18|100|2|30|0|0|1|False|0|2|111111111111111111111111111111111111111111111111|1111 6400020000001e00000002ffffffffffff000100000000000000 +PeriWashDispense DV103|18|100|2|30|0|0|1|True|75|5|111111111111111111111111111111111111111111111111|1111 6400020000001e004b0005ffffffffffff000100000000000000 +PeriWashDispense DV103|18|1|2|30|0|0|1|True|25|2|111111111111111111111111111111111111111111111111|1111 0100020000001e00190002ffffffffffff000100000000000000 +PeriWashDispense DV103|18|50|2|30|0|0|1|True|25|2|111111111111111111111111111111111111111111111111|1111 3200020000001e00190002ffffffffffff000100000000000000 +PeriWashDispense DV103|18|50|5|111|0|0|1|True|50|4|111111111111111111111111111111111111111111111111|1111 3200050000006f00320004ffffffffffff000100000000000000 +PeriWashDispense DV103|18|50|5|125|0|0|2|True|50|4|111111111111111111111111111111111111111111111111|1111 3200050000007d00320004ffffffffffff000200000000000000 +PeriWashDispense DV103|18|65535|2|30|0|0|1|True|25|2|111111111111111111111111111111111111111111111111|1111 ffff020000001e00190002ffffffffffff000100000000000000 +PeriWashDispense DV103|18|999|2|30|0|0|1|True|25|2|111111111111111111111111111111111111111111111111|1111 e703020000001e00190002ffffffffffff000100000000000000 +ShakeSoak DV103|11|True|True|00:01|X-axis|1|True|01:00 01010003003c0000000000 +ShakeSoak DV103|11|True|True|00:01|X-axis|Medium (5 Hz)|True|01:00 01010003003c0000000000 +ShakeSoak DV103|11|True|True|00:01|X-axis|Slow (3.5 Hz)|True|01:00 01010002003c0000000000 +ShakeSoak DV103|11|True|True|00:17|X-axis|Fast (8 Hz)|True|00:45 01110004002d0000000000 +ShakeSoak DV103|11|True|True|00:17|X-axis|Medium (5 Hz)|True|00:45 01110003002d0000000000 +ShakeSoak DV103|11|True|True|00:17|X-axis|Slow (3.5 Hz)|True|00:45 01110002002d0000000000 +ShakeSoak DV103|11|True|True|00:17|X-axis|Variable|True|00:45 01110001002d0000000000 +ShakeSoak DV103|11|True|True|00:17|Y-axis|Fast (8 Hz)|True|00:45 01110004012d0000000000 +ShakeSoak DV103|11|True|True|00:17|Y-axis|Medium (5 Hz)|True|00:45 01110003012d0000000000 +ShakeSoak DV103|11|True|True|00:17|Y-axis|Slow (3.5 Hz)|True|00:45 01110002012d0000000000 +ShakeSoak DV103|11|True|True|00:17|Y-axis|Variable|True|00:45 01110001012d0000000000 +StripAspirate DV103|14|0 CW|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 19000b0000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|1 CW|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900070000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|1|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900010000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|2 CW|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900080000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|2|0|84|50|0|None|91|0|0|111111111111111111111111111111111111111111111111|1111 0000023200005400000000005b00ffffffffffff0f000000000000 +StripAspirate DV103|14|2|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900020000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|3 CW|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900090000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|3|0|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 0000030000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|3|0|30|0|0|Point|30|0|0|111111111111111111111111111111111111111111111111|1111 0000030000001e00010000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|3|0|64|0|0|None|64|0|0|111111111111111111111111111111111111111111111111|1111 0000030000004000000000004000ffffffffffff0f000000000000 +StripAspirate DV103|14|3|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900030000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|4 CW|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 19000a0000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|4|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900040000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|5|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900050000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|6 CW|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 1900060000001e00000000001e00ffffffffffff0f000000000000 +StripAspirate DV103|14|7 CW|25|30|0|0|None|30|0|0|111111111111111111111111111111111111111111111111|1111 19000c0000001e00000000001e00ffffffffffff0f000000000000 +StripDispense DV103|15|0|5|336|0|0|False|50|5|2|False|0|111111111111111111111111111111111111111111111111|1111 0000050000005001000005020000ffffffffffff0f000000000000 +StripDispense DV103|15|1|5|336|0|0|False|50|5|2|False|0|111111111111111111111111111111111111111111111111|1111 0100050000005001000005020000ffffffffffff0f000000000000 +StripDispense DV103|15|300|6|336|0|0|True|300|5|2|True|0|111111111111111111111111111111111111111111111111|1111 2c010600000050012c0106020000ffffffffffff0f000000000000 +StripDispense DV103|15|50|5|336|0|0|False|50|5|2|False|0|111111111111111111111111111111111111111111111111|1111 3200050000005001000005020000ffffffffffff0f000000000000 +StripDispense DV103|15|65535|5|336|0|0|False|50|5|2|False|0|111111111111111111111111111111111111111111111111|1111 ffff050000005001000005020000ffffffffffff0f000000000000 +StripDispense DV103|15|999|5|336|0|0|False|50|5|2|False|0|111111111111111111111111111111111111111111111111|1111 e703050000005001000005020000ffffffffffff0f000000000000 +StripPrime DV103|16|0|5|3|False|00:05 000005030000000000000000 +StripPrime DV103|16|1|5|3|False|00:05 010005030000000000000000 +StripPrime DV103|16|4000|5|10|False|00:05 a00f050a0000000000000000 +StripPrime DV103|16|4444|5|3|True|00:06 5c1105030600000000000000 +StripPrime DV103|16|5000|5|5|False|00:05 881305050000000000000000 +StripPrime DV103|16|5000|9|1|True|04:00 88130901f000000000000000 +StripPrime DV103|16|50|5|3|False|00:05 320005030000000000000000 +StripPrime DV103|16|65535|5|3|False|00:05 ffff05030000000000000000 +StripPrime DV103|16|8000|5|5|False|00:05 401f05050000000000000000 +StripPrime DV103|16|8000|5|5|True|00:05 401f05050500000000000000 +StripPrime DV103|16|999|5|3|False|00:05 e70305030000000000000000 +StripWash DV103|13|Plate|2|False|False|False|True|True|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|1 CW|0|182|0|0|None|91|0|0#DV103|15|60|9|356|0|0|False|100|5|2|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|True|00:30#DV103|14|1 CW|0|182|0|0|None|91|0|0 000100026400050000005001000005020000000000000000000007000000b600000000005b00000000000000000007000000b600000000005b000000000000003c0009000000640100000502000000000000000001000003001e0000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|2|False|False|False|True|True|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|1 CW|0|182|50|0|None|91|0|0#DV103|15|100|9|356|0|0|False|100|5|2|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|True|00:30#DV103|14|1 CW|0|182|50|0|None|91|0|0 000100026400050000005001000005020000000000000000000007320000b600000000005b00000000000000000007320000b600000000005b00000000000000640009000000640100000502000000000000000001000003001e0000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|2|False|False|False|True|True|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|6 CW|0|91|0|0|None|91|0|0#DV103|15|200|5|334|0|0|False|100|5|2|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|True|00:30#DV103|14|6 CW|0|119|0|0|None|91|0|0 0001000264000500000050010000050200000000000000000000060000007700000000005b000000000000000000060000005b00000000005b00000000000000c800050000004e0100000502000000000000000001000003001e0000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|333|0|0|False|100|5|2|False|0#DV103|14|3|0|81|0|4|None|69|0|0#DV103|15|100|7|319|0|0|False|100|5|2|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|3|0|81|0|4|None|69|0|0 000100036400050000004d01000005020000000000000000000003000004510000000000450000000000000000000300000451000000000045000000000000006400070000003f010000050200000000000000000000000300000000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|4|0|100|50|16|None|91|0|0#DV103|15|300|9|333|0|0|False|100|5|2|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|4|0|100|50|16|None|91|0|0 0001000364000500000050010000050200000000000000000000043200106400000000005b000000000000000000043200106400000000005b000000000000002c01090000004d010000050200000000000000000000000300000000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|4|0|106|0|16|None|91|0|0#DV103|15|250|7|333|0|0|False|100|5|2|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|4|0|106|0|16|None|91|0|0 0001000364000500000050010000050200000000000000000000040000106a00000000005b000000000000000000040000106a00000000005b00000000000000fa00070000004d010000050200000000000000000000000300000000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|4|0|116|0|10|None|91|0|0#DV103|15|250|7|333|0|0|False|100|5|2|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|4|0|116|0|10|None|91|0|0 00010003640005000000500100000502000000000000000000000400000a7400000000005b0000000000000000000400000a7400000000005b00000000000000fa00070000004d010000050200000000000000000000000300000000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|4|0|91|48|10|None|91|0|0#DV103|15|300|9|333|0|0|False|100|5|2|True|0#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|4|0|91|48|10|None|91|0|0 00010003640005000000500100000502000000000000000000000430000a5b00000000005b0000000000000000000430000a5b00000000005b000000000000002c01090000004d010000050200000000000000000000000300000000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|6 CW|0|157|0|0|None|91|0|0#DV103|15|100|1|320|0|0|False|100|5|2|True|25#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|6 CW|0|157|0|0|None|91|0|0 0001000364000500000050010000050200000000000000000000060000009d00000000005b000000000000000000060000009d00000000005b0000000000000064000100000040010000050219000000000000000000000300000000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|6 CW|0|157|0|0|None|91|0|0#DV103|15|500|1|320|45|0|False|100|5|2|True|50#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|6 CW|0|157|0|0|None|91|0|0 0001000364000500000050010000050200000000000000000000060000009d00000000005b000000000000000000060000009d00000000005b00000000000000f401012d000040010000050232000000000000000000000300000000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|100|5|336|0|0|False|100|5|2|False|0#DV103|14|6 CW|0|157|0|0|None|91|0|0#DV103|15|500|2|320|45|0|False|100|5|2|True|50#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|6 CW|0|157|0|0|None|91|0|0 0001000364000500000050010000050200000000000000000000060000009d00000000005b000000000000000000060000009d00000000005b00000000000000f401022d000040010000050232000000000000000000000300000000000000ffffffffffff0f000000000000 +StripWash DV103|13|Plate|3|False|False|False|True|False|111111111111111111111111111111111111111111111111|1111#DV103|15|300|5|336|0|0|False|300|5|2|False|0#DV103|14|3|0|84|50|0|None|84|50|0#DV103|15|300|5|336|-4|-5|False|300|5|2|True|3#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|14|3|0|84|50|0|None|84|50|0 000100032c01050000005001000005020000000000000000000003320000540000320000540000000000000000000332000054000032000054000000000000002c0105fcfffb50010000050203000000000000000000000300000000000000ffffffffffff0f000000000000 +SyringeDispense 4|1|100|5|336|0|0|False|100|2|0|000010000000111111111111 0064000500005001000000000210f0ff000000000000000000 +SyringeDispense 4|1|10|2|336|0|0|False|50|2|0|000000000000000000000010 000a0002000050010000000002000040000000000000000000 +SyringeDispense 4|1|120|5|336|0|0|False|100|2|0|000001000000111111111111 0078000500005001000000000220f0ff000000000000000000 +SyringeDispense 4|1|140|5|336|0|0|False|100|2|0|000000100000111111111111 008c000500005001000000000240f0ff000000000000000000 +SyringeDispense 4|1|14|2|336|0|0|False|50|2|0|000000000000000000000100 000e0002000050010000000002000020000000000000000000 +SyringeDispense 4|1|160|5|336|0|0|False|100|2|0|000000010000111111111111 00a0000500005001000000000280f0ff000000000000000000 +SyringeDispense 4|1|180|5|336|0|0|False|100|2|0|000000001000111111111111 00b4000500005001000000000200f1ff000000000000000000 +SyringeDispense 4|1|18|2|336|0|0|False|50|2|0|000000000000000000001000 00120002000050010000000002000010000000000000000000 +SyringeDispense 4|1|200|5|336|0|0|False|100|2|0|000000000100111111111111 00c8000500005001000000000200f2ff000000000000000000 +SyringeDispense 4|1|20|2|336|0|0|False|20|2|0|100000000000111111111111 0014000200005001000000000201f0ff000000000000000000 +SyringeDispense 4|1|220|5|336|0|0|False|100|2|0|000000000010111111111111 00dc000500005001000000000200f4ff000000000000000000 +SyringeDispense 4|1|22|2|336|0|0|False|50|2|0|000000000000000000010000 00160002000050010000000002000008000000000000000000 +SyringeDispense 4|1|240|5|336|0|0|False|100|2|0|000000000001111111111111 00f0000500005001000000000200f8ff000000000000000000 +SyringeDispense 4|1|26|3|336|0|0|False|50|2|0|000000000000000000100000 001a0003000050010000000002000004000000000000000000 +SyringeDispense 4|1|30|4|336|0|0|False|50|2|0|000000000000000001000000 001e0004000050010000000002000002000000000000000000 +SyringeDispense 4|1|34|4|336|0|0|False|50|2|0|000000000000000010000000 00220004000050010000000002000001000000000000000000 +SyringeDispense 4|1|38|4|336|0|0|False|50|2|0|000000000000000100000000 00260004000050010000000002008000000000000000000000 +SyringeDispense 4|1|40|2|336|0|0|False|100|2|0|010000000000111111111111 0028000200005001000000000202f0ff000000000000000000 +SyringeDispense 4|1|42|5|336|0|0|False|50|2|0|000000000000001000000000 002a0005000050010000000002004000000000000000000000 +SyringeDispense 4|1|46|5|336|0|0|False|50|2|0|000000000000010000000000 002e0005000050010000000002002000000000000000000000 +SyringeDispense 4|1|50|5|336|0|0|False|50|2|0|000000000000100000000000 00320005000050010000000002001000000000000000000000 +SyringeDispense 4|1|54|5|336|0|0|False|50|2|0|000000000001000000000000 00360005000050010000000002000800000000000000000000 +SyringeDispense 4|1|58|5|336|0|0|False|50|2|0|000000000010000000000000 003a0005000050010000000002000400000000000000000000 +SyringeDispense 4|1|60|4|336|0|0|False|100|2|0|001000000000111111111111 003c000400005001000000000204f0ff000000000000000000 +SyringeDispense 4|1|62|5|336|0|0|False|50|2|0|000000000100000000000000 003e0005000050010000000002000200000000000000000000 +SyringeDispense 4|1|66|5|336|0|0|False|50|2|0|000000001000000000000000 00420005000050010000000002000100000000000000000000 +SyringeDispense 4|1|6|1|336|0|0|False|50|2|0|000000000000000000000001 00060001000050010000000002000080000000000000000000 +SyringeDispense 4|1|70|5|336|0|0|False|50|2|0|000000010000000000000000 00460005000050010000000002800000000000000000000000 +SyringeDispense 4|1|74|5|336|0|0|False|50|2|0|000000100000000000000000 004a0005000050010000000002400000000000000000000000 +SyringeDispense 4|1|78|5|336|0|0|False|50|2|0|000001000000000000000000 004e0005000050010000000002200000000000000000000000 +SyringeDispense 4|1|80|5|336|0|0|False|100|2|0|000100000000111111111111 0050000500005001000000000208f0ff000000000000000000 +SyringeDispense 4|1|82|5|336|0|0|False|50|2|0|000010000000000000000000 00520005000050010000000002100000000000000000000000 +SyringeDispense 4|1|86|5|336|0|0|False|50|2|0|000100000000000000000000 00560005000050010000000002080000000000000000000000 +SyringeDispense 4|1|90|5|336|0|0|False|50|2|0|001000000000000000000000 005a0005000050010000000002040000000000000000000000 +SyringeDispense 4|1|94|5|336|0|0|False|50|2|0|010000000000000000000000 005e0005000050010000000002020000000000000000000000 +SyringeDispense 4|1|98|5|336|0|0|False|50|2|0|100000000000000000000000 00620005000050010000000002010000000000000000000000 +SyringeDispense DV101|4|1|6|3|254|0|0|False|50|2|0|010100000000000000111111111111000000000000001010 000600030000fe0000000000020a00fc3f0050000000000000 +SyringeDispense DV101|4|2|6|3|254|0|0|False|50|2|0|010100000000000000111111111111000000000000001010 010600030000fe0000000000020a00fc3f0050000000000000 +SyringeDispense DV103|4|1|100|5|336|0|0|False|100|2|0|000000010000111111111111111111111111111111111111|1|1111 0064000500005001000000000280f0ffffffff000000000000 +SyringeDispense DV103|4|1|100|5|336|0|0|False|100|2|0|000010000000000000000000000000000000000000000000|1 00640005000050010000000002100000000000000000000000 +SyringeDispense DV103|4|1|10|2|333|0|0|False|50|2|0|000000000000000000000010000000000000000000000000|1 000a000200004d010000000002000040000000000000000000 +SyringeDispense DV103|4|1|10|2|333|0|0|False|50|2|0|000000000000000000000010000000000000000000000000|1|1111 000a000200004d010000000002000040000000000000000000 +SyringeDispense DV103|4|1|120|5|336|0|0|False|100|2|0|000000100000111111111111111111111111111111111111|1|1111 0078000500005001000000000240f0ffffffff000000000000 +SyringeDispense DV103|4|1|120|5|336|0|0|False|100|2|0|000001000000000000000000000000000000000000000000|1 00780005000050010000000002200000000000000000000000 +SyringeDispense DV103|4|1|140|5|336|0|0|False|100|2|0|000000100000000000000000000000000000000000000000|1 008c0005000050010000000002400000000000000000000000 +SyringeDispense DV103|4|1|140|5|336|0|0|False|100|2|0|000001000000111111111111111111111111111111111111|1|1111 008c000500005001000000000220f0ffffffff000000000000 +SyringeDispense DV103|4|1|14|2|333|0|0|False|50|2|0|000000000000000000000100000000000000000000000000|1 000e000200004d010000000002000020000000000000000000 +SyringeDispense DV103|4|1|14|2|333|0|0|False|50|2|0|000000000000000000000100000000000000000000000000|1|1111 000e000200004d010000000002000020000000000000000000 +SyringeDispense DV103|4|1|160|5|336|0|0|False|100|2|0|000000010000000000000000000000000000000000000000|1 00a00005000050010000000002800000000000000000000000 +SyringeDispense DV103|4|1|160|5|336|0|0|False|100|2|0|000010000000111111111111111111111111111111111111|1|1111 00a0000500005001000000000210f0ffffffff000000000000 +SyringeDispense DV103|4|1|180|5|336|0|0|False|100|2|0|000000001000000000000000000000000000000000000000|1 00b40005000050010000000002000100000000000000000000 +SyringeDispense DV103|4|1|180|5|336|0|0|False|100|2|0|000100000000111111111111111111111111111111111111|1|1111 00b4000500005001000000000208f0ffffffff000000000000 +SyringeDispense DV103|4|1|18|2|333|0|0|False|50|2|0|000000000000000000001000000000000000000000000000|1 0012000200004d010000000002000010000000000000000000 +SyringeDispense DV103|4|1|18|2|333|0|0|False|50|2|0|000000000000000000001000000000000000000000000000|1|1111 0012000200004d010000000002000010000000000000000000 +SyringeDispense DV103|4|1|200|5|336|0|0|False|100|2|0|000000000100000000000000000000000000000000000000|1 00c80005000050010000000002000200000000000000000000 +SyringeDispense DV103|4|1|200|5|336|0|0|False|100|2|0|001000000000111111111111111111111111111111111111|1|1111 00c8000500005001000000000204f0ffffffff000000000000 +SyringeDispense DV103|4|1|20|2|336|0|0|False|100|2|0|000000000001111111111111111111111111111111111111|1|1111 0014000200005001000000000200f8ffffffff000000000000 +SyringeDispense DV103|4|1|20|2|336|0|0|False|100|2|0|100000000000000000000000000000000000000000000000|1 00140002000050010000000002010000000000000000000000 +SyringeDispense DV103|4|1|220|5|336|0|0|False|100|2|0|000000000010000000000000000000000000000000000000|1 00dc0005000050010000000002000400000000000000000000 +SyringeDispense DV103|4|1|220|5|336|0|0|False|100|2|0|010000000000111111111111111111111111111111111111|1|1111 00dc000500005001000000000202f0ffffffff000000000000 +SyringeDispense DV103|4|1|22|2|333|0|0|False|50|2|0|000000000000000000010000000000000000000000000000|1 0016000200004d010000000002000008000000000000000000 +SyringeDispense DV103|4|1|22|2|333|0|0|False|50|2|0|000000000000000000010000000000000000000000000000|1|1111 0016000200004d010000000002000008000000000000000000 +SyringeDispense DV103|4|1|240|5|336|0|0|False|100|2|0|000000000001000000000000000000000000000000000000|1 00f00005000050010000000002000800000000000000000000 +SyringeDispense DV103|4|1|240|5|336|0|0|False|100|2|0|100000000000111111111111111111111111111111111111|1|1111 00f0000500005001000000000201f0ffffffff000000000000 +SyringeDispense DV103|4|1|26|3|333|0|0|False|50|2|0|000000000000000000100000000000000000000000000000|1 001a000300004d010000000002000004000000000000000000 +SyringeDispense DV103|4|1|26|3|333|0|0|False|50|2|0|000000000000000000100000000000000000000000000000|1|1111 001a000300004d010000000002000004000000000000000000 +SyringeDispense DV103|4|1|30|4|333|0|0|False|50|2|0|000000000000000001000000000000000000000000000000|1 001e000400004d010000000002000002000000000000000000 +SyringeDispense DV103|4|1|30|4|333|0|0|False|50|2|0|000000000000000001000000000000000000000000000000|1|1111 001e000400004d010000000002000002000000000000000000 +SyringeDispense DV103|4|1|34|4|333|0|0|False|50|2|0|000000000000000010000000000000000000000000000000|1 0022000400004d010000000002000001000000000000000000 +SyringeDispense DV103|4|1|34|4|333|0|0|False|50|2|0|000000000000000010000000000000000000000000000000|1|1111 0022000400004d010000000002000001000000000000000000 +SyringeDispense DV103|4|1|38|4|333|0|0|False|50|2|0|000000000000000100000000000000000000000000000000|1 0026000400004d010000000002008000000000000000000000 +SyringeDispense DV103|4|1|38|4|333|0|0|False|50|2|0|000000000000000100000000000000000000000000000000|1|1111 0026000400004d010000000002008000000000000000000000 +SyringeDispense DV103|4|1|40|2|336|0|0|False|100|2|0|000000000010111111111111111111111111111111111111|1|1111 0028000200005001000000000200f4ffffffff000000000000 +SyringeDispense DV103|4|1|40|2|336|0|0|False|100|2|0|010000000000000000000000000000000000000000000000|1 00280002000050010000000002020000000000000000000000 +SyringeDispense DV103|4|1|42|5|333|0|0|False|50|2|0|000000000000001000000000000000000000000000000000|1 002a000500004d010000000002004000000000000000000000 +SyringeDispense DV103|4|1|42|5|333|0|0|False|50|2|0|000000000000001000000000000000000000000000000000|1|1111 002a000500004d010000000002004000000000000000000000 +SyringeDispense DV103|4|1|46|5|333|0|0|False|50|2|0|000000000000010000000000000000000000000000000000|1 002e000500004d010000000002002000000000000000000000 +SyringeDispense DV103|4|1|46|5|333|0|0|False|50|2|0|000000000000010000000000000000000000000000000000|1|1111 002e000500004d010000000002002000000000000000000000 +SyringeDispense DV103|4|1|50|2|333|-4|-4|True|50|2|7|111111111111111111111111111111111111111111111111|1|1111 00320002fcfc4d010700320002ffffffffffff000000000000 +SyringeDispense DV103|4|1|50|2|336|0|0|False|0|2|0|111111111111111111111111111111111111111111111111|1 00320002000050010000000002ffffffffffff000000000000 +SyringeDispense DV103|4|1|50|2|336|0|0|False|50|2|0|111111111111111111111111111111111111111111111111|1 00320002000050010000000002ffffffffffff000000000000 +SyringeDispense DV103|4|1|50|2|336|0|0|False|50|2|0|111111111111111111111111111111111111111111111111|2 00320002000050010000000002ffffffffffff010000000000 +SyringeDispense DV103|4|1|50|2|336|0|0|True|75|5|0|111111111111111111111111111111111111111111111111|1 003200020000500100004b0005ffffffffffff000000000000 +SyringeDispense DV103|4|1|50|5|333|0|0|False|50|2|0|000000000000100000000000000000000000000000000000|1 0032000500004d010000000002001000000000000000000000 +SyringeDispense DV103|4|1|50|5|333|0|0|False|50|2|0|000000000000100000000000000000000000000000000000|1|1111 0032000500004d010000000002001000000000000000000000 +SyringeDispense DV103|4|1|54|5|333|0|0|False|50|2|0|000000000001000000000000000000000000000000000000|1 0036000500004d010000000002000800000000000000000000 +SyringeDispense DV103|4|1|54|5|333|0|0|False|50|2|0|000000000001000000000000000000000000000000000000|1|1111 0036000500004d010000000002000800000000000000000000 +SyringeDispense DV103|4|1|58|5|333|0|0|False|50|2|0|000000000010000000000000000000000000000000000000|1 003a000500004d010000000002000400000000000000000000 +SyringeDispense DV103|4|1|58|5|333|0|0|False|50|2|0|000000000010000000000000000000000000000000000000|1|1111 003a000500004d010000000002000400000000000000000000 +SyringeDispense DV103|4|1|60|4|336|0|0|False|100|2|0|000000000100111111111111111111111111111111111111|1|1111 003c000400005001000000000200f2ffffffff000000000000 +SyringeDispense DV103|4|1|60|4|336|0|0|False|100|2|0|001000000000000000000000000000000000000000000000|1 003c0004000050010000000002040000000000000000000000 +SyringeDispense DV103|4|1|62|5|333|0|0|False|50|2|0|000000000100000000000000000000000000000000000000|1 003e000500004d010000000002000200000000000000000000 +SyringeDispense DV103|4|1|62|5|333|0|0|False|50|2|0|000000000100000000000000000000000000000000000000|1|1111 003e000500004d010000000002000200000000000000000000 +SyringeDispense DV103|4|1|66|5|333|0|0|False|50|2|0|000000001000000000000000000000000000000000000000|1 0042000500004d010000000002000100000000000000000000 +SyringeDispense DV103|4|1|66|5|333|0|0|False|50|2|0|000000001000000000000000000000000000000000000000|1|1111 0042000500004d010000000002000100000000000000000000 +SyringeDispense DV103|4|1|6|1|333|0|0|False|50|2|0|000000000000000000000001000000000000000000000000|1 0006000100004d010000000002000080000000000000000000 +SyringeDispense DV103|4|1|6|1|333|0|0|False|50|2|0|000000000000000000000001000000000000000000000000|1|1111 0006000100004d010000000002000080000000000000000000 +SyringeDispense DV103|4|1|6|3|254|0|0|False|10|2|0|010100000000000000111111111111000000000000001010|1 000600030000fe0000000000020a00fc3f0050000000000000 +SyringeDispense DV103|4|1|6|3|254|0|0|True|6|2|0|010100000000000000111111111111000000000000001010|1|1111 000600030000fe0000000600020a00fc3f0050000000000000 +SyringeDispense DV103|4|1|70|5|333|0|0|False|50|2|0|000000010000000000000000000000000000000000000000|1 0046000500004d010000000002800000000000000000000000 +SyringeDispense DV103|4|1|70|5|333|0|0|False|50|2|0|000000010000000000000000000000000000000000000000|1|1111 0046000500004d010000000002800000000000000000000000 +SyringeDispense DV103|4|1|74|5|333|0|0|False|50|2|0|000000100000000000000000000000000000000000000000|1 004a000500004d010000000002400000000000000000000000 +SyringeDispense DV103|4|1|74|5|333|0|0|False|50|2|0|000000100000000000000000000000000000000000000000|1|1111 004a000500004d010000000002400000000000000000000000 +SyringeDispense DV103|4|1|78|5|333|0|0|False|50|2|0|000001000000000000000000000000000000000000000000|1 004e000500004d010000000002200000000000000000000000 +SyringeDispense DV103|4|1|78|5|333|0|0|False|50|2|0|000001000000000000000000000000000000000000000000|1|1111 004e000500004d010000000002200000000000000000000000 +SyringeDispense DV103|4|1|80|5|336|0|0|False|100|2|0|000000001000111111111111111111111111111111111111|1|1111 0050000500005001000000000200f1ffffffff000000000000 +SyringeDispense DV103|4|1|80|5|336|0|0|False|100|2|0|000100000000000000000000000000000000000000000000|1 00500005000050010000000002080000000000000000000000 +SyringeDispense DV103|4|1|82|5|333|0|0|False|50|2|0|000010000000000000000000000000000000000000000000|1 0052000500004d010000000002100000000000000000000000 +SyringeDispense DV103|4|1|82|5|333|0|0|False|50|2|0|000010000000000000000000000000000000000000000000|1|1111 0052000500004d010000000002100000000000000000000000 +SyringeDispense DV103|4|1|86|5|333|0|0|False|50|2|0|000100000000000000000000000000000000000000000000|1 0056000500004d010000000002080000000000000000000000 +SyringeDispense DV103|4|1|86|5|333|0|0|False|50|2|0|000100000000000000000000000000000000000000000000|1|1111 0056000500004d010000000002080000000000000000000000 +SyringeDispense DV103|4|1|90|5|333|0|0|False|50|2|0|001000000000000000000000000000000000000000000000|1 005a000500004d010000000002040000000000000000000000 +SyringeDispense DV103|4|1|90|5|333|0|0|False|50|2|0|001000000000000000000000000000000000000000000000|1|1111 005a000500004d010000000002040000000000000000000000 +SyringeDispense DV103|4|1|94|5|333|0|0|False|50|2|0|010000000000000000000000000000000000000000000000|1 005e000500004d010000000002020000000000000000000000 +SyringeDispense DV103|4|1|94|5|333|0|0|False|50|2|0|010000000000000000000000000000000000000000000000|1|1111 005e000500004d010000000002020000000000000000000000 +SyringeDispense DV103|4|1|98|5|333|0|0|False|50|2|0|100000000000000000000000000000000000000000000000|1 0062000500004d010000000002010000000000000000000000 +SyringeDispense DV103|4|1|98|5|333|0|0|False|50|2|0|100000000000000000000000000000000000000000000000|1|1111 0062000500004d010000000002010000000000000000000000 +SyringeDispense DV103|4|2|50|2|336|0|0|False|50|2|0|111111111111111111111111111111111111111111111111|3 01320002000050010000000002ffffffffffff020000000000 +SyringeDispense DV103|4|2|50|2|336|0|0|False|50|2|0|111111111111111111111111111111111111111111111111|4 01320002000050010000000002ffffffffffff030000000000 +SyringeDispense DV103|4|2|6|3|254|0|0|False|10|2|0|010100000000000000111111111111000000000000001010|1 010600030000fe0000000000020a00fc3f0050000000000000 +SyringeDispense DV103|4|2|6|3|254|0|0|False|10|2|0|010100000000000000111111111111000000000000001010|3 010600030000fe0000000000020a00fc3f0050020000000000 +SyringeDispense DV103|4|2|6|3|254|0|0|True|6|2|0|010100000000000000111111111111000000000000001010|3|1111 010600030000fe0000000600020a00fc3f0050020000000000 +SyringeDispense DV103|4|3|50|2|336|0|0|False|50|2|0|111111111111111111111111111111111111111111111111|5 02320002000050010000000002ffffffffffff040000000000 +SyringeDispense DV103|4|3|50|2|336|0|0|False|50|2|0|111111111111111111111111111111111111111111111111|6 02320002000050010000000002ffffffffffff050000000000 +SyringeDispense DV103|4|3|50|2|336|0|0|False|50|2|0|111111111111111111111111111111111111111111111111|7 02320002000050010000000002ffffffffffff060000000000 +SyringeDispense DV103|4|3|50|2|336|0|0|False|50|2|0|111111111111111111111111111111111111111111111111|8 02320002000050010000000002ffffffffffff070000000000 +SyringePrime 5|1|8000|5|5|0|True 00401f050500000100000000 +SyringePrime 5|2|8000|5|5|0|True 01401f050500000100000000 +SyringePrime DV103|5|1|5000|5|2|0|True|False|00:05|1 008813050200000100000000 +SyringePrime DV103|5|1|5000|5|2|0|True|False|00:05|2 008813050200000100000100 +SyringePrime DV103|5|1|5000|5|5|0|True|False|00:05|1 008813050500000100000000 +SyringePrime DV103|5|1|8000|5|4|0|True|False|00:00|1 00401f050400000100000000 +SyringePrime DV103|5|1|8000|5|4|0|True|True|00:20|1 00401f050400000114000000 +SyringePrime DV103|5|1|8000|5|5|0|True|False|00:05|1 00401f050500000100000000 +SyringePrime DV103|5|2|5000|5|2|0|True|False|00:05|3 018813050200000100000200 +SyringePrime DV103|5|2|5000|5|2|0|True|False|00:05|4 018813050200000100000300 +SyringePrime DV103|5|2|5000|5|5|0|True|False|00:05|3 018813050500000100000200 +SyringePrime DV103|5|2|8000|5|5|0|True|False|00:05|1 01401f050500000100000000 +SyringePrime DV103|5|2|8000|5|5|0|True|False|00:05|3 01401f050500000100000200 +SyringePrime DV103|5|3|5000|5|2|0|True|False|00:05|5 028813050200000100000400 +SyringePrime DV103|5|3|5000|5|2|0|True|False|00:05|6 028813050200000100000500 +SyringePrime DV103|5|3|5000|5|2|0|True|False|00:05|7 028813050200000100000600 +SyringePrime DV103|5|3|5000|5|2|0|True|False|00:05|8 028813050200000100000700 +Wash1536 DV103|12|Plate|10|False|10|2|False|True|False#DV103|7|False|3|0|30|0|0|None|30|0|0|0#DV103|4|1|50|2|336|0|0|True|50|2|0|111111111111111111111111111111111111111111111111|1#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|3|0|30|0|0|None|30|0|0|0 000000010a0002000a00000300001e0000000300001e0000320002000050010000320002ffffffffffff00000000000000000003000000000000000000000000000000 +Wash1536 DV103|12|Plate|10|True|25|4|False|False|False#DV103|7|False|3|0|30|0|0|None|30|0|0|0#DV103|4|1|50|2|336|0|0|True|50|2|0|111111111111111111111111111111111111111111111111|1#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|3|0|30|0|0|None|30|0|0|0 01000000190004000a00000300001e0000000300001e0000320002000050010000320002ffffffffffff00000000000000000003000000000000000000000000000000 +Wash1536 DV103|12|Plate|1|False|10|2|False|True|False#DV103|7|False|3|0|30|0|0|None|30|0|0|0#DV103|4|1|50|2|336|0|0|True|50|2|0|111111111111111111111111111111111111111111111111|1#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|3|0|30|0|0|None|30|0|0|0 000000010a0002000100000300001e0000000300001e0000320002000050010000320002ffffffffffff00000000000000000003000000000000000000000000000000 +Wash1536 DV103|12|Plate|1|True|25|4|False|False|False#DV103|7|False|3|0|30|0|0|None|30|0|0|0#DV103|4|1|50|2|336|0|0|True|50|2|0|111111111111111111111111111111111111111111111111|1#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|3|0|30|0|0|None|30|0|0|0 01000000190004000100000300001e0000000300001e0000320002000050010000320002ffffffffffff00000000000000000003000000000000000000000000000000 +Wash1536 DV103|12|Plate|3|False|10|2|False|True|False#DV103|7|False|3|0|30|0|0|None|30|0|0|0#DV103|4|1|50|2|336|0|0|True|50|2|0|111111111111111111111111111111111111111111111111|1#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|3|0|30|0|0|None|30|0|0|0 000000010a0002000300000300001e0000000300001e0000320002000050010000320002ffffffffffff00000000000000000003000000000000000000000000000000 +Wash1536 DV103|12|Plate|3|True|25|4|False|False|False#DV103|7|False|3|0|30|0|0|None|30|0|0|0#DV103|4|1|50|2|336|0|0|True|50|2|0|111111111111111111111111111111111111111111111111|1#DV103|11|True|False|00:05|X-axis|Medium (5 Hz)|False|00:30#DV103|7|False|3|0|30|0|0|None|30|0|0|0 01000000190004000300000300001e0000000300001e0000320002000050010000320002ffffffffffff00000000000000000003000000000000000000000000000000 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/001_W-CORNING_FLAT_96.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/001_W-CORNING_FLAT_96.LHC new file mode 100644 index 0000000000000000000000000000000000000000..c4ada6273a580a4ed9e76f0b7ca26d71a708e2a7 GIT binary patch literal 3568 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 SJusgE(hk_TC`}f=J%J0Q_pBHI literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_MAG_384.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_MAG_384.LHC new file mode 100644 index 0000000000000000000000000000000000000000..6c2da1bd9103477a7591ada3d1a8e797f8aedbc9 GIT binary patch literal 3624 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_MAG_384_select.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_MAG_384_select.LHC new file mode 100644 index 0000000000000000000000000000000000000000..6c2da1bd9103477a7591ada3d1a8e797f8aedbc9 GIT binary patch literal 3624 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_VAC_384.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/W-LUMINEX_VAC_384.LHC new file mode 100644 index 0000000000000000000000000000000000000000..918d0f88da7bd81524a4d829e2647cc2e431a961 GIT binary patch literal 3016 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 GtronmxhzKj literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/50TS/VAC30_TEST.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/50TS/VAC30_TEST.LHC new file mode 100644 index 0000000000000000000000000000000000000000..e40c3755d9fb237a5cada52e5bed550f11ac9360 GIT binary patch literal 4432 zcmV-W5wGrJN+e%;nb!BJYx6Ij1hf+r-d5R@JIDrx0oM-Uv8WeVDB5DFxn&`>F{$!!^FarQvu)>e0-&CNtE+eKTV zAlsiNG&|D*f2l1Dt{an(TNP8n{$&P?*7sJ-MZR*kkGj&&@}=zPzv#aOIN!E=)D%+l zk`PX56r4l3VWj%iPygR$FU6E)3IONLmx$RaUB4txW+#3CaAQ9Tzft5jBX=yYNje|2 z=ZnekFTf{tsH?cbHf_j%s}b5Jqal$`>!v-=tXv)lLh;wx{82ek%Lt=gm&Gi#(PKh* zx)yFy4rz^1a0&|yKfXZB$l<2gF3`8)qV?za2@1x zvkZkdOuGKq#|ur9TSg7XqfpDiP6bC{E$G4dU*{eMz~Z1fBr>m_tQ8=Ft04o|wWdj1 zLeh?BODbHMIekb5^Vk0-ewEUoVkw=ZLTVJk>)j<9vz~V*_$7~4Eyms#Kku>qZK85! z>$a+GJ)+b#h=No0`|G(S>Ex$!I<%DX8{0L+Hn05q#^;iAf*w;e*H4($U@j!2^; zj-JGs)q=ox5NUp)jsFE06h^jmcLaLF3uYc0?jtJg7cNr z#8wAHZ7?fU*}V6|vkUY_Lhra5(6UZ~L8;%8V$)v^*c}fD5q;2En9x;C8O6aR z7^vioG;})35+d6x#cn1|CN3HKlUad_ONhv39cSMheF4h*KwFJ!%ej?Nz$78~nsI#P zvY$QjZ4h8MJKA4&TF|KA*e9WTp~{4X3^dPc@FmZf;HyJ0hqP!g0WK!Ql6(NBTjyUT zE!vPY{WEv-!6GeRL4@d;z;#ojDDQbi*GacJPWc1Sg68K+KqMS-nLzuv?fl;C6X` z>riS0d`}AD0-DXDDxP}(-G#3c*=;ryT373lQ$hlJ$(5<3wTQtA$3T{ zt`2c0Qlql5p*}~aM!vZZqp1v}>OrwW?BTsp=hm)-KFa7qers57qW>_Jl{0SPO380rS=K4L)x_;i{(l!dPio?|7^CNZA8l95? zHlL8CuA(L%coT|cvjHWP_MKO{Pu~$Ry$x8tgjCTxEonR6tsmfZEnx{jxxAQ70b0Ym z(veH=y-}kmPaa+Np0v^W**pap-~=F63Wlz13`jbi4TJwQ87r8m)u+v;WNI2pJ4W<# z9`e{JLB$v>1xsCOAvPY%@K$<7V|g}$SApTR>%_rTzl_{C!MZ;BTxuk>=$mg?)=e** z==L?yG%8W-^l3KuvZs(0GBiU>9GGGH+cPp54V&}tPsMR9K zjR&Rgl*lcIXaha*f=<_KmO@(-+YAL5y>lzw(D8red~-K>~2Zt&GJBC;e>5)U>UU8 zkGPr*l3s`b8H=;M^b$%V)K^SbRC)87hUAXXk4qn7Mt6io!B4Jk>wT3HC_2}~VkNnV zle2AqMWgYP_*l_=yLsGM+9Zf>mq^BBw#3gh1<9XT_A!$u;?=Xv334wdFWzmv0&z6b zlrb!ihzYHFS030UfZp|R{6UJFm{!pWYDy793bf8|6*1$dw@vusSGz!xz*m}853_)D zLPzr|=OcF_W)K+@C7YaFF&IHyvC}nDfr6jXviYYAg(XQ1MwkXRs{}frlma|8&rF(5itmW)$Ldspa8hOBxTw)>H=2;7Jb4-$RzB=1lJ02XW$w)S-VsGr z;@IN^F2*)x=b+HH{KJRq_z$VP+3&&L(S?Mt90smZlVDi-#yX)Y02zG2kP$TD2ZBZl z3ulwbuO|2&ZaSaa1hk4lO%d3#^Jd7{R86KW$%iLU^ay#Rpta=nTP6v5q#~U0qMhTEeK`2o|lf%D6W@2O@Qd5IUw$eOs>~yY=?#572hYX}DmwF<< zopx)$>xn$Dm9!oHegCm^aCV1YeAy{R?FG$0(zQiMt?~WA*{Z*+4*9b%x;Mm5W4x*H zD~A#EMu(vay}q^ly5Ybc`^p=tLCArhI6HG^O-|S;dk^W!Y@)z4_HvDBIsTcy6Nxp~ z4<> zdVX@s=;q7<1q#u&k4P&yRP_SR+a=t1po`}}_)cVRD4b3Ato6xAuswk#cjMJl=rMBzs%LB2Fqj(W)q}6^|5W_-uUBSEh7JOF6 z5uUTYig*XkaDXdmyXZEbtf4iKS6f_)Fc*hN1P((`07Ewa$mRAY%Lp1(@EN#KBQX9_ zYW{WW^$$sIY=m{MMjaNVb0Wri78dmrwE2xDc$8Tqz`A=ekr(O5j`8%MW`iSft#qD* z0K7%z=qd*5*0+z0y)>HN2YQnJ)?~q4JSKZ3!yB`s+n7!fA}FlVNZ!qsXD#zCo~~s? zC>OZkuUouj+N%h3u2`nLN2;eVGk)Bu*2dE)mdT;BZaNlYv%|y_G@#jm!+Z!5$Hp{e zo|v6gcU*M|R-cWLMA{Ijbk4shseXAx7d+q^gJYP2G5~uRXX_@?;KUwkQ<{5z<*c^+ zA~r?MmhnYYx0GC%k9A&aicFxU8d8BX(dtU3Vct%3Go?iNOOeDU-bIx0azKiP1^BvV z$-q=8>-2I+$H2sgfzTbT6UZg~@FStHl$f!QJFxn8=o?*aGT)UgMNZ9&^@_*PG6g>n zp}oynR*P2@8{5qaAadQKVJ(1D?R2w8d+fd#ffS$RSu+8%kM%GL`iPRL-1%qCPphD7~uOY!`QrvpuQVNX*_z*@Zp_O%cX*g_J``AB9#d*2O)X(9kZ*LhydBS%p(*IRd9VWU3-#YB@q2 zvF8g0)AElqKE#?jn?jF~pGM6)fkEWILbu-qjoV4i?vu%W`2h1P+6sjn3^z43!g6CD z0gS~BiY2lejXUMwggT(CaI$<#Py;VBeH&)V<^EUpW)~nJVxP)0%t43!T3LDX^ls~> zE!6Je-8VSPStnhTcPd#|)2@`-4l~96LADaGZ)X;6Uy*0{CYF3l_53AsuMwpYFP4 zcj<@J_O>?z0>M;vZ>Pxr$^~Tpiy7@NKOXlXAI^Cy1Jt>!;jLd>4uuj*LI1lNAK!FD zfp;0kR8I;-k-c1Vo1AHC3oYb{?W&!Q*Sj9T`34Sqwq3&Gn9$lReL!EhGu3e6DT*xJ z7h#HtxUZTZh9k`M#oflsY_Cx;w zAtx9tIFmrOoFkCH4F(jSJEtU zr5d*byHSiVoD(AMShKDHc*6SG7x0ihUaB11R>UDzHrEPNLk^g`=@61zjtSYE4B za=APgj;QPn#Uel*p%*Wo7y?IHNp8>>F14$xtO&Kwi_BEu|3G+i#Jx}s`dXKknI5oC=SlLjpe(d(;QOJ)!y8JH WQEr=wZ*|BDf+qj(!}Rk5XeD2*Y>D&$ literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/003_P-1UL_CASS_RINSE.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/003_P-1UL_CASS_RINSE.LHC new file mode 100644 index 0000000000000000000000000000000000000000..6065c237e2b4d0c5699f273200ad6b8e9f51d52e GIT binary patch literal 2336 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 KblDHmiQcpu`gC9b literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/S-DAY_RINSE_A&B.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/S-DAY_RINSE_A&B.LHC new file mode 100644 index 0000000000000000000000000000000000000000..ac49f700fab46cac483f57d50da2f27b1a082a2e GIT binary patch literal 2696 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/W-CLEAN_w-BUFFER.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/EL406/W-CLEAN_w-BUFFER.LHC new file mode 100644 index 0000000000000000000000000000000000000000..72f77c699bc532aaf3d9c45e716962901c967798 GIT binary patch literal 3048 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/ELx405/OVERNIGHT_LOOP.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/ELx405/OVERNIGHT_LOOP.LHC new file mode 100644 index 0000000000000000000000000000000000000000..d718b176c99e34a22813b1b849ccf7a3196316fb GIT binary patch literal 2240 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFlo/P-10UL_CASS_RINSE.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFlo/P-10UL_CASS_RINSE.LHC new file mode 100644 index 0000000000000000000000000000000000000000..e515b332bfed350acd1181d3324c84485bb95058 GIT binary patch literal 2504 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/P-10UL_RAD_CASS_RINSE.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/P-10UL_RAD_CASS_RINSE.LHC new file mode 100644 index 0000000000000000000000000000000000000000..6ff87b9e9a812a746e7c4ebea2f96173ef7f62da GIT binary patch literal 4664 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/P-384_DILUTION.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/P-384_DILUTION.LHC new file mode 100644 index 0000000000000000000000000000000000000000..afc318c8a6d39c803f6069ea3f68681ce349a6de GIT binary patch literal 9272 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 aq#-=~ErSB&pf@@jQUHu0TAZ@}?80RE2kdkJ literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/PW-CORNING_96.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/MultiFloFX/PW-CORNING_96.LHC new file mode 100644 index 0000000000000000000000000000000000000000..ee7704a652e2b4a93a166868d33be134c7fdd834 GIT binary patch literal 12176 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%0A680y0ZYKc}CWI{2{L&Y$A3Yh!Cv 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 K0d$X1m}vUzPf;BJ literal 0 HcmV?d00001 diff --git a/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/fixtures/W-CORNING_FLAT_96.LHC b/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/fixtures/W-CORNING_FLAT_96.LHC new file mode 100644 index 0000000000000000000000000000000000000000..e76cb1219a8b46f104da3c49ea4831befec347af GIT binary patch literal 2680 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 aw(%cLeAHUoY! PlateRecord: + """The record this model works a format by. + + Args: + plate_type: The format to look up. + + Returns: + The record. + + Raises: + AssertionError: If the model does not offer that format, which would make the test meaningless. + """ + record = find(plate_type, EL406) + assert record is not None, plate_type.name + return record + + +PLATE_96 = offered(PlateType.PLATE_96_WELL) +PLATE_384 = offered(PlateType.PLATE_384_WELL) +PLATE_1536 = offered(PlateType.PLATE_1536_WELL) + + +def check( + steps: list[Step], + settings: InstrumentSettings | None = None, + plate: PlateRecord = PLATE_96, + plate_restriction: PlateRestriction | None = None, + carrier_type: CarrierType | None = None, +) -> tuple[ValidationReport, Reservations]: + """Run the pass over some steps. + + Args: + steps: The steps to check. + settings: What the instrument has fitted, defaulting to a fully equipped one. + plate: The plate on the carrier. + plate_restriction: Which plates the instrument accepts, when it has been asked. + carrier_type: Which carrier is fitted, when it has been asked. + + Returns: + The report, and what the protocol requires of the pumps. + """ + return validate( + steps=steps, + settings=settings if settings is not None else InstrumentSettings(family=EL406), + plate=plate, + rules=rules_for(EL406), + plate_restriction=plate_restriction, + carrier_type=carrier_type, + ) + + +class TestTheReport: + """What the pass answers with.""" + + def test_a_protocol_that_can_run_is_truthy(self): + report, _ = check([ManifoldPrime(volume=40_000)]) + assert report + assert report.failures == [] + + def test_every_step_is_checked_rather_than_stopping_at_the_first(self): + """A report that stopped early would hide the second reason a protocol will not run.""" + settings = InstrumentSettings(family=EL406, ultrasonic=False, vacuum_filtration=False) + report, _ = check([ManifoldAutoClean(), ManifoldAutoClean()], settings) + assert not report + assert len(report.steps) == 2 + assert len(report.failures) == 2 + + def test_a_report_prints_only_what_cannot_run(self): + settings = InstrumentSettings(family=EL406, ultrasonic=False) + report, _ = check([ManifoldPrime(volume=40_000), ManifoldAutoClean()], settings) + printed = str(report) + assert "1 of 2 steps" in printed + assert "MANIFOLD_AUTO_CLEAN" in printed + + def test_a_step_carries_its_number_and_its_reason(self): + settings = InstrumentSettings(family=EL406, ultrasonic=False) + report, _ = check([ManifoldPrime(volume=40_000), ManifoldAutoClean()], settings) + failure = report.failures[0] + assert failure.number == 2 + assert failure.step_type is StepType.MANIFOLD_AUTO_CLEAN + assert failure.rejection is not None + assert failure.rejection.code != 0 + + +class TestThePlateRule: + """Which step types a plate allows, which is the first thing asked about a step.""" + + @pytest.mark.parametrize( + "step_type, wells, allowed", + [ + (StepType.MANIFOLD_WASH, 96, True), + (StepType.MANIFOLD_WASH, 384, True), + (StepType.MANIFOLD_WASH, 1536, False), + (StepType.WASH_1536, 1536, True), + (StepType.WASH_1536, 96, False), + (StepType.MANIFOLD_PRIME, 1536, True), + (StepType.PERI_DISPENSE, 1536, True), + (StepType.PERI_WASH_ASPIRATE, 96, True), + (StepType.PERI_WASH_ASPIRATE, 1536, False), + ], + ) + def test_a_plate_allows_a_step_type_or_does_not( + self, step_type: StepType, wells: int, allowed: bool + ): + plate = {96: PLATE_96, 384: PLATE_384, 1536: PLATE_1536}[wells] + assert (plate_rules.check_plate(plate, step_type) is None) is allowed + + def test_a_wash_on_a_1536_plate_has_a_reason_of_its_own(self): + """That plate has a wash of its own, so the rejection says so rather than reporting a plate the + step cannot use.""" + plain = plate_rules.check_plate(PLATE_1536, StepType.MANIFOLD_WASH) + unusable = plate_rules.check_plate(PLATE_96, StepType.WASH_1536) + assert plain is not None and unusable is not None + assert plain.code != unusable.code + + def test_the_plate_is_the_first_verdict(self): + """A step whose plate rules it out is rejected for that, even when its own fields are also + wrong -- the order matters, because the reason is what a user acts on.""" + report, _ = check([ManifoldWash(cycles=0)], plate=PLATE_1536) + assert not report + assert report.failures[0].rejection == plate_rules.check_plate( + PLATE_1536, StepType.MANIFOLD_WASH + ) + + +class TestWhatMustBeFitted: + """A step that needs hardware the instrument does not have.""" + + @pytest.mark.parametrize( + "step, settings", + [ + (ManifoldAutoClean(), InstrumentSettings(family=EL406, ultrasonic=False)), + (PeriPrime(), InstrumentSettings(family=EL406, peri_pump=False)), + ( + SyringePrime(), + InstrumentSettings( + family=EL406, + syringe_box=SyringeBoxType.NOT_INSTALLED, + syringe_manifold=SyringeManifold.NOT_INSTALLED, + ), + ), + (StripPrime(), InstrumentSettings(family=EL406)), + ], + ) + def test_a_step_whose_hardware_is_absent_cannot_run(self, step: Step, settings): + report, _ = check([step], settings) + assert not report + + def test_a_step_whose_hardware_is_fitted_can_run(self): + report, _ = check([PeriPrime()], InstrumentSettings(family=EL406, peri_pump=True)) + assert report + + def test_the_palette_is_what_is_fitted(self): + everything = available_step_types(InstrumentSettings(family=EL406)) + without = available_step_types( + InstrumentSettings(family=EL406, peri_pump=False, ultrasonic=False) + ) + assert StepType.PERI_DISPENSE in everything + assert StepType.PERI_DISPENSE not in without + assert StepType.MANIFOLD_AUTO_CLEAN not in without + assert StepType.SHAKE_SOAK in without + + +class TestWhatTheProtocolCommitsTo: + """The rules that are about the protocol as a whole rather than one step.""" + + def test_one_buffer_throughout_unless_a_valve_box_can_switch_it(self): + without = InstrumentSettings( + family=EL406, valve_box=ValveBox.NOT_INSTALLED, buffer_switching=False + ) + report, _ = check( + [ManifoldDispense(volume=100, buffer="A"), ManifoldDispense(volume=100, buffer="B")], + without, + ) + assert not report + assert report.failures[0].number == 2 + + def test_the_same_buffer_twice_is_no_conflict(self): + without = InstrumentSettings( + family=EL406, valve_box=ValveBox.NOT_INSTALLED, buffer_switching=False + ) + report, _ = check( + [ManifoldDispense(volume=100, buffer="A"), ManifoldDispense(volume=100, buffer="A")], + without, + ) + assert report + + def test_a_conflict_is_reported_against_the_second_step(self): + """The claim accumulates, so the first step is fine and the second is where it shows.""" + report, _ = check( + [ + PeriPrime(cassette_type="1uL", peri_pump="Primary"), + PeriPrime(cassette_type="5uL", peri_pump="Primary"), + ] + ) + assert not report + assert [failure.number for failure in report.failures] == [2] + + +class TestWhatThePassReserves: + """The pass's second output: what the protocol requires of the pumps.""" + + def test_a_pinned_cassette_is_reserved_for_its_pump(self): + _, reservations = check([PeriPrime(cassette_type="5uL", peri_pump="Primary")]) + assert reservations.cassette_primary == "5uL" + assert reservations.uses_primary + + def test_accepting_any_cassette_pins_nothing(self): + _, reservations = check([PeriPrime(cassette_type="Any", peri_pump="Primary")]) + assert reservations.cassette_primary is None + + def test_a_dispense_records_that_it_got_far_enough_to_claim_one(self): + _, reservations = check([PeriDispense(volume=10, cassette_type="5uL")]) + assert reservations.dispense_reserved + + def test_a_protocol_that_drives_no_pump_reserves_nothing(self): + """Which is why a wash-only protocol opens its batch with one command and nothing before it.""" + _, reservations = check([ManifoldPrime(volume=40_000)]) + assert reservations.cassette_primary is None + assert reservations.cassette_secondary is None + assert not reservations.uses_primary + assert not reservations.uses_secondary + + def test_every_pass_starts_over(self): + """A second protocol must not inherit the first one's claims.""" + _, first = check([PeriPrime(cassette_type="5uL")]) + _, second = check([ManifoldPrime(volume=40_000)]) + assert first.cassette_primary == "5uL" + assert second.cassette_primary is None + + +class TestWhatTheInstrumentItselfRefuses: + """Rules about the instrument and the plate, which stop the whole protocol.""" + + def test_a_plate_the_instrument_does_not_accept_stops_everything(self): + report, _ = check( + [ManifoldPrime(volume=40_000)], + plate=PLATE_384, + plate_restriction=PlateRestriction.ALLOW_96_WELL_ONLY, + ) + assert not report + assert report.rejection is not None + assert report.steps == [] + assert "cannot run" in str(report) + + def test_an_accepted_plate_runs(self): + report, _ = check( + [ManifoldPrime(volume=40_000)], + plate=PLATE_96, + plate_restriction=PlateRestriction.ALLOW_96_WELL_ONLY, + ) + assert report + + def test_a_restriction_the_instrument_was_not_asked_about_is_not_guessed(self): + report, _ = check([ManifoldPrime(volume=40_000)], plate=PLATE_384) + assert report + + +class TestWhatTheFirmwareKnows: + """The per-model rule sets, which are data rather than code.""" + + def test_the_models_do_not_check_identically(self): + assert rules_for(InstrumentFamily.MULTIFLO_FX) is MULTIFLO_FX + assert rules_for(InstrumentFamily.EL406) is COMMON + assert MULTIFLO_FX.basecode_step_types + assert not COMMON.basecode_step_types + + def test_the_oldest_firmware_lacks_rules_the_others_have(self): + older = rules_for(InstrumentFamily.MULTIFLO) + assert older.absent_checks + assert not COMMON.absent_checks + + def test_a_rule_a_model_does_not_have_is_skipped(self): + """A washer manifold the plate rules out is a rejection on a model that checks for it, and the + step carries on to the next rule on one that does not.""" + settings = InstrumentSettings(family=EL406, washer_manifold=WasherManifold.TUBE_128) + report, _ = check([ManifoldDispense(volume=100)], settings, plate=PLATE_96) + assert not report From af70a2cb8c8e6faa7958fcd4b30404e252cc037a Mon Sep 17 00:00:00 2001 From: StefanMa Date: Mon, 31 Aug 2026 18:43:34 +0200 Subject: [PATCH 07/19] final polishing --- docs/_static/devices.json | 50 +- docs/api/pylabrobot.agilent.rst | 40 +- pylabrobot/agilent/biotek/__init__.py | 2 +- pylabrobot/agilent/biotek/el406/__init__.py | 1 - pylabrobot/agilent/biotek/el406/el406.py | 1112 --------------- pylabrobot/agilent/biotek/el406/enums.py | 91 -- .../agilent/biotek/el406/error_codes.py | 247 ---- pylabrobot/agilent/biotek/el406/errors.py | 47 - pylabrobot/agilent/biotek/el406/helpers.py | 104 -- .../biotek/el406/peristaltic_dispenser.py | 497 ------- .../agilent/biotek/el406/plate_washer.py | 1224 ----------------- pylabrobot/agilent/biotek/el406/protocol.py | 107 -- .../agilent/biotek/el406/syringe_dispenser.py | 374 ----- .../components/peristaltic_dispenser.py | 56 +- .../devices/components/syringe_dispenser.py | 25 +- .../biotek/lhc/devices/components/washer.py | 178 ++- .../agilent/biotek/lhc/devices/el406.py | 1 - .../agilent/biotek/lhc/devices/execution.py | 28 +- .../biotek/lhc/devices/instrument_settings.py | 7 +- .../agilent/biotek/lhc/devices/multiflo.py | 1 - .../agilent/biotek/lhc/devices/multiflo_fx.py | 1 - .../agilent/biotek/lhc/devices/queries.py | 102 ++ .../agilent/biotek/lhc/devices/runtime.py | 9 +- .../biotek/lhc/devices/settings_query.py | 147 +- .../biotek/lhc/devices/washer_405ts.py | 1 - .../biotek/lhc/enums/instrument/__init__.py | 6 - .../biotek/lhc/enums/instrument/basecode.py | 2 + .../lhc/enums/instrument/instrument_family.py | 2 + .../lhc/enums/instrument/jig_location.py | 10 - .../lhc/enums/instrument/keypad_security.py | 13 - .../enums/instrument/level_sensor_state.py | 13 - .../lhc/enums/instrument/offset_manifold.py | 19 - .../lhc/enums/instrument/product_type.py | 20 - .../biotek/lhc/enums/instrument/sensor.py | 2 + .../enums/instrument/strip_washer_manifold.py | 2 + .../biotek/lhc/enums/instrument/subsystem.py | 22 - .../lhc/enums/instrument/syringe_box_size.py | 2 + .../lhc/enums/instrument/syringe_box_type.py | 2 + .../lhc/enums/instrument/syringe_manifold.py | 2 + .../biotek/lhc/enums/instrument/valve_box.py | 2 + .../lhc/enums/instrument/washer_manifold.py | 2 + .../biotek/lhc/enums/motion/__init__.py | 11 +- .../lhc/enums/motion/basecode_motor_405ts.py | 12 - .../lhc/enums/motion/basecode_motor_406.py | 15 - .../enums/motion/basecode_motor_multiflo.py | 18 - .../biotek/lhc/enums/motion/carrier_speed.py | 13 - .../biotek/lhc/enums/motion/carrier_type.py | 2 + .../agilent/biotek/lhc/enums/motion/motor.py | 2 + .../lhc/enums/motion/motor_home_type.py | 2 + .../biotek/lhc/enums/motion/motor_sensor.py | 12 - .../lhc/enums/plates/plate_restriction.py | 2 + .../biotek/lhc/enums/plates/plate_type.py | 2 + .../biotek/lhc/enums/status/activity.py | 2 + .../biotek/lhc/enums/status/run_state.py | 2 + .../biotek/lhc/enums/steps/__init__.py | 4 - .../agilent/biotek/lhc/enums/steps/buffer.py | 2 + .../biotek/lhc/enums/steps/cassette_head.py | 2 + .../biotek/lhc/enums/steps/cassette_mode.py | 2 + .../biotek/lhc/enums/steps/cassette_type.py | 2 + .../biotek/lhc/enums/steps/fill_pattern.py | 12 - .../biotek/lhc/enums/steps/peri_flow_rate.py | 2 + .../biotek/lhc/enums/steps/peri_pump.py | 2 + .../enums/steps/secondary_aspirate_pattern.py | 2 + .../biotek/lhc/enums/steps/shake_axis.py | 2 + .../biotek/lhc/enums/steps/shake_intensity.py | 10 +- .../biotek/lhc/enums/steps/step_action.py | 2 + .../biotek/lhc/enums/steps/step_type.py | 2 + .../agilent/biotek/lhc/enums/steps/syringe.py | 2 + .../biotek/lhc/enums/steps/syringe_bottle.py | 2 + .../biotek/lhc/enums/steps/travel_rate.py | 30 +- .../biotek/lhc/enums/steps/wash_format.py | 2 + .../biotek/lhc/error_handling/error_codes.py | 2 +- .../biotek/lhc/plate_geometry/plate_record.py | 28 + .../lhc/protocols/steps/step_parts/groups.py | 2 +- .../protocols/steps/step_parts/positioning.py | 27 +- .../steps/steps/manifold_aspirate.py | 18 +- .../steps/steps/manifold_dispense.py | 12 +- .../protocols/steps/steps/peri_dispense.py | 14 +- .../steps/peri_random_access_dispense.py | 10 +- .../steps/steps/peri_wash_aspirate.py | 12 +- .../steps/steps/peri_wash_dispense.py | 12 +- .../protocols/steps/steps/strip_aspirate.py | 18 +- .../protocols/steps/steps/strip_dispense.py | 12 +- .../protocols/steps/steps/syringe_dispense.py | 14 +- .../lhc/protocols/steps/steps/wash_1536.py | 6 +- .../biotek/lhc/protocols/validation/checks.py | 3 +- .../lhc/protocols/validation/step_checks.py | 60 +- .../lhc/serialization/commands/__init__.py | 4 - .../serialization/commands/configuration.py | 13 - .../lhc/serialization/commands/diagnostics.py | 15 - .../agilent/biotek/lhc/serialization/frame.py | 5 - .../agilent/biotek/lhc/tests/corpus_tests.py | 1 + .../biotek/lhc/tests/definition_tests.py | 7 + .../agilent/biotek/lhc/tests/device_tests.py | 305 ++++ .../biotek/lhc/tests/error_handling_tests.py | 9 + .../biotek/lhc/tests/execution_tests.py | 18 +- .../biotek/lhc/tests/hardware_tests.py | 1 + .../agilent/biotek/lhc/tests/link_tests.py | 17 + .../biotek/lhc/tests/payload_bytes_tests.py | 9 +- .../biotek/lhc/tests/plate_geometry_tests.py | 8 + .../biotek/lhc/tests/protocol_file_tests.py | 10 + .../biotek/lhc/tests/settings_tests.py | 19 +- .../biotek/lhc/tests/step_payload_tests.py | 8 +- .../biotek/lhc/tests/validation_tests.py | 17 + 104 files changed, 1062 insertions(+), 4404 deletions(-) delete mode 100644 pylabrobot/agilent/biotek/el406/__init__.py delete mode 100644 pylabrobot/agilent/biotek/el406/el406.py delete mode 100644 pylabrobot/agilent/biotek/el406/enums.py delete mode 100644 pylabrobot/agilent/biotek/el406/error_codes.py delete mode 100644 pylabrobot/agilent/biotek/el406/errors.py delete mode 100644 pylabrobot/agilent/biotek/el406/helpers.py delete mode 100644 pylabrobot/agilent/biotek/el406/peristaltic_dispenser.py delete mode 100644 pylabrobot/agilent/biotek/el406/plate_washer.py delete mode 100644 pylabrobot/agilent/biotek/el406/protocol.py delete mode 100644 pylabrobot/agilent/biotek/el406/syringe_dispenser.py create mode 100644 pylabrobot/agilent/biotek/lhc/devices/queries.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/jig_location.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/keypad_security.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/level_sensor_state.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/offset_manifold.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/product_type.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/instrument/subsystem.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_405ts.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_406.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_multiflo.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/carrier_speed.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/motion/motor_sensor.py delete mode 100644 pylabrobot/agilent/biotek/lhc/enums/steps/fill_pattern.py diff --git a/docs/_static/devices.json b/docs/_static/devices.json index c6ec378ff72..cd08c6eb55c 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -46,6 +46,21 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge" }, + { + "id": "biotek-405-ts", + "vendor": "Agilent (BioTek)", + "name": "405 TS", + "kind": "plate washer", + "capabilities": [ + "plate washing" + ], + "status": "mostly", + "api": "pylabrobot.agilent.biotek.lhc.Washer405TS", + "api_version": "v1", + "code_slug": "agilent/biotek/lhc", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.agilent.com/en/product/microplate-instrumentation/automated-liquid-dispensing-handling/automated-microplate-washers-dispensers/biotek-405-ts-microplate-washer-1623261" + }, { "id": "biotek-cytation-1", "vendor": "Agilent (BioTek)", @@ -106,12 +121,43 @@ "dispensing" ], "status": "mostly", - "api": "pylabrobot.agilent.biotek.el406.EL406", + "api": "pylabrobot.agilent.biotek.lhc.EL406", "api_version": "v1", - "code_slug": "agilent/biotek/el406", + "code_slug": "agilent/biotek/lhc", "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.agilent.com/en/product/cell-analysis/microplate-automation-detection/microplate-washers-dispensers/biotek-el406-washer-dispenser-1623255" }, + { + "id": "biotek-multiflo", + "vendor": "Agilent (BioTek)", + "name": "MultiFlo", + "kind": "bulk dispenser", + "capabilities": [ + "dispensing" + ], + "status": "mostly", + "api": "pylabrobot.agilent.biotek.lhc.MultiFlo", + "api_version": "v1", + "code_slug": "agilent/biotek/lhc", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.agilent.com/en/product/microplate-instrumentation/automated-liquid-dispensing-handling/automated-microplate-dispensers/biotek-multiflo-microplate-dispenser-1623263" + }, + { + "id": "biotek-multiflo-fx", + "vendor": "Agilent (BioTek)", + "name": "MultiFlo FX", + "kind": "bulk dispenser", + "capabilities": [ + "dispensing", + "plate washing" + ], + "status": "mostly", + "api": "pylabrobot.agilent.biotek.lhc.MultiFloFX", + "api_version": "v1", + "code_slug": "agilent/biotek/lhc", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.agilent.com/en/product/microplate-instrumentation/automated-liquid-dispensing-handling/automated-microplate-dispensers/biotek-multiflo-fx-multi-mode-dispenser-1623264" + }, { "id": "biotek-synergy-h1", "vendor": "Agilent (BioTek)", diff --git a/docs/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst index 4f03a36203b..8097d521fb8 100644 --- a/docs/api/pylabrobot.agilent.rst +++ b/docs/api/pylabrobot.agilent.rst @@ -18,10 +18,12 @@ BenchCel 4R PlateNotchSettings -BioTek EL406 ------------- +BioTek washers and dispensers +----------------------------- -.. currentmodule:: pylabrobot.agilent.biotek.el406 +One class per model. Each exposes the capability objects its fitted hardware supports. + +.. currentmodule:: pylabrobot.agilent.biotek.lhc .. autosummary:: :toctree: _autosummary @@ -29,8 +31,14 @@ BioTek EL406 :recursive: EL406 + MultiFlo + MultiFloFX + Washer405TS + Protocol + read + write -.. currentmodule:: pylabrobot.agilent.biotek.el406.plate_washer +.. currentmodule:: pylabrobot.agilent.biotek.lhc.devices.components .. autosummary:: :toctree: _autosummary @@ -38,24 +46,40 @@ BioTek EL406 :recursive: PlateWasher + SyringeDispenser + PeristalticDispenser -.. currentmodule:: pylabrobot.agilent.biotek.el406.syringe_dispenser +.. currentmodule:: pylabrobot.agilent.biotek.lhc.devices .. autosummary:: :toctree: _autosummary :nosignatures: :recursive: - SyringeDispenser + InstrumentSettings + SettingsComparison -.. currentmodule:: pylabrobot.agilent.biotek.el406.peristaltic_dispenser +.. currentmodule:: pylabrobot.agilent.biotek.lhc.protocols.validation .. autosummary:: :toctree: _autosummary :nosignatures: :recursive: - PeristalticDispenser + ValidationReport + StepReport + Rejection + +.. currentmodule:: pylabrobot.agilent.biotek.lhc.error_handling + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BiotekError + LinkError + RejectedError BioTek Cytation diff --git a/pylabrobot/agilent/biotek/__init__.py b/pylabrobot/agilent/biotek/__init__.py index abd709b1806..e363fd9ad5b 100644 --- a/pylabrobot/agilent/biotek/__init__.py +++ b/pylabrobot/agilent/biotek/__init__.py @@ -1,3 +1,3 @@ from .cytation import Cytation1, Cytation5, CytationImagingConfig -from .el406 import EL406 +from .lhc import EL406, MultiFlo, MultiFloFX, Washer405TS from .synergy import SynergyH1 diff --git a/pylabrobot/agilent/biotek/el406/__init__.py b/pylabrobot/agilent/biotek/el406/__init__.py deleted file mode 100644 index e3d743aa40f..00000000000 --- a/pylabrobot/agilent/biotek/el406/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from pylabrobot.agilent.biotek.el406.el406 import EL406 diff --git a/pylabrobot/agilent/biotek/el406/el406.py b/pylabrobot/agilent/biotek/el406/el406.py deleted file mode 100644 index 7e8bfde8c4b..00000000000 --- a/pylabrobot/agilent/biotek/el406/el406.py +++ /dev/null @@ -1,1112 +0,0 @@ -from __future__ import annotations - -import asyncio -import enum -import logging -import time -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Literal, NamedTuple, TypedDict, TypeVar - -from pylabrobot.agilent.biotek.el406.peristaltic_dispenser import PeristalticDispenser -from pylabrobot.agilent.biotek.el406.plate_washer import PlateWasher -from pylabrobot.agilent.biotek.el406.syringe_dispenser import SyringeDispenser -from pylabrobot.io.binary import Reader, Writer -from pylabrobot.io.ftdi import FTDI -from pylabrobot.resources import Coordinate, Plate, PlateHolder - -from .enums import ( - EL406Motor, - EL406MotorHomeType, - EL406Sensor, - EL406StepType, - EL406SyringeManifold, - EL406WasherManifold, -) -from .error_codes import get_error_message -from .errors import EL406CommunicationError, EL406DeviceError -from .helpers import plate_to_wire_byte -from .protocol import build_framed_message - -logger = logging.getLogger(__name__) - -Intensity = Literal["Variable", "Slow", "Medium", "Fast"] - -INTENSITY_TO_BYTE: dict[str, int] = { - "Variable": 0x01, - "Slow": 0x02, - "Medium": 0x03, - "Fast": 0x04, -} - - -def validate_intensity(intensity: Intensity) -> None: - if intensity not in {"Slow", "Medium", "Fast", "Variable"}: - raise ValueError( - f"intensity must be one of {sorted({'Slow', 'Medium', 'Fast', 'Variable'})}, " - f"got {intensity!r}" - ) - - -LONG_READ_TIMEOUT = 120.0 # seconds, for long operations (wash cycles can take >30s) - -STATE_INITIAL = 1 -STATE_RUNNING = 2 -STATE_PAUSED = 3 -STATE_STOPPED = 4 - - -class DevicePollResult(NamedTuple): - """Parsed result from a STATUS_POLL response.""" - - validity: int - state: int - status: int - raw_response: bytes - - -class EL406: - """FTDI-based driver for the BioTek EL406 plate washer. - - Owns the USB connection, low-level protocol framing, command serialization, - batch management, and device-level operations (reset, home, pause, etc.). - """ - - def __init__( - self, - name: str, - timeout: float = 15.0, - device_id: str | None = None, - ) -> None: - super().__init__() - self.timeout = timeout - self._device_id = device_id - self.io = FTDI(human_readable_device_name="BioTek EL406", device_id=self._device_id) - self._command_lock: asyncio.Lock | None = None - self._in_batch: bool = False - self._current_plate: Plate | None = None - - self.washer = self.warsher = PlateWasher(self) - self.syringe_dispenser = SyringeDispenser(self) - self.peristaltic_dispenser = PeristalticDispenser(self) - - self.plate_holder = PlateHolder( - name=name + "_plate_holder", - size_x=127.76, - size_y=85.48, - size_z=0, - pedestal_size_z=0, - child_location=Coordinate.zero(), - ) - - @property - def plate(self) -> Plate: - """The plate currently loaded in the EL406. - - Raises: - RuntimeError: If no plate is loaded. - """ - if self._current_plate is None: - raise RuntimeError("No plate is loaded in the EL406. Call set_plate() first.") - return self._current_plate - - def set_plate(self, plate: Plate) -> None: - """Record the plate now sitting in the carrier. - - Operations encode the plate geometry into their wire commands, so this must match the - labware physically loaded. - """ - self._current_plate = plate - - def clear_plate(self) -> None: - """Forget the loaded plate. Operations raise until :meth:`set_plate` is called again.""" - self._current_plate = None - - async def setup(self, skip_reset: bool = False) -> None: - """Set up communication with the EL406. - - Configures the FTDI USB interface with the correct parameters: - - 38400 baud - - 8 data bits, 2 stop bits, no parity (8N2) - - No flow control (disabled) - - If ``self.io`` is already set (e.g. injected mock for testing), - it is used as-is and ``setup()`` is not called on it again. - - Args: - skip_reset: If True, skip the instrument reset step during setup. - - Raises: - RuntimeError: If pylibftdi is not installed or communication fails. - """ - self._command_lock = asyncio.Lock() - - logger.info("EL406Driver setting up") - logger.info(" Timeout: %.1f seconds", self.timeout) - - await self.io.setup() - - # Configure serial parameters - logger.debug("Configuring serial parameters...") - try: - await self.io.set_baudrate(38400) - await self.io.set_line_property(8, 2, 0) # 8 data bits, 2 stop bits, no parity - logger.info(" Serial: 38400 baud, 8N2") - - SIO_DISABLE_FLOW_CTRL = 0x0 - await self.io.set_flowctrl(SIO_DISABLE_FLOW_CTRL) - logger.info(" Flow control: NONE") - - await self.io.set_rts(True) - await self.io.set_dtr(True) - logger.debug(" RTS and DTR enabled") - except Exception as e: - await self.io.stop() - raise EL406CommunicationError( - f"Failed to configure FTDI device: {e}", - operation="configure", - original_error=e, - ) from e - - # Purge buffers - logger.debug("Purging TX/RX buffers...") - await self._purge_buffers() - - # Test communication - logger.info("Testing communication with device...") - try: - await self._test_communication() - logger.info(" Communication test: PASSED") - except Exception as e: - logger.error(" Communication test: FAILED - %s", e) - raise - - if not skip_reset: - logger.info("Performing full instrument reset...") - await self.reset() - logger.info(" Instrument reset: DONE") - - logger.info("EL406Driver setup complete") - - async def stop(self) -> None: - """Close the FTDI connection.""" - logger.info("EL406Driver stopping") - await self.io.stop() - - # --------------------------------------------------------------------------- - # Low-level I/O - # --------------------------------------------------------------------------- - - async def _write_to_device(self, data: bytes) -> None: - """Write bytes to the FTDI device, wrapping errors. - - Raises: - EL406CommunicationError: If the write fails. - """ - assert self.io is not None - try: - await self.io.write(data) - except Exception as e: - raise EL406CommunicationError( - f"Failed to write to device: {e}. Device may have disconnected.", - operation="write", - original_error=e, - ) from e - - async def _wait_for_ack(self, timeout: float, t0: float) -> None: - """Poll device for ACK byte within the remaining timeout window. - - Args: - timeout: Total timeout budget in seconds. - t0: Start timestamp (from ``time.monotonic()``). - - Raises: - RuntimeError: If device sends NAK. - TimeoutError: If no ACK within timeout. - """ - assert self.io is not None - while time.monotonic() - t0 < timeout: - byte = await self.io.read(1) - if byte: - if byte[0] == 0x15: # NAK - raise RuntimeError( - f"Device rejected command (NAK). Response: {byte!r}. " - "This may indicate an invalid command, bad parameters, or device busy state." - ) - if byte[0] == 0x06: # ACK - return - await asyncio.sleep(0.01) - raise TimeoutError("Timeout waiting for ACK") - - async def _read_exact_bytes(self, count: int, timeout: float, t0: float) -> bytes: - """Read exactly *count* bytes from the device, polling until done or timeout. - - Args: - count: Number of bytes to read. - timeout: Total timeout budget in seconds. - t0: Start timestamp (from ``time.monotonic()``). - - Returns: - Bytes read (may be shorter than *count* if timeout is reached). - """ - assert self.io is not None - buf = b"" - while len(buf) < count and time.monotonic() - t0 < timeout: - chunk = await self.io.read(count - len(buf)) - if chunk: - buf += chunk - else: - await asyncio.sleep(0.01) - return buf - - async def _purge_buffers(self) -> None: - """Purge the RX and TX buffers.""" - if self.io is None: - return - - try: - for _ in range(6): - await self.io.usb_purge_rx_buffer() - await self.io.usb_purge_tx_buffer() - except Exception as e: - raise EL406CommunicationError( - f"Failed to purge FTDI buffers: {e}. Device may have disconnected.", - operation="purge", - original_error=e, - ) from e - - async def _test_communication(self) -> None: - """Test communication with the device. - - Sends framed command 0x73 (115) and expects ACK (0x06) response. - - Raises: - RuntimeError: If communication test fails. - """ - if self.io is None: - raise RuntimeError("EL406 communication test failed: device not open") - - try: - framed_command = build_framed_message(command=0x73) - response = await self._send_framed_command(framed_command, timeout=self.timeout) - if 0x06 not in response: - raise RuntimeError( - f"EL406 communication test failed: expected ACK (0x06), got {response!r}" - ) - except TimeoutError as e: - raise RuntimeError(f"EL406 communication test failed: timeout - {e}") from e - - logger.info("EL406 communication test passed") - - # Send INIT_STATE (0xA0) command to clear device state - logger.info("Sending INIT_STATE command (0xA0) to clear device state") - init_state_cmd = build_framed_message(command=0xA0) - init_response = await self._send_framed_command(init_state_cmd, timeout=self.timeout) - logger.debug("INIT_STATE sent, response: %s", init_response.hex()) - - # --------------------------------------------------------------------------- - # Command sending - # --------------------------------------------------------------------------- - - async def _send_framed_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: - """Send a framed command and wait for full response. - - The device responds to framed commands with: - - ACK (0x06) + 11-byte header + N-byte data - - This method reads the complete response to avoid leaving data in the buffer. - For ACK-only commands (e.g. TEST_COMM, INIT_STATE), the header wait acts as - an implicit settling delay that the device needs before accepting further - commands. - - Args: - framed_message: Complete framed message (from build_framed_message). - timeout: Timeout in seconds. - - Returns: - Complete response bytes (ACK + header + data). - - Raises: - TimeoutError: If timeout waiting for response. - """ - if self.io is None or self._command_lock is None: - raise RuntimeError("Device not initialized") - - if timeout is None: - timeout = self.timeout - - async with self._command_lock: - await self._purge_buffers() - - # Send header and data separately - header = framed_message[:11] - data = framed_message[11:] if len(framed_message) > 11 else b"" - - await self._write_to_device(header) - logger.debug("Sent header: %s", header.hex()) - - if data: - await asyncio.sleep(0.001) # Small delay between header and data - await self._write_to_device(data) - logger.debug("Sent data: %s", data.hex()) - logger.debug("Sent framed: %s", framed_message.hex()) - - # Read full response: ACK + 11-byte header + variable data - await self._wait_for_ack(timeout, time.monotonic()) - result = bytes([0x06]) - - # Fresh timestamp after ACK — header + data share a single timeout budget. - t0 = time.monotonic() - resp_header = await self._read_exact_bytes(11, timeout, t0) - - if len(resp_header) == 11: - result += resp_header - # Parse data length from header bytes 7-8 (little-endian) - data_len = Reader(resp_header[7:]).u16() - response_data = await self._read_exact_bytes(data_len, timeout, t0) - result += response_data - logger.debug("Full response: %s (%d bytes)", result.hex(), len(result)) - else: - logger.debug("ACK-only response (no frame): %s", result.hex()) - - return result - - async def _send_action_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: - """Send an action command and wait for completion frame. - - Action commands (like reset, home_motors) work differently from query commands: - 1. Send command - 2. Device sends ACK immediately (acknowledging receipt) - 3. Device performs the physical action (takes time) - 4. Device sends completion frame when done - - This method waits for both the ACK and the completion frame. - - Args: - framed_message: Complete framed message (from build_framed_message). - timeout: Timeout in seconds for the entire operation including action completion. - - Returns: - Completion frame bytes (header + data). - - Raises: - TimeoutError: If timeout waiting for ACK or completion. - RuntimeError: If device rejects command (NAK). - """ - if self.io is None or self._command_lock is None: - raise RuntimeError("Device not initialized") - - if timeout is None: - timeout = LONG_READ_TIMEOUT # Default to long timeout for actions - - async with self._command_lock: - await self._purge_buffers() - - # Send header and data separately (matches _send_framed_command protocol) - header = framed_message[:11] - data = framed_message[11:] if len(framed_message) > 11 else b"" - - await self._write_to_device(header) - if data: - await asyncio.sleep(0.001) - await self._write_to_device(data) - logger.debug("Sent action command: %s", framed_message.hex()) - - t0 = time.monotonic() - - # Step 1: Wait for ACK (short timeout) - await self._wait_for_ack(min(timeout, self.timeout), t0) - logger.debug("Got ACK, waiting for completion...") - - # Step 2: Wait for completion frame (11-byte header + data) - header = await self._read_exact_bytes(11, timeout, t0) - if len(header) < 11: - raise TimeoutError(f"Timeout waiting for completion header (got {len(header)} bytes)") - - # Parse data length and read remaining data - data_len = Reader(header[7:]).u16() - data = await self._read_exact_bytes(data_len, timeout, t0) - - result = header + data - - logger.debug("Completion frame: %s (%d bytes)", result.hex(), len(result)) - - # Parse and log result - cmd_echo = Reader(result[2:]).u16() - response_data = result[11 : 11 + data_len] if len(result) >= 11 + data_len else b"" - logger.debug(" Command echo: 0x%04X, data: %s", cmd_echo, response_data.hex()) - - return result - - async def _send_framed_query( - self, - command: int, - data: bytes = b"", - timeout: float | None = None, - ) -> bytes: - """Send a framed query command and read full response with header and data. - - Sends the 11-byte header and optional data payload as separate USB writes, - then reads the full response: ACK + 11-byte response header + data. - - Args: - command: 16-bit command code - data: Optional data bytes to send with command - timeout: Timeout in seconds - - Returns: - Data bytes from response (header stripped). - - Raises: - RuntimeError: If device not initialized or response invalid. - TimeoutError: If timeout waiting for response. - """ - if self.io is None or self._command_lock is None: - raise RuntimeError("Device not initialized") - - if timeout is None: - timeout = self.timeout - - framed_message = build_framed_message(command, data) - - async with self._command_lock: - await self._purge_buffers() - - # Split header and data - msg_header = framed_message[:11] - msg_data = framed_message[11:] if len(framed_message) > 11 else b"" - - await self._write_to_device(msg_header) - logger.debug("Sent query header 0x%04X: %s", command, msg_header.hex()) - - if msg_data: - await asyncio.sleep(0.001) - await self._write_to_device(msg_data) - logger.debug("Sent query data: %s", msg_data.hex()) - - # Wait for ACK - try: - await self._wait_for_ack(timeout, time.monotonic()) - except RuntimeError as e: - raise RuntimeError( - f"Device rejected command 0x{command:04X} (NAK). Check command code and parameters." - ) from e - except TimeoutError as e: - raise TimeoutError(f"Timeout waiting for ACK (command 0x{command:04X})") from e - - t0 = time.monotonic() - # Read 11-byte response header (shares timeout budget with data) - resp_header = await self._read_exact_bytes(11, timeout, t0) - if len(resp_header) < 11: - raise TimeoutError(f"Timeout reading response header (got {len(resp_header)}/11 bytes)") - - logger.debug("Response header: %s", resp_header.hex()) - - # Parse data length from header bytes 7-8 (little-endian) - data_len = Reader(resp_header[7:]).u16() - logger.debug("Response data length: %d", data_len) - - # Read data bytes - response_data = await self._read_exact_bytes(data_len, timeout, t0) - if len(response_data) < data_len: - raise TimeoutError( - f"Timeout reading response data (got {len(response_data)}/{data_len} bytes)" - ) - - logger.debug("Response data: %s", response_data.hex()) - return response_data - - # --------------------------------------------------------------------------- - # Polling - # --------------------------------------------------------------------------- - - async def _poll_device_state(self) -> DevicePollResult: - """Send one STATUS_POLL and return the parsed device state. - - Returns: - DevicePollResult with validity, state, status, and raw_response. - - Raises: - EL406CommunicationError: If poll response is too short to parse. - """ - poll_command = build_framed_message(command=0x92) - poll_response = await self._send_framed_command(poll_command, timeout=self.timeout) - logger.debug("Status poll response (%d bytes): %s", len(poll_response), poll_response.hex()) - - if len(poll_response) < 21: - # Short response — return zeroed fields so callers can handle it - return DevicePollResult(validity=0, state=0, status=0, raw_response=poll_response) - - # Data layout (after ACK+header at offset 12): - # bytes 12-13: validity (little-endian, must be 0) - # bytes 14-15: state (little-endian) - # bytes 16-19: timestamp/counter - # byte 20: status code - r = Reader(poll_response[12:]) - validity = r.u16() - state = r.u16() - r.raw_bytes(4) # skip timestamp/counter (bytes 16-19) - status = r.u8() - - if validity != 0: - error_msg = get_error_message(validity) - logger.warning("Status poll returned error 0x%04X (%d): %s", validity, validity, error_msg) - - logger.debug("Status poll: validity=%d, state=%d, status=%d", validity, state, status) - return DevicePollResult( - validity=validity, state=state, status=status, raw_response=poll_response - ) - - async def _wait_until_ready(self, timeout: float = 5.0, poll_interval: float = 0.1) -> None: - """Poll until the device is no longer in STATE_RUNNING. - - Args: - timeout: Maximum time to wait in seconds. - poll_interval: Time between polls in seconds. - - Raises: - TimeoutError: If the device stays busy beyond *timeout*. - """ - t0 = time.monotonic() - while time.monotonic() - t0 < timeout: - poll = await self._poll_device_state() - if poll.state != STATE_RUNNING: - return - await asyncio.sleep(poll_interval) - raise TimeoutError(f"Device still busy (STATE_RUNNING) after {timeout}s waiting for readiness") - - async def _send_step_command( - self, - framed_message: bytes, - timeout: float | None = None, - poll_interval: float = 0.1, - ) -> bytes: - """Send a step command and poll for completion. - - Step commands (prime, dispense, aspirate, shake, etc.) require polling - for completion using STATUS_POLL (0x92) until the operation completes. - - Protocol flow: - 1. Wait for device to be ready (not RUNNING) - 2. Send step command (e.g., SYRINGE_PRIME 0xA2) - 3. Device ACKs immediately - 4. Poll with STATUS_POLL (0x92) repeatedly - 5. Check state in response to determine completion - - Args: - framed_message: Complete framed message (from build_framed_message). - timeout: Timeout in seconds for the entire operation. - poll_interval: Time between status polls in seconds. - - Returns: - Final status response bytes. - - Raises: - TimeoutError: If timeout waiting for completion. - EL406DeviceError: If device reports an error during the step. - RuntimeError: If device rejects command (NAK). - """ - if self.io is None: - raise RuntimeError("Device not initialized") - - if timeout is None: - timeout = LONG_READ_TIMEOUT - - logger.debug("Starting step command with timeout=%ss", timeout) - - # 1. Wait for device to be ready (not RUNNING) - await self._wait_until_ready(timeout=min(timeout, self.timeout)) - - # 2. Send the step command - logger.debug("Sending step command: %s", framed_message.hex()) - response = await self._send_framed_command(framed_message, timeout=min(timeout, self.timeout)) - logger.debug("Step command sent, got initial response: %s", response.hex()) - - # 3. Initial delay before polling - await asyncio.sleep(0.5) - - # 4. Poll for completion - t0 = time.monotonic() - poll_count = 0 - - logger.debug("Starting polling loop...") - - while time.monotonic() - t0 < timeout: - await asyncio.sleep(poll_interval) - poll_count += 1 - - poll = await self._poll_device_state() - logger.debug("Poll #%d: %d bytes", poll_count, len(poll.raw_response)) - - if poll.state in (STATE_INITIAL, STATE_STOPPED): - logger.debug("Step completed (state=%d) after %d polls", poll.state, poll_count) - if poll.validity != 0: - raise EL406DeviceError(poll.validity, get_error_message(poll.validity)) - return poll.raw_response - - if poll.state == STATE_RUNNING: - logger.debug("Step in progress (state=Running), continuing poll...") - elif poll.state == STATE_PAUSED: - logger.warning("Step is paused (state=3)") - elif poll.status == 0: - # Unknown state with status=0 means done - logger.debug("Done (unknown state=%d, status=0)", poll.state) - return poll.raw_response - else: - logger.debug("Unknown state=%d, status=%d, continuing...", poll.state, poll.status) - - raise TimeoutError(f"Timeout waiting for step completion after {timeout}s") - - # --------------------------------------------------------------------------- - # Batch management - # --------------------------------------------------------------------------- - - @asynccontextmanager - async def batch(self) -> AsyncIterator[None]: - """Context manager for batching step commands. - - Each step command (wash, syringe_prime, etc.) automatically wraps - its execution in a batch. Use this context manager to group multiple step - commands into a single batch, avoiding repeated start/cleanup cycles. - - If already inside a batch, this is a no-op passthrough. - - The plate must be assigned to the device's plate_holder before calling this. - - Example: - >>> async with driver.batch(): - ... await driver._send_step_command(framed_cmd) - """ - if self._in_batch: - yield - return - - self._in_batch = True - try: - await self.start_batch(plate_to_wire_byte(self.plate)) - yield - finally: - try: - await self.cleanup_after_protocol() - finally: - self._in_batch = False - - async def start_batch(self, wire_byte: int) -> None: - """Send START_STEP command to begin a batch of step operations. - - Use this function at the beginning of a protocol, before executing any step - commands. This puts the device in "ready to execute steps" mode. Must be - called once before running step commands like prime, dispense, aspirate, - shake, etc. - - This should be called: - - After setup() completes - - Before running any step commands - - Only once per batch of operations (not before each individual step) - - Args: - wire_byte: EL406 plate-type byte for the wire protocol. - """ - if self.io is None: - raise RuntimeError("Device not initialized - call setup() first") - - logger.info("Sending START_STEP to begin batch operations") - - # Send initialization commands before START_STEP - pre_batch_commands = [0xBF, 0xC1, 0xF2, 0xF4, 0x0154, 0x0102, 0x010A] - for cmd in pre_batch_commands: - cmd_frame = build_framed_message(cmd) - try: - resp = await self._send_framed_command(cmd_frame, timeout=self.timeout) - logger.debug("Command 0x%04X response: %s", cmd, resp.hex()) - except Exception as e: - logger.warning("Pre-batch command 0x%04X failed: %s", cmd, e) - - # Data byte is the plate type value (e.g., 0x04 for 96-well, 0x01 for 384-well). - start_step_data = bytes([wire_byte]) - start_step_cmd = build_framed_message(command=0x8D, data=start_step_data) - response = await self._send_framed_command(start_step_cmd, timeout=self.timeout) - logger.debug("START_STEP sent, response: %s", response.hex()) - - # --------------------------------------------------------------------------- - # Device-level operations - # --------------------------------------------------------------------------- - - async def abort( - self, - step_type: EL406StepType | None = None, - ) -> None: - """Abort a running operation. - - Args: - step_type: Optional step type to abort. If None, aborts current operation. - - Raises: - RuntimeError: If device not initialized. - TimeoutError: If timeout waiting for ACK response. - """ - logger.info( - "Aborting %s", - f"step type {step_type.name}" if step_type is not None else "current operation", - ) - - step_type_value = step_type.value if step_type is not None else 0 - data = bytes([step_type_value]) - framed_command = build_framed_message(command=0x89, data=data) - await self._send_framed_command(framed_command) - - async def pause(self) -> None: - """Pause a running operation.""" - logger.info("Pausing operation") - framed_command = build_framed_message(command=0x8A) - await self._send_framed_command(framed_command) - - async def resume(self) -> None: - """Resume a paused operation.""" - logger.info("Resuming operation") - framed_command = build_framed_message(command=0x8B) - await self._send_framed_command(framed_command) - - async def reset(self) -> None: - """Reset the instrument to a known state.""" - logger.info("Resetting instrument") - framed_command = build_framed_message(command=0x70) - await self._send_action_command(framed_command, timeout=LONG_READ_TIMEOUT) - logger.info("Instrument reset complete") - - async def _perform_end_of_batch(self) -> None: - """Perform end-of-batch activities - sends completion marker. - - NOTE: This command (140) is just a completion marker and does NOT: - - Stop the pump - - Home the syringes - - For a complete cleanup after a protocol, use cleanup_after_protocol() instead. - """ - logger.info("Performing end-of-batch activities (completion marker)") - framed_command = build_framed_message(command=0x8C) - await self._send_action_command(framed_command, timeout=60.0) - logger.info("End-of-batch marker sent") - - async def cleanup_after_protocol(self) -> None: - """Complete cleanup after running a protocol. - - This method performs the full cleanup sequence that the original BioTek - software does after all protocol steps complete: - 1. Home the syringes (XYZ motors) - 2. Send end-of-batch completion marker - - This is the recommended way to end a protocol run. - - Example: - >>> # Run protocol steps - >>> await backend.syringe_prime("A", 1000, 5, 2) - >>> await backend.syringe_prime("B", 1000, 5, 2) - >>> # Then cleanup - >>> await backend.cleanup_after_protocol() - """ - logger.info("Starting post-protocol cleanup") - - # Step 1: Home syringes - logger.info(" Homing motors...") - await self.home_motors(EL406MotorHomeType.HOME_XYZ_MOTORS) - - # Step 2: Send end-of-batch marker - logger.info(" Sending end-of-batch marker...") - await self._perform_end_of_batch() - - logger.info("Post-protocol cleanup complete") - - async def home_motors( - self, - home_type: EL406MotorHomeType, - motor: EL406Motor | None = None, - ) -> None: - """Home or verify motor positions.""" - logger.info( - "Home/verify motors: type=%s, motor=%s", - home_type.name, - motor.name if motor is not None else "default(0)", - ) - - motor_num = motor.value if motor is not None else 0 - data = bytes([home_type.value, motor_num]) - framed_command = build_framed_message(command=0xC8, data=data) - await self._send_action_command(framed_command, timeout=120.0) - logger.info("Motors homed") - - async def set_washer_manifold(self, manifold: EL406WasherManifold) -> None: - """Set the washer manifold type.""" - logger.info("Setting washer manifold to: %s", manifold.name) - data = bytes([manifold.value]) - framed_command = build_framed_message(command=0xD9, data=data) - await self._send_framed_command(framed_command) - logger.info("Washer manifold set to: %s", manifold.name) - - # --------------------------------------------------------------------------- - # Queries - # --------------------------------------------------------------------------- - - @staticmethod - def _extract_payload_byte(response_data: bytes) -> int: - """Extract the first payload byte, handling optional 2-byte header prefix.""" - return response_data[2] if len(response_data) > 2 else response_data[0] - - _E = TypeVar("_E", bound=enum.Enum) - - async def _query_enum(self, command: int, enum_cls: type[_E], label: str) -> _E: - """Send a framed query and parse the response byte as an *enum_cls* member.""" - logger.info("Querying %s", label) - response_data = await self._send_framed_query(command) - logger.debug("%s response data: %s", label.capitalize(), response_data.hex()) - value_byte = self._extract_payload_byte(response_data) - - try: - result = enum_cls(value_byte) - except ValueError: - logger.warning("Unknown %s: %d (0x%02X)", label, value_byte, value_byte) - raise ValueError( - f"Unknown {label}: {value_byte} (0x{value_byte:02X}). " - f"Valid types: {[m.name for m in enum_cls]}" - ) from None - - logger.info("%s: %s (0x%02X)", label.capitalize(), result.name, result.value) - return result - - async def request_washer_manifold(self) -> EL406WasherManifold: - """Query the installed washer manifold type.""" - return await self._query_enum( - command=0xD8, enum_cls=EL406WasherManifold, label="washer manifold type" - ) - - async def request_syringe_manifold(self) -> EL406SyringeManifold: - """Query the installed syringe manifold type.""" - return await self._query_enum( - command=0xBB, enum_cls=EL406SyringeManifold, label="syringe manifold type" - ) - - async def request_serial_number(self) -> str: - """Query the product serial number.""" - logger.info("Querying product serial number") - response_data = await self._send_framed_query(command=0x0100) - serial_number = response_data[2:].decode("ascii", errors="ignore").strip().rstrip("\x00") - logger.info("Product serial number: %s", serial_number) - return serial_number - - async def request_sensor_enabled(self, sensor: EL406Sensor) -> bool: - """Query whether a specific sensor is enabled.""" - logger.info("Querying sensor enabled status: %s", sensor.name) - response_data = await self._send_framed_query(command=0xD2, data=bytes([sensor.value])) - logger.debug("Sensor enabled response data: %s", response_data.hex()) - enabled = bool(self._extract_payload_byte(response_data)) - logger.info("Sensor %s enabled: %s", sensor.name, enabled) - return enabled - - class SyringeBoxInfo(TypedDict): - box_type: int - box_size: int - installed: bool - - async def request_syringe_box_info(self) -> SyringeBoxInfo: - """Get syringe box information.""" - logger.info("Querying syringe box info") - response_data = await self._send_framed_query(command=0xF6) - logger.debug("Syringe box info response data: %s", response_data.hex()) - - box_type = self._extract_payload_byte(response_data) - box_size = ( - response_data[3] - if len(response_data) > 3 - else (response_data[1] if len(response_data) > 1 else 0) - ) - installed = box_type != 0 - - info = self.SyringeBoxInfo(box_type=box_type, box_size=box_size, installed=installed) - logger.info("Syringe box info: %s", info) - return info - - async def request_peristaltic_installed(self, selector: int) -> bool: - """Check if a peristaltic pump is installed.""" - if selector < 0 or selector > 1: - raise ValueError(f"Invalid selector {selector}. Must be 0 (primary) or 1 (secondary).") - - logger.info("Querying peristaltic pump installed: selector=%d", selector) - response_data = await self._send_framed_query(command=0x0104, data=bytes([selector])) - logger.debug("Peristaltic installed response data: %s", response_data.hex()) - - installed = bool(self._extract_payload_byte(response_data)) - - logger.info("Peristaltic pump %d installed: %s", selector, installed) - return installed - - class InstrumentSettings(TypedDict): - washer_manifold: EL406WasherManifold - syringe_manifold: EL406SyringeManifold - syringe_box: "EL406.SyringeBoxInfo" - peristaltic_pump_1: bool - peristaltic_pump_2: bool - - async def request_instrument_settings(self) -> InstrumentSettings: - """Get current instrument hardware configuration.""" - logger.info("Querying instrument settings from hardware") - - washer_manifold = await self.request_washer_manifold() - syringe_manifold = await self.request_syringe_manifold() - syringe_box = await self.request_syringe_box_info() - peristaltic_1 = await self.request_peristaltic_installed(0) - peristaltic_2 = await self.request_peristaltic_installed(1) - - settings = self.InstrumentSettings( - washer_manifold=washer_manifold, - syringe_manifold=syringe_manifold, - syringe_box=syringe_box, - peristaltic_pump_1=peristaltic_1, - peristaltic_pump_2=peristaltic_2, - ) - logger.info("Instrument settings: %s", settings) - return settings - - class SelfCheckResult(TypedDict): - success: bool - error_code: int - message: str - - async def run_self_check(self) -> SelfCheckResult: - """Run instrument self-check diagnostics.""" - logger.info("Running instrument self-check") - response_data = await self._send_framed_query(command=0x95, timeout=LONG_READ_TIMEOUT) - logger.debug("Self-check response data: %s", response_data.hex()) - error_code = self._extract_payload_byte(response_data) - success = error_code == 0 - - message = "Self-check passed" if success else f"Self-check failed (error code: {error_code})" - result = self.SelfCheckResult(success=success, error_code=error_code, message=message) - logger.info("Self-check result: %s", result["message"]) - return result - - # --------------------------------------------------------------------------- - # Shake / soak - # --------------------------------------------------------------------------- - - MAX_SHAKE_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) - MAX_SOAK_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) - - async def shake( - self, - duration: float, - intensity: Intensity = "Medium", - soak_duration: int = 0, - move_home_first: bool = True, - ) -> None: - """Shake the plate with optional soak period. - - The EL406 shake is a single fire-and-forget command with the duration baked in; - it has no continuous start/stop shaking and no plate locking. Intensity is a - discrete level, not an RPM. - - Durations are in whole seconds (GUI uses mm:ss picker, max 59:59 each). - A duration of 0 disables shake. A soak_duration of 0 disables soak. - - Note: The GUI forces move_home_first=True when total time exceeds 60s - to prevent manifold drip contamination. Our default of True matches this. - - The plate is read from :attr:`plate` (set by assigning to the device's plate_holder). - - Args: - duration: Shake duration in seconds (0-3599). 0 to disable shake. - intensity: Shake intensity - "Variable", "Slow" (3.5 Hz), "Medium" (5 Hz), - or "Fast" (8 Hz). - soak_duration: Soak duration in seconds after shaking (0-3599). 0 to disable. - move_home_first: Move carrier to home position before shaking. - - Raises: - ValueError: If parameters are invalid. - """ - dur = int(duration) - plate = self.plate - - if dur < 0 or dur > self.MAX_SHAKE_DURATION: - raise ValueError(f"Invalid duration {dur}. Must be 0-{self.MAX_SHAKE_DURATION}.") - if soak_duration < 0 or soak_duration > self.MAX_SOAK_DURATION: - raise ValueError( - f"Invalid soak_duration {soak_duration}. Must be 0-{self.MAX_SOAK_DURATION}." - ) - if dur == 0 and soak_duration == 0: - raise ValueError("At least one of duration or soak_duration must be > 0.") - validate_intensity(intensity) - - shake_enabled = dur > 0 - - logger.info( - "Shake: %ds, %s intensity, move_home=%s, soak=%ds", - dur, - intensity, - move_home_first, - soak_duration, - ) - - data = self._build_shake_command( - plate=plate, - shake_duration=dur, - soak_duration=soak_duration, - intensity=intensity, - shake_enabled=shake_enabled, - move_home_first=move_home_first, - ) - framed_command = build_framed_message(command=0xA3, data=data) - total_timeout = dur + soak_duration + self.timeout - async with self.batch(): - await self._send_step_command(framed_command, timeout=total_timeout) - - def _build_shake_command( - self, - plate: Plate, - shake_duration: int = 0, - soak_duration: int = 0, - intensity: Intensity = "Medium", - shake_enabled: bool = True, - move_home_first: bool = True, - ) -> bytes: - """Build shake command bytes. - - Byte structure (12 bytes): - [0] Plate type - [1] move_home_first: 0x00 or 0x01 - [2-3] Shake duration in total seconds (16-bit LE) - [4] Intensity: 0x01=Variable, 0x02=Slow, 0x03=Medium, 0x04=Fast - [5] Reserved: 0x00 - [6-7] Soak duration in total seconds (16-bit LE) - [8-11] Padding (4 bytes) - - Args: - plate: PLR Plate resource. - shake_duration: Shake duration in seconds. - soak_duration: Soak duration in seconds. - intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). - shake_enabled: Whether shake is enabled. When False, shake_duration is not encoded. - move_home_first: Move carrier to home position before shaking. - - Returns: - Command bytes (12 bytes). - """ - shake_total_seconds = int(shake_duration) if shake_enabled else 0 - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(0x01 if move_home_first else 0x00) # [1] move_home_first - .u16(shake_total_seconds) # [2-3] Shake duration (seconds) - .u8(INTENSITY_TO_BYTE.get(intensity, 0x03)) # [4] Intensity - .u8(0x00) # [5] Reserved - .u16(int(soak_duration)) # [6-7] Soak duration (seconds) - .raw_bytes(b'\x00' * 4) # [8-11] Padding - .finish() - ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/el406/enums.py b/pylabrobot/agilent/biotek/el406/enums.py deleted file mode 100644 index 8456c4ee576..00000000000 --- a/pylabrobot/agilent/biotek/el406/enums.py +++ /dev/null @@ -1,91 +0,0 @@ -"""EL406 enumeration types. - -This module contains all enumeration types used by the BioTek EL406 -plate washer backend. -""" - -from __future__ import annotations - -import enum - - -class EL406WasherManifold(enum.IntEnum): - """Washer manifold types.""" - - TUBE_96_DUAL = 0 - TUBE_192 = 1 - TUBE_128 = 2 - TUBE_96_SINGLE = 3 - DEEP_PIN_96 = 4 - NOT_INSTALLED = 255 - - -class EL406SyringeManifold(enum.IntEnum): - """Syringe manifold types.""" - - NOT_INSTALLED = 0 - TUBE_16 = 1 - TUBE_32_LARGE_BORE = 2 - TUBE_32_SMALL_BORE = 3 - TUBE_16_7 = 4 - TUBE_8 = 5 - PLATE_6_WELL = 6 - PLATE_12_WELL = 7 - PLATE_24_WELL = 8 - PLATE_48_WELL = 9 - - -class EL406Sensor(enum.IntEnum): - """Sensor types for the EL406.""" - - VACUUM = 0 # Vacuum sensor - WASTE = 1 # Waste container sensor - FLUID = 2 # Fluid level sensor - FLOW = 3 # Flow sensor - FILTER_VAC = 4 # Filter vacuum sensor - PLATE = 5 # Plate presence sensor - - -class EL406StepType(enum.IntEnum): - """Step types for EL406 operations.""" - - UNDEFINED = 0 - P_DISPENSE = 1 # Peristaltic pump dispense - P_PRIME = 2 # Peristaltic pump prime - P_PURGE = 3 # Peristaltic pump purge - S_DISPENSE = 4 # Syringe dispense - S_PRIME = 5 # Syringe prime - M_WASH = 6 # Manifold wash - M_ASPIRATE = 7 # Manifold aspirate - M_DISPENSE = 8 # Manifold dispense - M_PRIME = 9 # Manifold prime - M_AUTO_CLEAN = 10 # Manifold auto-clean - SHAKE_SOAK = 11 # Shake/soak - - -class EL406Motor(enum.IntEnum): - """Motor types for the EL406.""" - - CARRIER_X = 0 # X-axis plate carrier motor - CARRIER_Y = 1 # Y-axis plate carrier motor - DISP_HEAD_Z = 2 # Dispense head Z-axis motor - WASH_HEAD_Z = 3 # Wash head Z-axis motor - SYRINGE_A = 4 # Syringe pump A motor - SYRINGE_B = 5 # Syringe pump B motor - PERI_PUMP_PRIMARY = 6 # Primary peristaltic pump motor - PERI_PUMP_SECONDARY = 7 # Secondary peristaltic pump motor - LEVEL_SENSE_Y = 8 # Level sense Y-axis motor - WASH_SYRINGE = 9 # Wash syringe motor - WASH_ASP_HEAD_Z = 10 # Wash aspirate head Z-axis motor - SINGLE_WELL_Y = 11 # Single well Y-axis motor - - -class EL406MotorHomeType(enum.IntEnum): - """Motor home types for the EL406.""" - - INIT_ALL_MOTORS = 1 # Initialize all motors - INIT_PERI_PUMP = 2 # Initialize peristaltic pump - HOME_MOTOR = 3 # Home a specific motor - HOME_XYZ_MOTORS = 4 # Home all XYZ motors - VERIFY_MOTOR = 5 # Verify a specific motor position - VERIFY_XYZ_MOTORS = 6 # Verify all XYZ motor positions diff --git a/pylabrobot/agilent/biotek/el406/error_codes.py b/pylabrobot/agilent/biotek/el406/error_codes.py deleted file mode 100644 index 2cbbc39b504..00000000000 --- a/pylabrobot/agilent/biotek/el406/error_codes.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -BioTek EL406 Error Codes - -This module contains error codes for the BioTek EL406 plate washer. - -The error codes provide human-readable descriptions for errors that may -occur during communication with the EL406 plate washer. -""" - -ERROR_CODES: dict[int, str] = { - 0x0175: "Error communicating with instrument software. didn't find find park opto sensor transition.", # 373 - 0x0C01: "Requested config/autocal data absent.", # 3073 - 0x0C02: "Calculated checksum didn't match checksum saved.", # 3074 - 0x0C03: "Config parameter out of range.", # 3075 - 0x1001: "Bootcode checksum error at powerup.", # 4097 - 0x1002: "Bootcode error unknown.", # 4098 - 0x1003: "Bootcode page program error.", # 4099 - 0x1004: "Bootcode block size error.", # 4100 - 0x1005: "Bootcode invalid processor signature.", # 4101 - 0x1006: "Bootcode memory exceeded.", # 4102 - 0x1007: "Bootcode invalid slave port.", # 4103 - 0x1008: "Bootcode invalid slave response.", # 4104 - 0x1009: "Bootcode invalid processor detected.", # 4105 - 0x100A: "Bootcode checksum error at powerup.", # 4106 - 0x100B: "Bootcode checksum error at powerup.", # 4107 - 0x100C: "Bootcode checksum error at powerup.", # 4108 - 0x100D: "Bootcode checksum error at powerup.", # 4109 - 0x100E: "Bootcode checksum error at powerup.", # 4110 - 0x100F: "Bootcode checksum error at powerup.", # 4111 - 0x1010: "Bootcode download checksum error.", # 4112 - 0x1250: "UI Processor internal RAM failure.", # 4688 - 0x1251: "MC Processor internal RAM failure.", # 4689 - 0x1300: "Invalid syringe", # 4864 - 0x1301: "Syringe is not connected", # 4865 - 0x1302: "Unable to initialize syringe", # 4866 - 0x1303: "Unable to initialize syringe sensor clear", # 4867 - 0x1304: "Syringe dispense volume out of calibration range", # 4868 - 0x1305: "Invalid syringe operation", # 4869 - 0x1306: "Syringe A FMEA check error", # 4870 - 0x1307: "Syringe B FMEA check error", # 4871 - 0x1355: "The Peri-pump module is not configured", # 4949 - 0x1356: "Invalid Peri-pump dispense position", # 4950 - 0x1357: "The second Peri-pump module is required", # 4951 - 0x1358: "This instrument does not support 0.5 uL Peri-pump dispense volume", # 4952 - 0x1400: "No vacuum pressure detected after turning on the vacuum pump", # 5120 - 0x1401: "The waste bottles must be emptied before continuing", # 5121 - 0x1402: "The valve to be cycle is invalid", # 5122 - 0x1403: "The magnet adapter height is out of range", # 5123 - 0x1404: "Use of the selected plate type is restricted", # 5124 - 0x1405: "Z Axis height error", # 5125 - 0x1406: "Invalid Plate type", # 5126 - 0x1407: "Invalid Step type", # 5127 - 0x1408: "Invalid plate geometry", # 5128 - 0x1409: "Invalid carrier type", # 5129 - 0x140A: "Invalid carrier specified", # 5130 - 0x140B: "Invalid carrier specified", # 5131 - 0x140C: "Invalid carrier specified", # 5132 - 0x140D: "Invalid carrier specified", # 5133 - 0x140E: "Invalid carrier specified", # 5134 - 0x140F: "Invalid carrier specified", # 5135 - 0x1410: "Incompatible hardware configuration", # 5136 - 0x1411: "Invalid carrier specified", # 5137 - 0x1412: "Plate clearance error", # 5138 - 0x1413: "AutoPrime in progress. Please wait until AutoPrime completes.", # 5139 - 0x1414: "AutoPrime is cleaning up. Please wait until AutoPrime cleanup completes.", # 5140 - 0x1415: "An AutoPrime value is out-of-range.", # 5141 - 0x1416: "Vacuum pressure incorrectly detected prior to starting the vacuum pump.", # 5142 - 0x1417: "The autocal sensor was not detected in the back of the instrument.", # 5143 - 0x1430: "Strip washer syringe FMEA check error.", # 5168 - 0x1431: "Strip washer aspirate head not installed.", # 5169 - 0x1432: "Strip washer syringe box not connected.", # 5170 - 0x1433: "Bad step type pointer passed in when finding plate heights.", # 5171 - 0x1500: "There was no buffer fluid present at the start of a manifold-based protocol or at the start of an individual step.", # 5376 - 0x1501: "There was no buffer fluid present immediately before the manifold dispense sequence.", # 5377 - 0x1502: "The buffer valve selection is invalid", # 5378 - 0x1503: "The requested volume to be dispensed through the manifold is smaller than the minimum volume that will be dispensed by the time the DC dispense pump turns on and the dispense valve is opened.", # 5379 - 0x1504: "There was no buffer fluid detected flowing through the manifold tubing during a manifold dispense/prime operation.", # 5380 - 0x1505: "There was no buffer fluid present at the end of a manifold-based protocol or at the end of an individual step.", # 5381 - 0x1506: "The requested carrier Y-axis position is out of range.", # 5382 - 0x1514: "The Ultrasonic Advantage hardware is not configured.", # 5396 - 0x1515: "The low-flow cell-wash hardware is not configured", # 5397 - 0x1516: "Vacuum pressure issue for vacuum filtration", # 5398 - 0x1517: "The software could not read the vacuum filter hardware consistently.", # 5399 - 0x1600: "Ran out of on-board storage space", # 5632 - 0x1601: "Ran out of on-board storage space for P-Dispense steps", # 5633 - 0x1602: "Ran out of on-board storage space for P-Prime steps", # 5634 - 0x1603: "Ran out of on-board storage space for P-Purge steps", # 5635 - 0x1604: "Ran out of on-board storage space for S-Dispense steps", # 5636 - 0x1605: "Ran out of on-board storage space for S-Prime steps", # 5637 - 0x1606: "Ran out of on-board storage space for W-Wash steps", # 5638 - 0x1607: "Ran out of on-board storage space for W-Aspirate steps", # 5639 - 0x1608: "Ran out of on-board storage space for W-Dispense steps", # 5640 - 0x1609: "Ran out of on-board storage space for W-Prime steps", # 5641 - 0x160A: "Ran out of on-board storage space for W-AutoClean steps", # 5642 - 0x160B: "Ran out of on-board storage space for Shake/Soak steps", # 5643 - 0x160C: "Ran out of on-board storage space for 1536 Wash steps", # 5644 - 0x160D: "Invalid Step Type encountered", # 5645 - 0x160E: "Ran out of on-board storage space for P-Purge steps", # 5646 - 0x160F: "Ran out of on-board storage space for P-Purge steps", # 5647 - 0x1610: "Protocol transfer failed.", # 5648 - 0x1700: "Level sensor not installed.", # 5888 - 0x1701: "Level sensor framing error.", # 5889 - 0x1702: "Level sensor timing error.", # 5890 - 0x1703: "Level sensor unknown command.", # 5891 - 0x1704: "Level sensor parameter error.", # 5892 - 0x1705: "Level sensor address error.", # 5893 - 0x1706: "Level sensor error detected but not classified.", # 5894 - 0x1707: "Level sensor response cmd char != request cmd char.", # 5895 - 0x1708: "Level sensor command response not long enough.", # 5896 - 0x1709: "Level sensor command response address not equal to '0'.", # 5897 - 0x170A: "Level sensor command response checksum error.", # 5898 - 0x170B: "Level sensor timeout while looking for SOF char.", # 5899 - 0x170C: "Level sensor RX error - framing error.", # 5900 - 0x170D: "Level sensor RX error in Mode parameter.", # 5901 - 0x170E: "Level sensor RX error in Format parameter.", # 5902 - 0x170F: "Level sensor RX error in Sensitivity parameter.", # 5903 - 0x1710: "Level sensor RX error in Average parameter.", # 5904 - 0x1711: "Level sensor RX error in Temp Comp parameter.", # 5905 - 0x1712: "Level sensor RX error in SDC parameter.", # 5906 - 0x1713: "Level sensor RX error in SDE parameter.", # 5907 - 0x1714: "Level sensor RX error in setting configuration.", # 5908 - 0x1715: "Level sensor error in converting a read to a level.", # 5909 - 0x1716: "7 reads did not come up with at least 3 good ones.", # 5910 - 0x1717: "Level sensor echo range error.", # 5911 - 0x1718: "Level sensor echo width error.", # 5912 - 0x1719: "7 reads did not come up with at least 3 good ones.", # 5913 - 0x171A: "Level sensor - motor axis incorrect in FindAxisCenter().", # 5914 - 0x171B: "7 reads did not come up with at least 3 good ones.", # 5915 - 0x171C: "In FindAxisCenter() initial read not > threshold.", # 5916 - 0x171D: "7 reads did not come up with at least 3 good ones.", # 5917 - 0x171E: "Level sensor - no well edge found - reached step limit.", # 5918 - 0x171F: "Level sensor - repeated FindAxisCenter() did not converge.", # 5919 - 0x1720: "Level sensor corner cal memory checksum error.", # 5920 - 0x1721: "Level sensor A1 cal memory checksum error.", # 5921 - 0x1722: "Level sensor - carrier height wrong - plate test > 30mm.", # 5922 - 0x1723: "A plate read was started but not finished successfully.", # 5923 - 0x1724: "7 reads did not come up with at least 3 good ones.", # 5924 - 0x1725: "The range of the smallest 3 reads (of 7) was > 0.5mm.", # 5925 - 0x1726: "Input to McReqLvlSnsZPosn() out of range.", # 5926 - 0x1727: "The correction factor is out of range.", # 5927 - 0x1728: "7 reads did not come up with at least 3 good ones.", # 5928 - 0x1729: "FindLsyParkPosn() could not find the park position.", # 5929 - 0x172A: "Read Plate or Read One command to MC - invalid Read Type.", # 5930 - 0x172B: "Row or column was 0 - must start at 1.", # 5931 - 0x172C: "Well test error - previous config not loaded.", # 5932 - 0x172D: "Well test error - wrong well.", # 5933 - 0x172E: "7 reads did not come up with at least 3 good ones.", # 5934 - 0x172F: "7 reads did not come up with at least 3 good ones.", # 5935 - 0x1730: "7 reads did not come up with at least 3 good ones.", # 5936 - 0x1731: "7 reads did not come up with at least 3 good ones.", # 5937 - 0x1732: "Level sensor - config memory checksum error.", # 5938 - 0x1733: "Well positions have not been calculated.", # 5939 - 0x1734: "Level sense correction factor not been calculated.", # 5940 - 0x1735: "Doing a Carrier Test - no previous Z-Axis cal data in EEPROM.", # 5941 - 0x1736: "Attempted a Z-axis wash head move with Sensor Y not at park posn.", # 5942 - 0x1737: "Plate test did not find a plate.", # 5943 - 0x1738: "Level sensor - config memory checksum error.", # 5944 - 0x1739: "MC Not all level sensor cal and config data has been loaded.", # 5945 - 0x173A: "Level sensor transmission buffer should be empty before sending a command.", # 5946 - 0x173B: "Level sensor - Z-Cal, Z=0, current to cal > +/-0.75mm.", # 5947 - 0x173C: "Level sensor - Z-Cal, Z=0, factory cal < 23mm or > 29mm.", # 5948 - 0x173D: "Level sensor - Z-Cal, Z=0, post to pre > +/-0.3mm.", # 5949 - 0x173E: "Level sensor - Z-Cal, Z=0, < 15.0mm.", # 5950 - 0x173F: "7 reads did not produce at least 6 good ones.", # 5951 - 0x6100: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24832 - 0x6101: "The 405 TS does not support downloading basecode from the LHC.", # 24833 - 0x6102: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24834 - 0x6110: "The Verify Manifold Test input parameters file was not found.", # 24848 - 0x6111: "The user data file for the Verify Manifold Test could not be read in.", # 24849 - 0x6112: "The Verify Manifold Test was stopped by user.", # 24850 - 0x6113: "The Verify Manifold Test is not supported.", # 24851 - 0x6114: "Invalid well specified.", # 24852 - 0x6115: "Invalid well specified.", # 24853 - 0x6116: "Invalid well specified.", # 24854 - 0x6117: "Invalid well specified.", # 24855 - 0x6118: "Invalid well specified.", # 24856 - 0x6119: "Invalid well specified.", # 24857 - 0x611A: "Invalid well specified.", # 24858 - 0x611B: "Invalid well specified.", # 24859 - 0x611C: "Invalid well specified.", # 24860 - 0x611D: "Invalid well specified.", # 24861 - 0x611E: "Invalid well specified.", # 24862 - 0x611F: "Invalid well specified.", # 24863 - 0x6120: "The carrier is not level.", # 24864 - 0x6121: "The test had an aspirate scan error.", # 24865 - 0x6122: "The test had an dispense scan error.", # 24866 - 0x6123: "Center of well not found where expected for Verify test plate.", # 24867 - 0x6124: "Incorrect plate installed for Verify test.", # 24868 - 0x6125: "The well volume following an aspirate indicates insufficient aspiration.", # 24869 - 0x6126: "The well volume following a dispense indicates insufficient dispense.", # 24870 - 0x6127: "Scan data could not be returned from the instrument.", # 24871 - 0x6128: "Invalid well specified.", # 24872 - 0x6129: "This Verify Manifold Test step was not performed.", # 24873 - 0x6150: "The mean Dispense Volume is out of range.", # 24912 - 0x6151: "The Dispense CV % exceeds the maximum threshold.", # 24913 - 0x6152: "The Aspirate Rate is below the minimum threshold.", # 24914 - 0x6160: "This step requires Washer components to be installed and connected.", # 24928 - 0x6161: "The Strip Washer Manifold and the Plate Type are incompatible.", # 24929 - 0x6162: "The Strip Washer does not support this Plate Type", # 24930 - 0x6165: "This Peri-pump does not support single well dispensing.", # 24933 - 0x6166: "The instrument does not support single well dispensing.", # 24934 - 0x6167: "The Syringe Manifold can only be used with 6-well plates", # 24935 - 0x6168: "The Syringe Manifold can only be used with 12-well plates", # 24936 - 0x6169: "The Syringe Manifold can only be used with 24-well plates", # 24937 - 0x6170: "The Syringe Manifold can only be used with 48-well plates", # 24944 - 0x6171: "The Cassette for single well dispensing does not support this plate type", # 24945 - 0x8100: "Error communicating with instrument software. Message not acknowledged (NAK).", # 33024 - 0x8101: "Error communicating with instrument software. Timeout while waiting for serial message data.", # 33025 - 0x8102: "Error communicating with instrument software. Instrument busy and unable to process message.", # 33026 - 0x8103: "Error communicating with instrument software. Receive buffer overflow error.", # 33027 - 0x8104: "Error communicating with instrument software. Communication checksum error.", # 33028 - 0x8105: "Error communicating with instrument software. Invalid structure type in byMsgStructure header field.", # 33029 - 0x8106: "Error communicating with instrument software. Invalid destination in byMsgDestination header field.", # 33030 - 0x8107: "Error communicating with instrument software. Message sent to instrument is not supported.", # 33031 - 0x8108: "Error communicating with instrument software. Message body size exceeds max limit.", # 33032 - 0x8109: "Error communicating with instrument software. Max number of requests currently running and cannot run the latest request.", # 33033 - 0x810A: "Error communicating with instrument software. No request running when response request issued.", # 33034 - 0x810B: "Error communicating with instrument software. Receive buffer overflow error.", # 33035 - 0x810C: "Error communicating with instrument software. Response for outstanding request not ready yet.", # 33036 - 0x810D: "Error communicating with instrument software. To communicate, the instrument must be at the Main Menu.", # 33037 - 0x810E: "Error communicating with instrument software. One or more request parameters are not valid.", # 33038 - 0x810F: "Error communicating with instrument software. Command not valid in current state.", # 33039 - 0xA100: " not available.", # 41216 - 0xA101: " not available.", # 41217 - 0xA102: " not available.", # 41218 - 0xA103: " not available.", # 41219 - 0xA104: " not available.", # 41220 - 0xA300: " power supply level error.", # 41728 - 0xA301: "+5v logic power supply level error.", # 41729 - 0xA302: "+24v system/motor power supply level error.", # 41730 - 0xA303: "Internal +42v PeriPump power supply level error.", # 41731 - 0xA304: "Internal reference voltage error.", # 41732 - 0xA305: "External +42v PeriPump power supply level error.", # 41733 -} - - -def get_error_message(code: int) -> str: - """ - Get the error message for a given error code. - - Args: - code: The error code to look up. - - Returns: - The error message, or a default message if not found. - """ - return ERROR_CODES.get(code, f"Unknown error code: 0x{code:04X} ({code})") diff --git a/pylabrobot/agilent/biotek/el406/errors.py b/pylabrobot/agilent/biotek/el406/errors.py deleted file mode 100644 index 12bbcab09dc..00000000000 --- a/pylabrobot/agilent/biotek/el406/errors.py +++ /dev/null @@ -1,47 +0,0 @@ -"""EL406 exception classes. - -This module contains exception classes used by the BioTek EL406 -plate washer backend. -""" - -from __future__ import annotations - - -class EL406CommunicationError(Exception): - """Exception raised for FTDI/USB communication errors with the EL406. - - This exception is raised when low-level communication fails, such as: - - USB device disconnected - - FTDI driver errors - - Write/read failures - - Attributes: - operation: The operation that failed (e.g., "write", "read", "open"). - original_error: The underlying exception that caused this error. - """ - - def __init__( - self, - message: str, - operation: str = "", - original_error: Exception | None = None, - ) -> None: - super().__init__(message) - self.operation = operation - self.original_error = original_error - - -class EL406DeviceError(Exception): - """Exception raised when the EL406 device reports an error via the validity field. - - The device returns a non-zero validity code in the status poll response - when a step command fails (e.g., no buffer fluid, invalid syringe, hardware fault). - - Attributes: - error_code: The raw error code from the device (e.g., 0x1500). - message: Human-readable error description. - """ - - def __init__(self, error_code: int, message: str) -> None: - self.error_code = error_code - super().__init__(f"EL406 error 0x{error_code:04X}: {message}") diff --git a/pylabrobot/agilent/biotek/el406/helpers.py b/pylabrobot/agilent/biotek/el406/helpers.py deleted file mode 100644 index cdac55fcf13..00000000000 --- a/pylabrobot/agilent/biotek/el406/helpers.py +++ /dev/null @@ -1,104 +0,0 @@ -"""EL406 plate type defaults and helper functions.""" - -from __future__ import annotations - -from pylabrobot.resources import Plate - -# Threshold for distinguishing standard-height vs low-profile plates (in mm). -# Standard microplates are ~14mm tall; PCR/flanged plates are typically <12mm. -_LOW_PROFILE_THRESHOLD_MM = 12.0 - -# Wire byte → physical defaults for each EL406 plate format. -# Keys are the raw byte values sent on the wire protocol. -_WIRE_BYTE_DEFAULTS: dict[int, dict[str, int]] = { - 0: { # 1536-well standard - "dispenser_height": 250, - "dispense_z": 94, - "aspirate_z": 42, - "rows": 32, - "cols": 48, - }, - 1: { # 384-well standard - "dispenser_height": 333, - "dispense_z": 120, - "aspirate_z": 22, - "rows": 16, - "cols": 24, - }, - 2: { # 384-well PCR (low profile) - "dispenser_height": 230, - "dispense_z": 83, - "aspirate_z": 2, - "rows": 16, - "cols": 24, - }, - 4: { # 96-well - "dispenser_height": 336, - "dispense_z": 121, - "aspirate_z": 29, - "rows": 8, - "cols": 12, - }, - 14: { # 1536-well flanged (low profile) - "dispenser_height": 196, - "dispense_z": 93, - "aspirate_z": 13, - "rows": 32, - "cols": 48, - }, -} - - -def plate_to_wire_byte(plate: Plate) -> int: - """Resolve a PLR Plate to the EL406 wire protocol byte. - - Determines the format from well count, and uses plate height (``size_z``) - to distinguish standard vs low-profile variants for 384 and 1536 plates. - - Args: - plate: A PyLabRobot Plate resource. - - Returns: - Integer byte value for the EL406 wire protocol. - - Raises: - ValueError: If the plate well count is not 96, 384, or 1536. - """ - wells = plate.num_items - if wells == 96: - return 4 - if wells == 384: - return 2 if plate.get_size_z() < _LOW_PROFILE_THRESHOLD_MM else 1 - if wells == 1536: - return 14 if plate.get_size_z() < _LOW_PROFILE_THRESHOLD_MM else 0 - raise ValueError(f"Unsupported plate well count: {wells}. EL406 supports 96, 384, or 1536.") - - -def plate_defaults(plate: Plate) -> dict[str, int]: - """Return the physical defaults dict for a plate.""" - return _WIRE_BYTE_DEFAULTS[plate_to_wire_byte(plate)] - - -def plate_max_columns(plate: Plate) -> int: - """Return the number of columns for a plate.""" - return plate.num_items_x - - -def plate_max_row_groups(plate: Plate) -> int: - """Return the number of row groups for a plate. - - 96-well: 1 row group (no row selection). - 384-well: 2 row groups. - 1536-well: 4 row groups. - """ - return {12: 1, 24: 2, 48: 4}[plate.num_items_x] - - -def plate_well_count(plate: Plate) -> int: - """Return the well count for a plate (96, 384, or 1536).""" - return plate.num_items - - -def plate_default_z(plate: Plate) -> int: - """Return the default dispenser Z height for a plate.""" - return plate_defaults(plate)["dispenser_height"] diff --git a/pylabrobot/agilent/biotek/el406/peristaltic_dispenser.py b/pylabrobot/agilent/biotek/el406/peristaltic_dispenser.py deleted file mode 100644 index a60254e4bc8..00000000000 --- a/pylabrobot/agilent/biotek/el406/peristaltic_dispenser.py +++ /dev/null @@ -1,497 +0,0 @@ -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Dict, Literal, Optional - -from pylabrobot.io.binary import Writer -from pylabrobot.resources import Plate - -from .helpers import ( - plate_default_z, - plate_max_columns, - plate_max_row_groups, - plate_to_wire_byte, - plate_well_count, -) -from .protocol import build_framed_message, columns_to_column_mask, encode_column_mask - -if TYPE_CHECKING: - from .el406 import EL406 - -logger = logging.getLogger(__name__) - -PeristalticFlowRate = Literal["Low", "Medium", "High"] -Cassette = Literal["Any", "1uL", "5uL", "10uL"] - -PERISTALTIC_FLOW_RATE_MAP: dict[str, int] = {"Low": 0, "Medium": 1, "High": 2} - - -def cassette_to_byte(cassette: Cassette) -> int: - mapping = {"ANY": 0, "1UL": 1, "5UL": 2, "10UL": 3} - key = cassette.upper() - if key not in mapping: - raise ValueError(f"Invalid cassette '{cassette}'. Must be one of: Any, 1uL, 5uL, 10uL") - return mapping[key] - - -def encode_quadrant_mask_inverted( - rows: Optional[list[int]], - num_row_groups: int = 4, -) -> int: - """Encode row/quadrant selection as inverted bitmask. - - The protocol uses INVERTED encoding for the quadrant/row mask byte: - 0 = selected, 1 = deselected. This is the opposite of the well mask. - - Args: - rows: List of row numbers (1 to num_row_groups) to select, or None for all. - If None, returns 0x00 (all selected in inverted encoding). - num_row_groups: Number of valid row groups for this plate type (1, 2, or 4). - - Returns: - Single byte with inverted bit encoding (only lower num_row_groups bits used). - - Raises: - ValueError: If any row number is out of range. - """ - if rows is None: - return 0x00 - - max_mask = (1 << num_row_groups) - 1 - mask = max_mask - for row in rows: - if row < 1 or row > num_row_groups: - raise ValueError(f"Row number {row} out of range. Must be 1-{num_row_groups}.") - mask &= ~(1 << (row - 1)) - - return mask & 0xFF - - -def validate_peristaltic_flow_rate(flow_rate: PeristalticFlowRate) -> None: - if flow_rate not in PERISTALTIC_FLOW_RATE_MAP: - raise ValueError( - f"flow_rate must be one of {sorted(PERISTALTIC_FLOW_RATE_MAP)}, got {flow_rate!r}" - ) - - -class PeristalticDispenser: - """Peristaltic dispensing backend for the BioTek EL406.""" - - def __init__(self, el406: EL406) -> None: - self._el406 = el406 - - async def dispense( - self, - volumes: Dict[int, float], - flow_rate: PeristalticFlowRate = "High", - offset_x: float = 0.0, - offset_y: float = 0.0, - offset_z: Optional[float] = None, - pre_dispense_volume: float = 10.0, - num_pre_dispenses: int = 2, - cassette: Cassette = "Any", - rows: Optional[list[int]] = None, - ) -> None: - """Dispense peristaltically, one command per run of columns sharing a volume. - - Dispenses into the plate currently loaded in the EL406 (see ``EL406.set_plate``). - - Args: - volumes: Volume in uL per column index. - flow_rate: Flow rate ("Low", "Medium", or "High"). - offset_x: X offset in mm (-12.5 to 12.5). - offset_y: Y offset in mm (-4.0 to 4.0). - offset_z: Z offset in mm (0.1-150.0). None picks the plate-type default: - 33.6 for 96/384-well, 25.4 for 1536-well. - pre_dispense_volume: Pre-dispense volume in uL (0 to disable). - num_pre_dispenses: Number of pre-dispenses. - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - rows: List of 1-indexed row group numbers, or None for all. - For 96-well: only 1 (no selection). For 384-well: 1-2. For 1536-well: 1-4. - """ - plate = self._el406.plate - - # Group consecutive columns with the same volume, in ascending order - groups: list[tuple[float, list[int]]] = [] - for col in sorted(volumes.keys()): - vol = volumes[col] - if groups and groups[-1][0] == vol: - groups[-1][1].append(col) - else: - groups.append((vol, [col])) - - async with self._el406.batch(): - for vol, cols in groups: - await self._peristaltic_dispense( - plate, - volume=vol, - columns=cols, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pre_dispense_volume=pre_dispense_volume, - num_pre_dispenses=num_pre_dispenses, - cassette=cassette, - rows=rows, - ) - - async def prime( - self, - volume: Optional[float] = None, - duration: Optional[int] = None, - flow_rate: PeristalticFlowRate = "High", - cassette: Cassette = "Any", - ) -> None: - """Prime the peristaltic pump fluid lines. - - Fills the peristaltic pump tubing with liquid. Specify either volume - or duration, but not both. If neither is specified, defaults to 1000 uL. - - Note: Peristaltic prime has no buffer selection. Use the manifold - ``prime()`` on :class:`PlateWasher` for buffer-specific priming. - - Uses the plate currently loaded in the EL406 (see ``EL406.set_plate``). - - Args: - volume: Prime volume in uL (1-3000). Mutually exclusive with duration. - duration: Fixed prime duration in seconds (1-300). Mutually exclusive - with volume. - flow_rate: Flow rate ("Low", "Medium", or "High"). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - - Raises: - ValueError: If parameters are invalid or both volume and duration given. - """ - if volume is not None and duration is not None: - raise ValueError("Specify either volume or duration, not both.") - if duration is not None: - if not 1 <= duration <= 300: - raise ValueError("duration must be 1-300 seconds") - wire_volume, wire_duration = 0.0, duration - else: - if volume is None: - volume = 1000.0 - if not 1 <= volume <= 3000: - raise ValueError("volume must be 1-3000 uL (GUI limit)") - wire_volume, wire_duration = volume, 0 - - validate_peristaltic_flow_rate(flow_rate) - logger.info( - "Peristaltic prime: %.1f uL, flow rate %s, cassette %s", wire_volume, flow_rate, cassette - ) - - data = self._build_peristaltic_prime_command( - plate=self._el406.plate, - volume=wire_volume, - duration=wire_duration, - flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], - reverse=True, - cassette=cassette, - pump=1, - ) - framed_command = build_framed_message(command=0x90, data=data) - async with self._el406.batch(): - await self._el406._send_step_command( - framed_command, timeout=self._el406.timeout + wire_duration + 30 - ) - - async def purge( - self, - volume: Optional[float] = None, - duration: Optional[int] = None, - flow_rate: PeristalticFlowRate = "High", - cassette: Cassette = "Any", - ) -> None: - """Purge the peristaltic pump fluid lines. - - Clears liquid from the peristaltic pump tubing. Uses the same wire - format as prime (identical data bytes, different command byte 0x91). - Specify either volume or duration, but not both. - - Uses the plate currently loaded in the EL406 (see ``EL406.set_plate``). - - Args: - volume: Purge volume in uL (1-3000). Mutually exclusive with duration. - duration: Fixed purge duration in seconds (1-300). Mutually exclusive - with volume. - flow_rate: Flow rate ("Low", "Medium", or "High"). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - - Raises: - ValueError: If parameters are invalid, both are given, or neither is given. - """ - if volume is not None and duration is not None: - raise ValueError("Specify either volume or duration, not both.") - if volume is None and duration is None: - raise ValueError("Either volume or duration must be specified.") - if duration is not None: - if not 1 <= duration <= 300: - raise ValueError("duration must be 1-300 seconds") - wire_volume, wire_duration = 0.0, duration - else: - assert volume is not None - if not 1 <= volume <= 3000: - raise ValueError("volume must be 1-3000 uL (GUI limit)") - wire_volume, wire_duration = volume, 0 - - validate_peristaltic_flow_rate(flow_rate) - logger.info( - "Peristaltic purge: %.1f uL, flow rate %s, cassette %s", wire_volume, flow_rate, cassette - ) - - data = self._build_peristaltic_prime_command( - plate=self._el406.plate, - volume=wire_volume, - duration=wire_duration, - flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], - reverse=True, - cassette=cassette, - pump=1, - ) - framed_command = build_framed_message(command=0x91, data=data) - async with self._el406.batch(): - await self._el406._send_step_command( - framed_command, timeout=self._el406.timeout + wire_duration + 30 - ) - - def _validate_well_selection( - self, - plate: Plate, - columns: Optional[list[int]], - rows: Optional[list[int]], - ) -> Optional[list[int]]: - """Validate column/row selection and return column mask.""" - max_cols = plate_max_columns(plate) - if columns is not None: - for col in columns: - if col < 1 or col > max_cols: - raise ValueError(f"Column {col} out of range for plate type (1-{max_cols}).") - max_rows = plate_max_row_groups(plate) - if rows is not None: - for row in rows: - if row < 1 or row > max_rows: - raise ValueError(f"Row {row} out of range for plate type (1-{max_rows}).") - return columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) - - def _validate_dispense_params( - self, - plate: Plate, - volume: float, - columns: Optional[list[int]], - flow_rate: PeristalticFlowRate, - offset_x: float, - offset_y: float, - offset_z: Optional[float], - pre_dispense_volume: float, - rows: Optional[list[int]], - ) -> tuple[int, int, int, int, Optional[list[int]]]: - """Validate peristaltic dispense parameters and resolve defaults. - - Returns: - (offset_x_steps, offset_y_steps, offset_z_steps, flow_rate_enum, column_mask) - """ - # Convert mm → 0.1mm steps for wire protocol - offset_x_steps = round(offset_x * 10) - offset_y_steps = round(offset_y * 10) - offset_z_steps = round(offset_z * 10) if offset_z is not None else None - - if not 1 <= volume <= 3000: - raise ValueError(f"Peri-pump dispense volume must be 1-3000 uL, got {volume}") - validate_peristaltic_flow_rate(flow_rate) - if not -125 <= offset_x_steps <= 125: - raise ValueError(f"Peri-pump dispense X-axis offset must be -125..125, got {offset_x_steps}") - if not -40 <= offset_y_steps <= 40: - raise ValueError(f"Peri-pump dispense Y-axis offset must be -40..40, got {offset_y_steps}") - - if offset_z_steps is None: - offset_z_steps = plate_default_z(plate) - if not 1 <= offset_z_steps <= 1500: - raise ValueError(f"Peri-pump dispense Z-axis offset must be 1..1500, got {offset_z_steps}") - - if pre_dispense_volume < 0: - raise ValueError(f"pre_dispense_volume must be non-negative, got {pre_dispense_volume}") - - column_mask = self._validate_well_selection(plate, columns, rows) - flow_rate_enum = PERISTALTIC_FLOW_RATE_MAP[flow_rate] - - return (offset_x_steps, offset_y_steps, offset_z_steps, flow_rate_enum, column_mask) - - async def _peristaltic_dispense( - self, - plate: Plate, - volume: float, - columns: Optional[list[int]] = None, - flow_rate: PeristalticFlowRate = "High", - offset_x: float = 0.0, - offset_y: float = 0.0, - offset_z: Optional[float] = None, - pre_dispense_volume: float = 10.0, - num_pre_dispenses: int = 2, - cassette: Cassette = "Any", - rows: Optional[list[int]] = None, - ) -> None: - """Send a single peristaltic dispense command for a set of columns. - - Args: - plate: PLR Plate resource. - volume: Dispense volume in microliters (1-3000). - columns: 1-indexed column numbers to dispense to, or None for all. - """ - offset_x_steps, offset_y_steps, offset_z_steps, flow_rate_enum, column_mask = ( - self._validate_dispense_params( - plate, - volume, - columns, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pre_dispense_volume=pre_dispense_volume, - rows=rows, - ) - ) - - logger.info( - "Peristaltic dispense: %.1f uL, flow rate %s, cassette %s", volume, flow_rate, cassette - ) - - data = self._build_peristaltic_dispense_command( - plate=plate, - volume=volume, - flow_rate=flow_rate_enum, - cassette=cassette, - offset_x=offset_x_steps, - offset_y=offset_y_steps, - offset_z=offset_z_steps, - pre_dispense_volume=pre_dispense_volume, - num_pre_dispenses=num_pre_dispenses, - column_mask=column_mask, - rows=rows, - pump=1, - ) - framed_command = build_framed_message(command=0x8F, data=data) - async with self._el406.batch(): - await self._el406._send_step_command(framed_command) - - # ========================================================================= - # COMMAND BUILDERS - # ========================================================================= - - def _build_peristaltic_prime_command( - self, - plate: Plate, - volume: float, - duration: int = 0, - flow_rate: int = 2, - reverse: bool = True, - cassette: Cassette = "Any", - pump: int = 1, - ) -> bytes: - """Build peristaltic prime command bytes. - - Protocol format (11 bytes): - Example: 04 2c 01 00 00 02 01 00 01 00 00 - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1-2] Volume (LE) — 0x0000 when using duration mode - [3-4] Duration in seconds (LE) — 0x0000 when using volume mode - [5] Flow rate enum (0=Low, 1=Medium, 2=High) - [6] Reverse/submerge (0 or 1) - [7] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) - [8] Pump (Primary: 1, Secondary: 2) - [9-10] Padding (0x0000) - - Args: - volume: Prime volume in microliters (0 when using duration mode). - duration: Fixed duration in seconds (0 when using volume mode). - flow_rate: Flow rate (0=Low, 1=Medium, 2=High). - reverse: Whether to reverse/submerge after prime. - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - pump: Pump (1=Primary, 2=Secondary). - - Returns: - Command bytes (11 bytes). - """ - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u16(int(volume)) # [1-2] Volume (LE) - .u16(duration) # [3-4] Duration (LE) - .u8(flow_rate) # [5] Flow rate - .u8(1 if reverse else 0) # [6] Reverse/submerge - .u8(cassette_to_byte(cassette)) # [7] Cassette type - .u8(pump & 0xFF) # [8] Pump - .raw_bytes(b'\x00' * 2) # [9-10] Padding - .finish() - ) # fmt: skip - - def _build_peristaltic_dispense_command( - self, - plate: Plate, - volume: float, - flow_rate: int, - cassette: Cassette = "Any", - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 336, - pre_dispense_volume: float = 0.0, - num_pre_dispenses: int = 2, - column_mask: Optional[list[int]] = None, - rows: Optional[list[int]] = None, - pump: int = 1, - ) -> bytes: - """Build peristaltic dispense command bytes. - - Protocol format (24 bytes): - Example: 04 0a 00 02 00 00 00 50 01 0a 00 02 ff ff ff ff ff ff 00 01 00 00 00 00 - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1-2] Volume (LE) - [3] Flow rate (0=Low, 1=Med, 2=High) - [4] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) - [5] Offset X (signed byte) - [6] Offset Y (signed byte) - [7-8] Offset Z (LE) - [9-10] Pre-dispense volume (LE, 0 if disabled) - [11] Num pre-dispenses - [12-17] Column mask (48 bits packed, normal: 1=selected) - [18] Row mask (4 bits packed, INVERTED: 0=selected, 1=deselected) - [19] Pump (Primary: 1, Secondary: 2) - [20-23] Padding - - Args: - volume: Dispense volume in microliters. - flow_rate: Flow rate (0=Low, 1=Medium, 2=High). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - offset_x: X offset (signed, 0.1mm units). - offset_y: Y offset (signed, 0.1mm units). - offset_z: Z offset (0.1mm units). - pre_dispense_volume: Pre-dispense volume in uL. - num_pre_dispenses: Number of pre-dispenses (default 2). - column_mask: List of column indices (0-47) or None for all columns. - rows: List of row numbers (1-4) or None for all rows. - pump: Pump (1=Primary, 2=Secondary). - - Returns: - Command bytes (24 bytes). - """ - num_row_groups = plate_max_row_groups(plate) - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u16(int(volume)) # [1-2] Volume (LE) - .u8(flow_rate) # [3] Flow rate - .u8(cassette_to_byte(cassette)) # [4] Cassette type - .i8(offset_x) # [5] Offset X - .i8(offset_y) # [6] Offset Y - .u16(offset_z) # [7-8] Offset Z (LE) - .u16(int(pre_dispense_volume)) # [9-10] Pre-dispense vol - .u8(num_pre_dispenses) # [11] Num pre-dispenses - .raw_bytes(encode_column_mask(column_mask)) # [12-17] Column mask - .u8(encode_quadrant_mask_inverted(rows, num_row_groups=num_row_groups)) # [18] Row mask - .u8(pump & 0xFF) # [19] Pump - .raw_bytes(b'\x00' * 4) # [20-23] Padding - .finish() - ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/el406/plate_washer.py b/pylabrobot/agilent/biotek/el406/plate_washer.py deleted file mode 100644 index 5df2d331560..00000000000 --- a/pylabrobot/agilent/biotek/el406/plate_washer.py +++ /dev/null @@ -1,1224 +0,0 @@ -"""EL406 manifold step methods. - -Provides aspirate, dispense, wash, prime, -and auto_clean operations plus their corresponding command builders. -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Literal, Optional - -from pylabrobot.io.binary import Writer -from pylabrobot.resources import Plate - -from .helpers import plate_defaults, plate_to_wire_byte -from .protocol import build_framed_message - -if TYPE_CHECKING: - from .el406 import EL406 - -Intensity = Literal["Variable", "Slow", "Medium", "Fast"] - -INTENSITY_TO_BYTE: dict[str, int] = { - "Variable": 0x01, - "Slow": 0x02, - "Medium": 0x03, - "Fast": 0x04, -} - - -def validate_intensity(intensity: Intensity) -> None: - if intensity not in {"Slow", "Medium", "Fast", "Variable"}: - raise ValueError( - f"intensity must be one of {sorted({'Slow', 'Medium', 'Fast', 'Variable'})}, " - f"got {intensity!r}" - ) - - -logger = logging.getLogger(__name__) - -Buffer = Literal["A", "B", "C", "D"] -TravelRate = Literal["1", "2", "3", "4", "5", "1 CW", "2 CW", "3 CW", "4 CW", "6 CW"] - -TRAVEL_RATE_TO_BYTE: dict[str, int] = { - "1": 1, - "2": 2, - "3": 3, - "4": 4, - "5": 5, - "1 CW": 7, - "2 CW": 8, - "3 CW": 9, - "4 CW": 10, - "6 CW": 6, -} - - -def travel_rate_to_byte(rate: TravelRate) -> int: - if rate not in TRAVEL_RATE_TO_BYTE: - valid = sorted(TRAVEL_RATE_TO_BYTE.keys()) - raise ValueError( - f"Invalid travel rate '{rate}'. Must be one of: {', '.join(repr(r) for r in valid)}" - ) - return TRAVEL_RATE_TO_BYTE[rate] - - -def get_plate_wash_defaults(plate: Plate) -> dict: - pt = plate_defaults(plate) - return { - "dispense_volume": 300.0 if pt["cols"] == 12 else 100.0, - "dispense_z": pt["dispense_z"], - "aspirate_z": pt["aspirate_z"], - } - - -def validate_buffer(buffer: Buffer) -> None: - if buffer.upper() not in {"A", "B", "C", "D"}: - raise ValueError(f"Invalid buffer '{buffer}'. Must be one of: A, B, C, D") - - -def validate_flow_rate(flow_rate: int) -> None: - if not 1 <= flow_rate <= 9: - raise ValueError(f"Invalid flow rate {flow_rate}. Must be between 1 and 9.") - - -def validate_cycles(cycles: int) -> None: - if not 1 <= cycles <= 250: - raise ValueError(f"cycles must be 1-250, got {cycles}") - - -def validate_delay_ms(delay_ms: int) -> None: - if not 0 <= delay_ms <= 65535: - raise ValueError(f"delay_ms must be 0-65535, got {delay_ms}") - - -def validate_travel_rate(rate: int) -> None: - if not 1 <= rate <= 9: - raise ValueError(f"travel_rate must be 1-9, got {rate}") - - -class PlateWasher: - """Manifold plate washer for the BioTek EL406.""" - - def __init__(self, el406: EL406) -> None: - self._el406 = el406 - - @staticmethod - def _validate_manifold_xy(x: int, y: int, label: str) -> None: - """Validate manifold X/Y offsets (X: -60..60, Y: -40..40).""" - if not -60 <= x <= 60: - raise ValueError(f"{label} X offset must be -60..60, got {x}") - if not -40 <= y <= 40: - raise ValueError(f"{label} Y offset must be -40..40, got {y}") - - def _validate_aspirate_params( - self, - plate: Plate, - vacuum_filtration: bool, - travel_rate: TravelRate, - delay: float, - vacuum_time: float, - offset_x: int, - offset_y: int, - offset_z: Optional[int], - secondary_aspirate: bool, - secondary_x: int, - secondary_y: int, - secondary_z: Optional[int], - ) -> tuple[int, int, int, int]: - """Validate aspirate parameters and resolve plate-type defaults. - - Returns: - (offset_z, secondary_z, time_value, rate_byte) - """ - pt_defaults = get_plate_wash_defaults(plate) - resolved_z = offset_z if offset_z is not None else pt_defaults["aspirate_z"] - resolved_secondary_z = secondary_z if secondary_z is not None else pt_defaults["aspirate_z"] - - # validate aspiration mode - delay_ms = round(delay * 1000) - vacuum_time_sec = round(vacuum_time) - if not vacuum_filtration: - if travel_rate not in TRAVEL_RATE_TO_BYTE: - raise ValueError( - f"Invalid travel rate '{travel_rate}'. Must be one of: " - f"{', '.join(repr(r) for r in sorted(TRAVEL_RATE_TO_BYTE))}" - ) - if not 0 <= delay_ms <= 5000: - raise ValueError(f"Aspirate delay must be 0-5000 ms, got {delay_ms}") - time_value, rate_byte = delay_ms, travel_rate_to_byte(travel_rate) - else: - if not 5 <= vacuum_time_sec <= 999: - raise ValueError(f"Vacuum filtration time must be 5-999 seconds, got {vacuum_time_sec}") - time_value, rate_byte = vacuum_time_sec, travel_rate_to_byte("3") - - # validate offsets - self._validate_manifold_xy(offset_x, offset_y, "Aspirate") - if not 1 <= resolved_z <= 210: - raise ValueError(f"Aspirate Z offset must be 1-210, got {resolved_z}") - if secondary_aspirate: - self._validate_manifold_xy(secondary_x, secondary_y, "Secondary") - if not 1 <= resolved_secondary_z <= 210: - raise ValueError(f"Secondary Z offset must be 1-210, got {resolved_secondary_z}") - - return (resolved_z, resolved_secondary_z, time_value, rate_byte) - - def _validate_dispense_params( - self, - plate: Plate, - volume: float, - buffer: Buffer, - flow_rate: int, - offset_x: int, - offset_y: int, - offset_z: Optional[int], - pre_dispense_volume: float, - pre_dispense_flow_rate: int, - vacuum_delay_volume: float, - ) -> int: - """Validate dispense parameters and resolve plate-type defaults. - - Returns: - Resolved offset_z. - """ - if offset_z is None: - pt_defaults = get_plate_wash_defaults(plate) - offset_z = pt_defaults["dispense_z"] - - if not 25 <= volume <= 3000: - raise ValueError(f"Manifold dispense volume must be 25-3000 uL, got {volume}") - validate_buffer(buffer) - if not 1 <= flow_rate <= 11: - raise ValueError(f"Manifold dispense flow rate must be 1-11, got {flow_rate}") - if flow_rate <= 2 and vacuum_delay_volume <= 0: - raise ValueError( - f"Flow rates 1-2 (cell wash) require vacuum_delay_volume > 0, " - f"got flow_rate={flow_rate} with vacuum_delay_volume={vacuum_delay_volume}" - ) - self._validate_manifold_xy(offset_x, offset_y, "Manifold dispense") - if not 1 <= offset_z <= 210: - raise ValueError(f"Manifold dispense Z offset must be 1-210, got {offset_z}") - - # validate pre-dispense and vacuum delay - if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: - raise ValueError( - f"Manifold pre-dispense volume must be 0 (disabled) or 25-3000 uL, " - f"got {pre_dispense_volume}" - ) - if not 3 <= pre_dispense_flow_rate <= 11: - raise ValueError( - f"Manifold pre-dispense flow rate must be 3-11, got {pre_dispense_flow_rate}" - ) - if not 0 <= vacuum_delay_volume <= 3000: - raise ValueError(f"Manifold vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") - - return offset_z - - def _resolve_wash_defaults( - self, - plate: Plate, - dispense_volume: Optional[float], - dispense_z: Optional[int], - aspirate_z: Optional[int], - secondary_z: Optional[int], - final_secondary_z: Optional[int], - ) -> tuple[float, int, int, int, int]: - """Resolve plate-type-aware defaults for wash parameters.""" - pt_defaults = get_plate_wash_defaults(plate) - if dispense_volume is None: - dispense_volume = pt_defaults["dispense_volume"] - if dispense_z is None: - dispense_z = pt_defaults["dispense_z"] - if aspirate_z is None: - aspirate_z = pt_defaults["aspirate_z"] - if secondary_z is None: - secondary_z = pt_defaults["aspirate_z"] - if final_secondary_z is None: - final_secondary_z = pt_defaults["aspirate_z"] - return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) - - def _validate_wash_params( - self, - plate: Plate, - cycles: int, - dispense_volume: Optional[float], - *, - buffer: Buffer, - dispense_flow_rate: int, - dispense_x: int, - dispense_y: int, - dispense_z: Optional[int], - aspirate_travel_rate: int, - aspirate_z: Optional[int], - pre_dispense_flow_rate: int, - aspirate_delay: float, - aspirate_x: int, - aspirate_y: int, - final_aspirate_x: int, - final_aspirate_y: int, - final_aspirate_delay: float, - pre_dispense_volume: float, - vacuum_delay_volume: float, - soak_duration: int, - shake_duration: int, - shake_intensity: Intensity, - secondary_aspirate: bool, - secondary_z: Optional[int], - secondary_x: int, - secondary_y: int, - final_secondary_aspirate: bool, - final_secondary_z: Optional[int], - final_secondary_x: int, - final_secondary_y: int, - bottom_wash: bool, - bottom_wash_volume: float, - bottom_wash_flow_rate: int, - pre_dispense_between_cycles_volume: float, - pre_dispense_between_cycles_flow_rate: int, - wash_format: Literal["Plate", "Sector"], - sectors: Optional[list[int]], - ) -> tuple[float, int, int, int, int, int, int, int]: - """Validate wash parameters and resolve plate-type defaults. - - Returns: - (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z, - aspirate_delay_ms, final_aspirate_delay_ms, sector_mask) - """ - aspirate_delay_ms = round(aspirate_delay * 1000) - final_aspirate_delay_ms = round(final_aspirate_delay * 1000) - - if sectors is not None: - sector_mask = 0 - for q in sectors: - if not 1 <= q <= 4: - raise ValueError(f"Sector/quadrant must be 1-4, got {q}") - sector_mask |= 1 << (q - 1) - else: - sector_mask = 0x0F - # resolve plate-type defaults - ( - dispense_volume, - resolved_dispense_z, - resolved_aspirate_z, - resolved_secondary_z, - resolved_final_secondary_z, - ) = self._resolve_wash_defaults( - plate, - dispense_volume, - dispense_z, - aspirate_z, - secondary_z, - final_secondary_z, - ) - - # core dispense/aspirate params - validate_cycles(cycles) - if dispense_volume <= 0: - raise ValueError(f"dispense_volume must be positive, got {dispense_volume}") - validate_buffer(buffer) - validate_flow_rate(dispense_flow_rate) - self._validate_manifold_xy(dispense_x, dispense_y, "Wash dispense") - validate_travel_rate(aspirate_travel_rate) - self._validate_manifold_xy(aspirate_x, aspirate_y, "Wash aspirate") - if wash_format not in ("Plate", "Sector"): - raise ValueError(f"wash_format must be 'Plate' or 'Sector', got '{wash_format}'") - if not 0 <= sector_mask <= 0xFFFF: - raise ValueError(f"sector_mask must be 0x0000-0xFFFF, got 0x{sector_mask:04X}") - validate_flow_rate(pre_dispense_flow_rate) - validate_delay_ms(aspirate_delay_ms) - - # final aspirate, pre-dispense, soak/shake - self._validate_manifold_xy(final_aspirate_x, final_aspirate_y, "Final aspirate") - validate_delay_ms(final_aspirate_delay_ms) - if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: - raise ValueError( - f"Wash pre-dispense volume must be 0 (disabled) or 25-3000 uL, got {pre_dispense_volume}" - ) - if not 0 <= vacuum_delay_volume <= 3000: - raise ValueError(f"Wash vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") - if not 0 <= soak_duration <= 3599: - raise ValueError(f"Wash soak duration must be 0-3599 seconds, got {soak_duration}") - if not 0 <= shake_duration <= 3599: - raise ValueError(f"Wash shake duration must be 0-3599 seconds, got {shake_duration}") - validate_intensity(shake_intensity) - - # secondary aspirates - if secondary_aspirate: - self._validate_manifold_xy(secondary_x, secondary_y, "Secondary") - if final_secondary_aspirate: - self._validate_manifold_xy(final_secondary_x, final_secondary_y, "Final secondary") - - # bottom wash and mid-cycle pre-dispense - if bottom_wash: - if not 25 <= bottom_wash_volume <= 3000: - raise ValueError(f"Bottom wash volume must be 25-3000 uL, got {bottom_wash_volume}") - validate_flow_rate(bottom_wash_flow_rate) - if pre_dispense_between_cycles_volume != 0: - if not 25 <= pre_dispense_between_cycles_volume <= 3000: - raise ValueError( - f"Pre-dispense between cycles volume must be 0 (disabled) or " - f"25-3000 uL, got {pre_dispense_between_cycles_volume}" - ) - validate_flow_rate(pre_dispense_between_cycles_flow_rate) - - return ( - dispense_volume, - resolved_dispense_z, - resolved_aspirate_z, - resolved_secondary_z, - resolved_final_secondary_z, - aspirate_delay_ms, - final_aspirate_delay_ms, - sector_mask, - ) - - async def aspirate( - self, - vacuum_filtration: bool = False, - travel_rate: TravelRate = "3", - delay: float = 0.0, - vacuum_time: float = 30.0, - offset_x: int = 0, - offset_y: int = 0, - offset_z: Optional[int] = None, - secondary_aspirate: bool = False, - secondary_x: int = 0, - secondary_y: int = 0, - secondary_z: Optional[int] = None, - ) -> None: - """Aspirate liquid from all wells via the wash manifold. - - Two modes, based on ``vacuum_filtration``: - - - Normal (vacuum_filtration=False): Uses travel_rate and delay. - - Vacuum filtration (vacuum_filtration=True): Uses vacuum_time. - Travel rate is ignored (greyed out in GUI). - - Args: - vacuum_filtration: Enable vacuum filtration mode. - travel_rate: Head travel rate ("1"-"5", or cell wash "1 CW"-"6 CW"). - delay: Post-aspirate delay in seconds (0-5). - vacuum_time: Vacuum filtration time in seconds (5-999). - offset_x: X offset in steps (-60 to +60). - offset_y: Y offset in steps (-40 to +40). - offset_z: Z offset in steps (1-210). None for plate-type default. - secondary_aspirate: Enable secondary aspirate at a different position. - secondary_x: Secondary X offset (-60 to +60). - secondary_y: Secondary Y offset (-40 to +40). - secondary_z: Secondary Z offset (1-210). None for plate-type default. - """ - resolved_z, resolved_secondary_z, time_value, rate_byte = self._validate_aspirate_params( - self._el406.plate, - vacuum_filtration=vacuum_filtration, - travel_rate=travel_rate, - delay=delay, - vacuum_time=vacuum_time, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - secondary_aspirate=secondary_aspirate, - secondary_x=secondary_x, - secondary_y=secondary_y, - secondary_z=secondary_z, - ) - - logger.info( - "Aspirating: vacuum=%s, travel_rate=%s, delay=%.3f s", - vacuum_filtration, - travel_rate, - delay, - ) - - data = self._build_aspirate_command( - plate=self._el406.plate, - vacuum_filtration=vacuum_filtration, - time_value=time_value, - travel_rate_byte=rate_byte, - offset_x=offset_x, - offset_y=offset_y, - offset_z=resolved_z, - secondary_mode=1 if secondary_aspirate else 0, - secondary_x=secondary_x, - secondary_y=secondary_y, - secondary_z=resolved_secondary_z, - ) - framed_command = build_framed_message(command=0xA5, data=data) - async with self._el406.batch(): - await self._el406._send_step_command(framed_command) - - async def dispense( - self, - volume: float, - buffer: Buffer = "A", - flow_rate: int = 7, - offset_x: int = 0, - offset_y: int = 0, - offset_z: Optional[int] = None, - pre_dispense_volume: float = 0.0, - pre_dispense_flow_rate: int = 9, - vacuum_delay_volume: float = 0.0, - ) -> None: - """Dispense liquid to all wells via the wash manifold. - - Args: - volume: Volume to dispense in uL/well (25-3000). - buffer: Buffer valve selection (A, B, C, D). - flow_rate: Dispense flow rate (1-11). - offset_x: X offset in steps (-60 to +60). - offset_y: Y offset in steps (-40 to +40). - offset_z: Z offset in steps (1-210). None for plate-type default. - pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable). - pre_dispense_flow_rate: Pre-dispense flow rate (3-11). - vacuum_delay_volume: Delay start of vacuum until volume dispensed in uL/well. - """ - resolved_z = self._validate_dispense_params( - self._el406.plate, - volume, - buffer=buffer, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pre_dispense_volume=pre_dispense_volume, - pre_dispense_flow_rate=pre_dispense_flow_rate, - vacuum_delay_volume=vacuum_delay_volume, - ) - - logger.info("Dispensing %.1f uL from buffer %s, flow rate %d", volume, buffer, flow_rate) - - data = self._build_dispense_command( - plate=self._el406.plate, - volume=volume, - buffer=buffer, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=resolved_z, - pre_dispense_volume=pre_dispense_volume, - pre_dispense_flow_rate=pre_dispense_flow_rate, - vacuum_delay_volume=vacuum_delay_volume, - ) - framed_command = build_framed_message(command=0xA6, data=data) - async with self._el406.batch(): - await self._el406._send_step_command(framed_command) - - async def wash( - self, - cycles: int = 3, - dispense_volume: Optional[float] = None, - buffer: Buffer = "A", - dispense_flow_rate: int = 7, - dispense_x: int = 0, - dispense_y: int = 0, - dispense_z: Optional[int] = None, - aspirate_travel_rate: int = 3, - aspirate_z: Optional[int] = None, - pre_dispense_flow_rate: int = 9, - aspirate_delay: float = 0.0, - aspirate_x: int = 0, - aspirate_y: int = 0, - final_aspirate: bool = True, - final_aspirate_z: Optional[int] = None, - final_aspirate_x: int = 0, - final_aspirate_y: int = 0, - final_aspirate_delay: float = 0.0, - pre_dispense_volume: float = 0.0, - vacuum_delay_volume: float = 0.0, - soak_duration: int = 0, - shake_duration: int = 0, - shake_intensity: Intensity = "Medium", - secondary_aspirate: bool = False, - secondary_z: Optional[int] = None, - secondary_x: int = 0, - secondary_y: int = 0, - final_secondary_aspirate: bool = False, - final_secondary_z: Optional[int] = None, - final_secondary_x: int = 0, - final_secondary_y: int = 0, - bottom_wash: bool = False, - bottom_wash_volume: float = 0.0, - bottom_wash_flow_rate: int = 5, - pre_dispense_between_cycles_volume: float = 0.0, - pre_dispense_between_cycles_flow_rate: int = 9, - wash_format: Literal["Plate", "Sector"] = "Plate", - sectors: Optional[list[int]] = None, - move_home_first: bool = False, - ) -> None: - """Perform manifold wash cycles. - - Sends a 102-byte MANIFOLD_WASH (0xA4) command that performs repeated - dispense-aspirate cycles. The wire format contains two dispense sections, - two aspirate sections, and a final shake/soak section. - - The wash command supports 4 independent coordinate sets: - - Primary aspirate (aspirate_x/y/z): between-cycle aspirate position - - Primary secondary (secondary_x/y/z): second aspirate position per cycle - - Final aspirate (final_aspirate_x/y/z): aspirate after last cycle - - Final secondary (final_secondary_x/y/z): second position for final aspirate - - Args: - cycles: Number of wash cycles (1-250). Default 3. - Encoded at header byte [6]. - dispense_volume: Volume to dispense per cycle in uL. Default None - (plate-type-aware: 300 for 96-well, 100 for others). - buffer: Buffer valve selection (A, B, C, D). - dispense_flow_rate: Flow rate for dispensing (1-9). - dispense_x: Dispense X offset in steps (-60 to +60). - dispense_y: Dispense Y offset in steps (-40 to +40). - dispense_z: Z offset for dispense in 0.1mm units (1-210). None is - plate-type-aware: 121 for 96-well, 120 for 384-well, etc. - aspirate_travel_rate: Travel rate for aspiration (1-9). - aspirate_z: Z offset for aspirate in 0.1mm units (1-210). None is - plate-type-aware: 29 for 96-well, 22 for 384-well, etc. - pre_dispense_flow_rate: Pre-dispense flow rate (3-11). Controls how fast the - pre-dispense is delivered. - aspirate_delay: Post-aspirate delay in seconds (0-65.535). Wire resolution: 1 ms. - aspirate_x: Aspirate X offset in steps (-60 to +60). - aspirate_y: Aspirate Y offset in steps (-40 to +40). - final_aspirate: Enable final aspirate after last cycle. Encoded in header - config flags byte [2]. - final_aspirate_z: Z offset for final aspirate (1-210). None inherits from - aspirate_z. Independent from primary aspirate Z. - final_aspirate_x: X offset for final aspirate (-60 to +60). - final_aspirate_y: Y offset for final aspirate (-40 to +40). - final_aspirate_delay: Post-aspirate delay for final aspirate in seconds - (0-65.535). Wire resolution: 1 ms. - pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, 25-3000 - when enabled). - vacuum_delay_volume: Vacuum delay volume in uL/well (0 to disable, 0-3000 - when enabled). Cell wash operations only. - soak_duration: Soak duration in seconds (0 to disable, 0-3599). - shake_duration: Shake duration in seconds (0 to disable, 0-3599). - shake_intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). - secondary_aspirate: Enable secondary aspirate for primary (between-cycle) aspirate. - secondary_z: Z offset for secondary aspirate in 0.1mm units (1-210). None is - plate-type-aware, same as aspirate_z default. - secondary_x: Secondary aspirate X offset (-60 to +60). - secondary_y: Secondary aspirate Y offset (-40 to +40). - final_secondary_aspirate: Enable secondary aspirate for final aspirate. - final_secondary_z: Z offset for final secondary aspirate (1-210). None is - plate-type-aware, same as aspirate_z default. - final_secondary_x: X offset for final secondary aspirate (-60 to +60). - final_secondary_y: Y offset for final secondary aspirate (-40 to +40). - bottom_wash: Enable bottom wash. Encoded in header[1]. - bottom_wash_volume: Bottom wash volume in uL (25-3000). - bottom_wash_flow_rate: Bottom wash flow rate (3-11). - pre_dispense_between_cycles_volume: Pre-dispense volume between wash cycles in - uL (0 to disable, 25-3000 when enabled). - pre_dispense_between_cycles_flow_rate: Flow rate for pre-dispense between - cycles (3-11). - wash_format: Wash format ("Plate" or "Sector"). Encoded at header[3]: - Plate=0x00, Sector=0x01. 384-well plates typically use "Sector" for - quadrant-based washing. - sectors: List of quadrant numbers to wash (1-4). None means all 4. Example: - ``sectors=[1, 2]`` washes quadrants 1 and 2. Only used when - wash_format="Sector". - move_home_first: Move carrier to home position before shake/soak. Same as in - the standalone shake interface. Encoded at wire [87] (shake/soak section - byte 0). - - Raises: - ValueError: If parameters are invalid. - """ - # Validate — returns resolved defaults and derived wire values - ( - dispense_volume, - resolved_dispense_z, - resolved_aspirate_z, - resolved_secondary_z, - resolved_final_secondary_z, - aspirate_delay_ms, - final_aspirate_delay_ms, - sector_mask, - ) = self._validate_wash_params( - self._el406.plate, - cycles, - dispense_volume, - buffer=buffer, - dispense_flow_rate=dispense_flow_rate, - dispense_x=dispense_x, - dispense_y=dispense_y, - dispense_z=dispense_z, - aspirate_travel_rate=aspirate_travel_rate, - aspirate_z=aspirate_z, - pre_dispense_flow_rate=pre_dispense_flow_rate, - aspirate_delay=aspirate_delay, - aspirate_x=aspirate_x, - aspirate_y=aspirate_y, - final_aspirate_x=final_aspirate_x, - final_aspirate_y=final_aspirate_y, - final_aspirate_delay=final_aspirate_delay, - pre_dispense_volume=pre_dispense_volume, - vacuum_delay_volume=vacuum_delay_volume, - soak_duration=soak_duration, - shake_duration=shake_duration, - shake_intensity=shake_intensity, - secondary_aspirate=secondary_aspirate, - secondary_z=secondary_z, - secondary_x=secondary_x, - secondary_y=secondary_y, - final_secondary_aspirate=final_secondary_aspirate, - final_secondary_z=final_secondary_z, - final_secondary_x=final_secondary_x, - final_secondary_y=final_secondary_y, - bottom_wash=bottom_wash, - bottom_wash_volume=bottom_wash_volume, - bottom_wash_flow_rate=bottom_wash_flow_rate, - pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, - pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, - wash_format=wash_format, - sectors=sectors, - ) - - data = self._build_wash_composite_command( - plate=self._el406.plate, - cycles=cycles, - buffer=buffer, - dispense_volume=dispense_volume, - dispense_flow_rate=dispense_flow_rate, - dispense_x=dispense_x, - dispense_y=dispense_y, - dispense_z=resolved_dispense_z, - aspirate_travel_rate=aspirate_travel_rate, - aspirate_z=resolved_aspirate_z, - pre_dispense_flow_rate=pre_dispense_flow_rate, - aspirate_delay_ms=aspirate_delay_ms, - aspirate_x=aspirate_x, - aspirate_y=aspirate_y, - final_aspirate=final_aspirate, - final_aspirate_z=final_aspirate_z, - final_aspirate_x=final_aspirate_x, - final_aspirate_y=final_aspirate_y, - final_aspirate_delay_ms=final_aspirate_delay_ms, - pre_dispense_volume=pre_dispense_volume, - vacuum_delay_volume=vacuum_delay_volume, - soak_duration=soak_duration, - shake_duration=shake_duration, - shake_intensity=shake_intensity, - secondary_aspirate=secondary_aspirate, - secondary_z=resolved_secondary_z, - secondary_x=secondary_x, - secondary_y=secondary_y, - final_secondary_aspirate=final_secondary_aspirate, - final_secondary_z=resolved_final_secondary_z, - final_secondary_x=final_secondary_x, - final_secondary_y=final_secondary_y, - bottom_wash=bottom_wash, - bottom_wash_volume=bottom_wash_volume, - bottom_wash_flow_rate=bottom_wash_flow_rate, - pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, - pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, - wash_format=wash_format, - sector_mask=sector_mask, - move_home_first=move_home_first, - ) - - framed_command = build_framed_message(command=0xA4, data=data) - wash_timeout = (cycles * 60) + shake_duration + soak_duration + 120 - async with self._el406.batch(): - await self._el406._send_step_command(framed_command, timeout=wash_timeout) - - async def prime( - self, - volume: float = 10000.0, - buffer: Buffer = "A", - flow_rate: int = 9, - low_flow_volume: float = 5000.0, - submerge_duration: float = 0.0, - ) -> None: - """Prime the manifold fluid lines. - - Fills the wash manifold tubing with liquid from the specified buffer. - This is typically done at the start of a protocol to ensure the lines - are filled and ready for dispensing. - - Args: - volume: Prime volume in uL (5000-999000). Wire resolution: 1000 uL. - buffer: Buffer valve selection (A, B, C, D). - flow_rate: Flow rate (3-11). - low_flow_volume: Low flow path volume in uL (5000-999000). Set to 0 to disable. - submerge_duration: Submerge duration in seconds (0 to disable, 60-86340). - Must be a multiple of 60. - - Raises: - ValueError: If parameters are invalid. - """ - # Validate in PLR units - if not 5000 <= volume <= 999000: - raise ValueError(f"Washer prime volume must be 5000-999000 uL, got {volume}") - validate_buffer(buffer) - if not 3 <= flow_rate <= 11: - raise ValueError(f"Washer prime flow rate must be 3-11, got {flow_rate}") - if low_flow_volume != 0 and not 5000 <= low_flow_volume <= 999000: - raise ValueError( - f"Low flow path volume must be 0 (disabled) or 5000-999000 uL, got {low_flow_volume}" - ) - if submerge_duration != 0 and not 60 <= submerge_duration <= 86340: - raise ValueError( - f"Submerge duration must be 0 (disabled) or 60-86340 seconds, got {submerge_duration}" - ) - if submerge_duration % 60 != 0: - raise ValueError( - f"Submerge duration must be a multiple of 60 seconds (device resolution is 1 minute), " - f"got {submerge_duration}" - ) - - volume_ml = round(volume / 1000) - low_flow_volume_ml = round(low_flow_volume / 1000) - submerge_duration_min = round(submerge_duration / 60) - low_flow_enabled = low_flow_volume > 0 - submerge_enabled = submerge_duration > 0 - - data = self._build_prime_command( - plate=self._el406.plate, - buffer=buffer, - volume_ml=volume_ml, - flow_rate=flow_rate, - low_flow_volume_ml=low_flow_volume_ml, - low_flow_enabled=low_flow_enabled, - submerge_enabled=submerge_enabled, - submerge_duration_min=submerge_duration_min, - ) - framed_command = build_framed_message(command=0xA7, data=data) - prime_timeout = self._el406.timeout + submerge_duration + 30 - async with self._el406.batch(): - await self._el406._send_step_command(framed_command, timeout=prime_timeout) - - async def auto_clean( - self, - buffer: Buffer = "A", - duration: float = 60.0, - ) -> None: - """Run a manifold auto-clean cycle. - - Args: - buffer: Buffer valve to use (A, B, C, or D). - duration: Cleaning duration in seconds (60-14340, i.e. up to 3h59m). - Wire resolution: 60 s (1 minute). - - Raises: - ValueError: If parameters are invalid. - """ - validate_buffer(buffer) - if not 60 <= duration <= 14340: - raise ValueError(f"AutoClean duration must be 60-14340 seconds, got {duration}") - if duration % 60 != 0: - raise ValueError( - f"AutoClean duration must be a multiple of 60 seconds (device resolution is 1 minute), " - f"got {duration}" - ) - - # Convert to wire units: seconds → minutes - duration_min = round(duration / 60) - - logger.info("Auto-clean: buffer %s, duration %.0f s", buffer, duration) - - data = self._build_auto_clean_command( - plate=self._el406.plate, - buffer=buffer, - duration_min=duration_min, - ) - framed_command = build_framed_message(command=0xA8, data=data) - auto_clean_timeout = max(120.0, duration + 30.0) - async with self._el406.batch(): - await self._el406._send_step_command(framed_command, timeout=auto_clean_timeout) - - # ========================================================================= - # COMMAND BUILDERS - # ========================================================================= - - def _build_wash_composite_command( - self, - plate: Plate, - cycles: int = 3, - buffer: Buffer = "A", - dispense_volume: Optional[float] = None, - dispense_flow_rate: int = 7, - dispense_x: int = 0, - dispense_y: int = 0, - dispense_z: Optional[int] = None, - aspirate_travel_rate: int = 3, - aspirate_z: Optional[int] = None, - pre_dispense_flow_rate: int = 9, - aspirate_delay_ms: int = 0, - aspirate_x: int = 0, - aspirate_y: int = 0, - final_aspirate: bool = True, - final_aspirate_z: Optional[int] = None, - final_aspirate_x: int = 0, - final_aspirate_y: int = 0, - final_aspirate_delay_ms: int = 0, - pre_dispense_volume: float = 0.0, - vacuum_delay_volume: float = 0.0, - soak_duration: int = 0, - shake_duration: int = 0, - shake_intensity: Intensity = "Medium", - secondary_aspirate: bool = False, - secondary_z: Optional[int] = None, - secondary_x: int = 0, - secondary_y: int = 0, - final_secondary_aspirate: bool = False, - final_secondary_z: Optional[int] = None, - final_secondary_x: int = 0, - final_secondary_y: int = 0, - bottom_wash: bool = False, - bottom_wash_volume: float = 0.0, - bottom_wash_flow_rate: int = 5, - pre_dispense_between_cycles_volume: float = 0.0, - pre_dispense_between_cycles_flow_rate: int = 9, - wash_format: Literal["Plate", "Sector"] = "Plate", - sector_mask: int = 0x0F, - move_home_first: bool = False, - ) -> bytes: - """Build 102-byte MANIFOLD_WASH (0xA4) command payload. - - Structure: header(7) + dispense1(22) + final_aspirate(20) + primary_aspirate(19) - + dispense2(19) + shake_soak(15) = 102 bytes. - - Header [0-6]: - [0] plate_type (plate_type.value) - [1] bottom_wash enable - [2] config flags -- final_aspirate - [3] wash_format -- 0=Plate, 1=Sector - [4-5] sector_mask as 16-bit LE - [6] wash cycles count - - Four coordinate sets for aspirate positions: - - Primary: aspirate_x/y/z (between-cycle aspirate, wire [49-67]) - - Primary secondary: secondary_x/y/z (wire [55-61]) - - Final: final_aspirate_x/y/z (post-cycle aspirate, wire [29-48]) - - Final secondary: final_secondary_x/y/z (wire [37-41]) - - Returns: - 102-byte command payload. - """ - # Resolve plate-type defaults - ( - dispense_volume, - dispense_z, - aspirate_z, - secondary_z, - final_secondary_z, - ) = self._resolve_wash_defaults( - plate, dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z - ) - - # Derived values - buffer_char = ord(buffer.upper()) - disp_vol = int(dispense_volume) - final_asp_z = final_aspirate_z if final_aspirate_z is not None else aspirate_z - pre_disp = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 - vac_delay = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 - intensity_byte = INTENSITY_TO_BYTE.get(shake_intensity, 0x03) if shake_duration > 0 else 0x00 - - # Secondary aspirate offsets (0 when disabled) - sec_x = secondary_x if secondary_aspirate else 0 - sec_y = secondary_y if secondary_aspirate else 0 - final_sec_x = final_secondary_x if final_secondary_aspirate else 0 - final_sec_y = final_secondary_y if final_secondary_aspirate else 0 - final_sec_z = final_secondary_z if final_secondary_aspirate else final_asp_z - - # Bottom wash: Dispense1 gets bottom wash params when enabled, else mirrors main - bw_vol = int(bottom_wash_volume) if bottom_wash else disp_vol - bw_flow = bottom_wash_flow_rate if bottom_wash else dispense_flow_rate - - # Pre-dispense between cycles: override or fall back to main pre-dispense - if pre_dispense_between_cycles_volume > 0: - midcyc_vol = int(pre_dispense_between_cycles_volume) - midcyc_flow = pre_dispense_between_cycles_flow_rate - else: - midcyc_vol = pre_disp - midcyc_flow = pre_dispense_flow_rate - - w = Writer() - - # --- Header [0-6] (7 bytes) --- - w.u8(plate_to_wire_byte(plate)) # [0] Plate type - w.u8(0x01 if bottom_wash else 0x00) # [1] Bottom wash enable - w.u8(0x01 if final_aspirate else 0x00) # [2] Config flags - w.u8({"Plate": 0x00, "Sector": 0x01}[wash_format]) # [3] Wash format - w.u16(sector_mask) # [4-5] Sector mask (LE) - w.u8(cycles) # [6] Wash cycles - - # --- Dispense section 1 [7-28] (22 bytes) — bottom wash or mirror of main --- - w.u8(buffer_char) # [7] Buffer (ASCII) - w.u16(bw_vol) # [8-9] Volume (LE) - w.u8(bw_flow) # [10] Flow rate - w.i8(dispense_x) # [11] Offset X - w.i8(dispense_y) # [12] Offset Y - w.u16(dispense_z) # [13-14] Dispense Z (LE) - w.u16(pre_disp) # [15-16] Pre-dispense vol (LE) - w.u8(pre_dispense_flow_rate) # [17] Pre-dispense flow rate - w.u16(vac_delay) # [18-19] Vacuum delay vol (LE) - w.raw_bytes(b"\x00" * 7) # [20-26] Padding - w.u16(final_aspirate_delay_ms) # [27-28] Final asp delay (LE) - - # --- Final aspirate section [29-48] (20 bytes) --- - w.u8(aspirate_travel_rate) # [29] Travel rate - w.u16(0x0000) # [30-31] Delay (always 0 here) - w.u16(final_asp_z) # [32-33] Final aspirate Z (LE) - w.u8(0x01 if final_secondary_aspirate else 0x00) # [34] Final secondary mode - w.i8(final_aspirate_x) # [35] Final aspirate X - w.i8(final_aspirate_y) # [36] Final aspirate Y - w.u16(final_sec_z) # [37-38] Final secondary Z (LE) - w.u8(0x00) # [39] Reserved - w.i8(final_sec_x) # [40] Final secondary X - w.i8(final_sec_y) # [41] Final secondary Y - w.raw_bytes(b"\x00" * 5) # [42-46] Reserved - w.u8(0x00) # [47] vac_filt (always 0 in wash) - # aspirate_delay_ms split: low byte here, high byte starts next section - w.u8(aspirate_delay_ms & 0xFF) # [48] asp delay low - - # --- Primary aspirate section [49-67] (19 bytes) --- - w.u8((aspirate_delay_ms >> 8) & 0xFF) # [49] asp delay high - w.u8(aspirate_travel_rate) # [50] Travel rate - w.i8(aspirate_x) # [51] Aspirate X - w.i8(aspirate_y) # [52] Aspirate Y - w.u16(aspirate_z) # [53-54] Aspirate Z (LE) - w.u8(0x01 if secondary_aspirate else 0x00) # [55] Secondary mode - w.i8(sec_x) # [56] Secondary X - w.i8(sec_y) # [57] Secondary Y - w.u16(secondary_z) # [58-59] Secondary Z (LE) - w.raw_bytes(b"\x00" * 8) # [60-67] Reserved - - # --- Dispense section 2 [68-86] (19 bytes) — main dispense --- - w.u8(buffer_char) # [68] Buffer (ASCII) - w.u16(disp_vol) # [69-70] Volume (LE) - w.u8(dispense_flow_rate) # [71] Flow rate - w.i8(dispense_x) # [72] Offset X - w.i8(dispense_y) # [73] Offset Y - w.u16(dispense_z) # [74-75] Dispense Z (LE) - w.u16(midcyc_vol) # [76-77] Mid-cycle vol (LE) - w.u8(midcyc_flow) # [78] Mid-cycle flow rate - w.u16(vac_delay) # [79-80] Vacuum delay vol (LE) - w.raw_bytes(b"\x00" * 6) # [81-86] Padding - - # --- Shake/soak section [87-101] (15 bytes) --- - w.u8(0x01 if move_home_first else 0x00) # [87] move_home_first - w.u16(shake_duration) # [88-89] Shake duration (LE) - w.u8(intensity_byte if shake_duration > 0 else 0x03) # [90] Intensity - w.u8(0x00) # [91] Shake type (always 0) - w.u16(soak_duration) # [92-93] Soak duration (LE) - w.raw_bytes(b"\x00" * 4) # [94-97] Padding - w.raw_bytes(b"\x00" * 4) # [98-101] Trailing padding - - data = w.finish() - assert len(data) == 102, f"Wash command should be 102 bytes, got {len(data)}" - - logger.debug("Wash command data (%d bytes): %s", len(data), data.hex()) - return data - - def _build_aspirate_command( - self, - plate: Plate, - vacuum_filtration: bool = False, - time_value: int = 0, - travel_rate_byte: int = 3, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 30, - secondary_mode: int = 0, - secondary_x: int = 0, - secondary_y: int = 0, - secondary_z: int = 30, - ) -> bytes: - """Build aspirate command bytes. - - Wire format (22 bytes): - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] vacuum_filtration: 0 or 1 - [2-3] time_value: ushort LE. delay_ms when normal, vacuum_time_sec when vacuum. - [4] travel_rate: byte from lookup table - [5] x_offset: signed byte - [6] y_offset: signed byte - [7-8] z_offset: short LE - [9] secondary_mode: byte (0=None, 1=enabled) - [10] secondary_x: signed byte - [11] secondary_y: signed byte - [12-13] secondary_z: short LE - [14-15] reserved: 0x0000 - [16-17] unknown: 0xFF0F (possibly column mask?) - [18-21] padding: 4 bytes 0x00 - - Args: - vacuum_filtration: Enable vacuum filtration. - time_value: Delay in ms (normal mode) or time in seconds (vacuum mode). - travel_rate_byte: Pre-encoded travel rate byte value. - offset_x: X offset (signed byte). - offset_y: Y offset (signed byte). - offset_z: Z offset (unsigned short). - secondary_mode: Secondary aspirate mode byte (0=None, 1=enabled). - secondary_x: Secondary X offset (signed byte). - secondary_y: Secondary Y offset (signed byte). - secondary_z: Secondary Z offset (unsigned short). - - Returns: - Command bytes (22 bytes). - """ - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(1 if vacuum_filtration else 0) # [1] Vacuum filtration - .u16(time_value) # [2-3] Time/delay (LE) - .u8(travel_rate_byte & 0xFF) # [4] Travel rate - .i8(offset_x) # [5] X offset - .i8(offset_y) # [6] Y offset - .u16(offset_z) # [7-8] Z offset (LE) - .u8(secondary_mode & 0xFF) # [9] Secondary mode - .i8(secondary_x) # [10] Secondary X - .i8(secondary_y) # [11] Secondary Y - .u16(secondary_z) # [12-13] Secondary Z (LE) - .raw_bytes(b'\x00' * 2) # [14-15] Reserved - .raw_bytes(b'\xff\x0f') # [16-17] Unknown, possibly column mask - .raw_bytes(b'\x00' * 4) # [18-21] Padding - .finish() - ) # fmt: skip - - def _build_dispense_command( - self, - plate: Plate, - volume: float, - buffer: Buffer, - flow_rate: int, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 121, - pre_dispense_volume: float = 0.0, - pre_dispense_flow_rate: int = 9, - vacuum_delay_volume: float = 0.0, - ) -> bytes: - """Build manifold dispense command bytes. - - Protocol format for manifold dispense: - Wire format: 20 bytes (19 + plate type prefix) - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) - [2-3] Volume: 2 bytes, LE, in uL (25-3000) - [4] Flow rate: 1-11 (1-2 = cell wash, requires vacuum delay) - [5] Offset X: signed byte (-60..60) - [6] Offset Y: signed byte (-40..40) - [7-8] Offset Z: 2 bytes, LE (1-210) - [9-10] Pre-dispense volume: 2 bytes, LE (0 if disabled, 25-3000 when enabled) - [11] Pre-dispense flow rate: 3-11 - [12-13] Vacuum delay volume: 2 bytes, LE (0 if disabled, 0-3000) - [14-19] Padding: 6 bytes (0x00) - - Note: Pre-dispense is enabled when pre_dispense_volume > 0. - Vacuum delay is enabled when vacuum_delay_volume > 0. - - Args: - volume: Dispense volume in uL. - buffer: Buffer valve (A, B, C, D). - flow_rate: Flow rate (1-11; 1-2 = cell wash, requires vacuum delay). - offset_x: X offset (signed, steps, -60..60). - offset_y: Y offset (signed, steps, -40..40). - offset_z: Z offset (steps, 1-210). - pre_dispense_volume: Pre-dispense volume in uL (0 to disable). - pre_dispense_flow_rate: Pre-dispense flow rate (3-11). - vacuum_delay_volume: Vacuum delay volume in uL (0 to disable). - - Returns: - Command bytes (20 bytes). - """ - pre_disp_vol = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 - vac_delay = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(ord(buffer.upper())) # [1] Buffer (ASCII) - .u16(int(volume)) # [2-3] Volume (LE) - .u8(flow_rate) # [4] Flow rate - .i8(offset_x) # [5] X offset - .i8(offset_y) # [6] Y offset - .u16(offset_z) # [7-8] Z offset (LE) - .u16(pre_disp_vol) # [9-10] Pre-dispense volume (LE) - .u8(pre_dispense_flow_rate) # [11] Pre-dispense flow rate - .u16(vac_delay) # [12-13] Vacuum delay volume (LE) - .raw_bytes(b'\x00' * 6) # [14-19] Padding - .finish() - ) # fmt: skip - - def _build_prime_command( - self, - plate: Plate, - buffer: Buffer, - volume_ml: float, - flow_rate: int = 9, - low_flow_volume_ml: int = 5, - low_flow_enabled: bool = True, - submerge_enabled: bool = False, - submerge_duration_min: int = 0, - ) -> bytes: - """Build manifold prime command bytes. - - Protocol format for manifold prime (13 bytes): - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) - [2-3] Volume: 2 bytes, little-endian, in mL - [4] Flow rate: 3-11 - [5-6] Low flow volume: 2 bytes, little-endian (in mL, 0 if disabled) - [7-8] Submerge duration: 2 bytes, little-endian (in minutes, 0 if disabled) - HH:MM encoded as total minutes: hours*60+minutes - [9-12] Padding zeros: 4 bytes - - Args: - buffer: Buffer valve (A, B, C, D). - volume_ml: Prime volume in mL. - flow_rate: Flow rate (3-11, default 9). - low_flow_volume_ml: Low flow volume in mL (default 5). - low_flow_enabled: Enable low flow path (default True). - submerge_enabled: Enable submerge tips after prime (default False). - submerge_duration_min: Submerge duration in minutes (default 0). - - Returns: - Command bytes (13 bytes). - """ - lf_vol = low_flow_volume_ml if (low_flow_enabled and low_flow_volume_ml > 0) else 0 - sub_dur = submerge_duration_min if submerge_enabled else 0 - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(ord(buffer.upper())) # [1] Buffer (ASCII) - .u16(int(volume_ml)) # [2-3] Volume (LE, mL) - .u8(flow_rate) # [4] Flow rate - .u16(lf_vol) # [5-6] Low flow volume (LE, mL) - .u16(sub_dur) # [7-8] Submerge duration (LE, minutes) - .raw_bytes(b'\x00' * 4) # [9-12] Padding - .finish() - ) # fmt: skip - - def _build_auto_clean_command( - self, - plate: Plate, - buffer: Buffer, - duration_min: int = 1, - ) -> bytes: - """Build auto-clean command bytes. - - Protocol format for auto-clean (8 bytes): - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) - [2-3] Duration: 2 bytes, little-endian (in minutes) - [4-7] Padding zeros: 4 bytes - - Args: - buffer: Buffer valve (A, B, C, D). - duration_min: Cleaning duration in minutes (1-239). - - Returns: - Command bytes (8 bytes). - """ - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(ord(buffer.upper())) # [1] Buffer (ASCII) - .u16(int(duration_min)) # [2-3] Duration (LE, minutes) - .raw_bytes(b'\x00' * 4) # [4-7] Padding - .finish() - ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/el406/protocol.py b/pylabrobot/agilent/biotek/el406/protocol.py deleted file mode 100644 index 8ec638c0bfc..00000000000 --- a/pylabrobot/agilent/biotek/el406/protocol.py +++ /dev/null @@ -1,107 +0,0 @@ -"""EL406 protocol framing utilities. - -This module contains the protocol framing functions for building -properly formatted messages for the BioTek EL406 plate washer. -""" - -from __future__ import annotations - -from pylabrobot.io.binary import Writer - - -def build_framed_message(command: int, data: bytes = b"") -> bytes: - """Build a properly framed EL406 message. - - Protocol structure: - [0]: 0x01 (start marker) - [1]: 0x02 (version marker) - [2-3]: command (little-endian short) - [4]: 0x01 (constant) - [5-6]: reserved (ushort, typically 0) - [7-8]: data length (ushort, little-endian) - [9-10]: checksum (ushort, little-endian) - ... followed by data bytes - - Checksum is two's complement of sum of header bytes 0-8 + all data bytes. - - Args: - command: 16-bit command code - data: Optional data bytes - - Returns: - Complete framed message with header and checksum - """ - # Build header bytes 0-8 (checksum placeholder filled after) - header_prefix = ( - Writer() - .u8(0x01) # [0] Start marker - .u8(0x02) # [1] Version marker - .u16(command) # [2-3] Command (LE) - .u8(0x01) # [4] Constant - .u16(0x0000) # [5-6] Reserved - .u16(len(data)) # [7-8] Data length (LE) - .finish() - ) # fmt: skip - - # Checksum: two's complement of sum of header bytes 0-8 + all data bytes - checksum_sum = sum(header_prefix) + sum(data) - checksum = (0xFFFF - checksum_sum + 1) & 0xFFFF - - return header_prefix + Writer().u16(checksum).finish() + data - - -def encode_column_mask(columns: list[int] | None) -> bytes: - """Encode list of column indices to 6-byte (48-bit) column mask. - - Each bit represents one column: 0 = skip, 1 = operate on column. - - Args: - columns: List of column indices (0-47) to select, or None for all columns. - If None, returns all 1s (all columns selected). - If empty list, returns all 0s (no columns selected). - - Returns: - 6 bytes representing the 48-bit column mask in little-endian order. - - Raises: - ValueError: If any column index is out of range (not 0-47). - """ - if columns is None: - return bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) - - for col in columns: - if col < 0 or col > 47: - raise ValueError(f"Column index {col} out of range. Must be 0-47.") - - mask = [0] * 6 - for col in columns: - byte_index = col // 8 - bit_index = col % 8 - mask[byte_index] |= 1 << bit_index - - return bytes(mask) - - -def columns_to_column_mask(columns: list[int] | None, plate_wells: int = 96) -> list[int] | None: - """Convert 1-indexed column numbers to 0-indexed column indices. - - Args: - columns: List of column numbers (1-based), or None for all columns. - plate_wells: Plate format (96, 384, 1536). Determines max columns. - - Returns: - List of 0-indexed column indices, or None if columns is None. - - Raises: - ValueError: If column numbers are out of range. - """ - if columns is None: - return None - - max_cols = {96: 12, 384: 24, 1536: 48}.get(plate_wells, 48) - indices = [] - for col in columns: - if col < 1 or col > max_cols: - raise ValueError(f"Column {col} out of range for {plate_wells}-well plate (1-{max_cols}).") - indices.append(col - 1) - return indices diff --git a/pylabrobot/agilent/biotek/el406/syringe_dispenser.py b/pylabrobot/agilent/biotek/el406/syringe_dispenser.py deleted file mode 100644 index 77840e0b47f..00000000000 --- a/pylabrobot/agilent/biotek/el406/syringe_dispenser.py +++ /dev/null @@ -1,374 +0,0 @@ -"""EL406 syringe pump step methods. - -Provides syringe_dispense and syringe_prime operations -plus their corresponding command builders. -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Dict, Literal, Optional - -from pylabrobot.io.binary import Writer -from pylabrobot.resources import Plate - -from .helpers import ( - plate_to_wire_byte, - plate_well_count, -) -from .protocol import build_framed_message, columns_to_column_mask, encode_column_mask - -if TYPE_CHECKING: - from .el406 import EL406 - -logger = logging.getLogger(__name__) - -Syringe = Literal["A", "B", "Both"] - - -def syringe_to_byte(syringe: Syringe) -> int: - syringe_upper = syringe.upper() - if syringe_upper == "A": - return 0 - if syringe_upper == "B": - return 1 - if syringe_upper == "BOTH": - return 2 - raise ValueError(f"Invalid syringe: {syringe}") - - -def validate_syringe(syringe: Syringe) -> None: - if syringe.upper() not in {"A", "B", "BOTH"}: - raise ValueError(f"Invalid syringe '{syringe}'. Must be one of: A, B, BOTH") - - -def validate_syringe_flow_rate(flow_rate: int) -> None: - if not 1 <= flow_rate <= 5: - raise ValueError(f"Syringe flow rate must be 1-5, got {flow_rate}") - - -def validate_pump_delay(delay: int) -> None: - if not 0 <= delay <= 5000: - raise ValueError(f"Pump delay must be 0-5000 ms, got {delay}") - - -class SyringeDispenser: - """Syringe dispenser for the BioTek EL406.""" - - def __init__(self, el406: EL406) -> None: - self._el406 = el406 - - async def dispense( - self, - volumes: Dict[int, float], - syringe: Syringe = "A", - flow_rate: int = 2, - offset_x: float = 0.0, - offset_y: float = 0.0, - offset_z: float = 33.6, - pump_delay: float = 0.0, - pre_dispense: bool = False, - pre_dispense_volume: float = 0.0, - num_pre_dispenses: int = 2, - ) -> None: - """Dispense from the syringes, one command per run of columns sharing a volume. - - Dispenses into the plate currently loaded in the EL406 (see ``EL406.set_plate``). - - Args: - volumes: Volume in uL per column index. - syringe: Syringe selection - "A", "B", or "Both". - flow_rate: Flow rate (1-5). Maximum rate depends on volume and plate type. - For 96-well: rate 1 for 10+ uL, rate 2 for 20+ uL, rate 3 for 50+ uL, - rate 4 for 60+ uL, rate 5 for 80+ uL. - For 384-well: rate 1 for 5+ uL, rate 2 for 10+ uL, rate 3 for 25+ uL, - rate 4 for 30+ uL, rate 5 for 40+ uL. - For 1536-well: all rates for 3+ uL. - offset_x: X offset in mm. - offset_y: Y offset in mm. - offset_z: Z offset in mm (33.6 for 96-well, 25.4 for 1536-well). - pump_delay: Post-dispense delay in seconds (0-5). Wire resolution: 1 ms. - pre_dispense: Whether to enable pre-dispense mode. - pre_dispense_volume: Pre-dispense volume in uL/tube (only used if pre_dispense=True). - num_pre_dispenses: Number of pre-dispenses. - """ - plate = self._el406.plate - - # Group consecutive columns with the same volume, in ascending order - groups: list[tuple[float, list[int]]] = [] - for col in sorted(volumes.keys()): - vol = volumes[col] - if groups and groups[-1][0] == vol: - groups[-1][1].append(col) - else: - groups.append((vol, [col])) - - async with self._el406.batch(): - for vol, cols in groups: - await self._syringe_dispense( - plate, - volume=vol, - columns=cols, - syringe=syringe, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pump_delay=pump_delay, - pre_dispense=pre_dispense, - pre_dispense_volume=pre_dispense_volume, - num_pre_dispenses=num_pre_dispenses, - ) - - async def _syringe_dispense( - self, - plate: Plate, - volume: float, - columns: Optional[list[int]] = None, - syringe: Syringe = "A", - flow_rate: int = 2, - offset_x: float = 0.0, - offset_y: float = 0.0, - offset_z: float = 33.6, - pump_delay: float = 0.0, - pre_dispense: bool = False, - pre_dispense_volume: float = 0.0, - num_pre_dispenses: int = 2, - ) -> None: - """Send a single syringe dispense command to the firmware.""" - pump_delay_ms = round(pump_delay * 1000) - - if volume <= 0: - raise ValueError(f"volume must be positive, got {volume}") - validate_syringe(syringe) - validate_syringe_flow_rate(flow_rate) - validate_pump_delay(pump_delay_ms) - - column_mask = columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) - - logger.info( - "Syringe dispense: %.1f uL from syringe %s, flow rate %d", volume, syringe, flow_rate - ) - - data = self._build_syringe_dispense_command( - plate=plate, - volume=volume, - syringe=syringe, - flow_rate=flow_rate, - # Convert mm → 0.1mm steps for wire protocol - offset_x=round(offset_x * 10), - offset_y=round(offset_y * 10), - offset_z=round(offset_z * 10), - pump_delay_ms=pump_delay_ms, - pre_dispense=pre_dispense, - pre_dispense_volume=pre_dispense_volume, - num_pre_dispenses=num_pre_dispenses, - column_mask=column_mask, - ) - framed_command = build_framed_message(command=0xA1, data=data) - async with self._el406.batch(): - await self._el406._send_step_command(framed_command) - - async def prime( - self, - volume: float, - syringe: Literal["A", "B"] = "A", - flow_rate: int = 5, - refills: int = 2, - pump_delay: float = 0.0, - submerge_tips: bool = True, - submerge_duration: float = 0.0, - ) -> None: - """Prime the syringe pump fluid lines. - - Fills the syringe pump tubing with liquid by performing one or more - aspirate-dispense cycles (refills). Optionally submerges the tips in - fluid after priming is complete. - - Uses the plate currently loaded in the EL406 (see ``EL406.set_plate``). - - Args: - volume: Prime volume in uL per refill (80-9999). - syringe: Syringe selection - "A" or "B". - flow_rate: Flow rate (1-5). - refills: Number of prime cycles (1-255). - pump_delay: Delay between cycles in seconds (0-5). Wire resolution: 1 ms. - submerge_tips: Submerge tips in fluid after prime. - submerge_duration: Submerge duration in seconds (0-86340, i.e. up to 23:59). - 0 to disable submerge time. Only encoded when submerge_tips=True. - Wire resolution: 60 s (1 minute). - - Raises: - ValueError: If parameters are invalid. - """ - pump_delay_ms = round(pump_delay * 1000) - if submerge_duration != 0 and submerge_duration % 60 != 0: - raise ValueError( - f"Submerge duration must be a multiple of 60 seconds (device resolution is 1 minute), " - f"got {submerge_duration}" - ) - submerge_duration_min = round(submerge_duration / 60) - - validate_syringe(syringe) - # validate syringe volume - if not 80 <= volume <= 9999: - raise ValueError(f"Syringe volume must be 80-9999 uL, got {volume}") - validate_syringe_flow_rate(flow_rate) - validate_pump_delay(pump_delay_ms) - # validate submerge duration - if not 0 <= submerge_duration_min <= 1439: - raise ValueError(f"Submerge duration must be 0-1439 minutes, got {submerge_duration_min}") - if not 1 <= refills <= 255: - raise ValueError(f"refills must be 1-255, got {refills}") - - logger.info( - "Syringe prime: syringe %s, %.1f uL, flow rate %d, %d refills", - syringe, - volume, - flow_rate, - refills, - ) - - data = self._build_syringe_prime_command( - plate=self._el406.plate, - volume=volume, - syringe=syringe, - flow_rate=flow_rate, - refills=refills, - pump_delay_ms=pump_delay_ms, - submerge_tips=submerge_tips, - submerge_duration_min=submerge_duration_min, - ) - framed_command = build_framed_message(command=0xA2, data=data) - prime_timeout = self._el406.timeout + submerge_duration + 30 - async with self._el406.batch(): - await self._el406._send_step_command(framed_command, timeout=prime_timeout) - - # ========================================================================= - # COMMAND BUILDERS - # ========================================================================= - - def _build_syringe_dispense_command( - self, - plate: Plate, - volume: float, - syringe: Syringe, - flow_rate: int, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 336, - pump_delay_ms: int = 0, - pre_dispense: bool = False, - pre_dispense_volume: float = 0.0, - num_pre_dispenses: int = 2, - column_mask: Optional[list[int]] = None, - ) -> bytes: - """Build syringe dispense command bytes. - - Wire format (26 bytes): - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Syringe: A=0, B=1, Both=2 - [2-3] Volume: 2 bytes, little-endian, in uL - [4] Flow rate: 1-5 - [5] Offset X: signed byte - [6] Offset Y: signed byte - [7-8] Offset Z: 2 bytes, little-endian - [9-10] Pump delay: 2 bytes, little-endian, in ms - [11-12] Pre-dispense volume: 2 bytes, little-endian (0 if pre_dispense=False) - [13] Number of pre-dispenses (default 2) - [14-19] Column mask: 6 bytes (48 bits packed) - [20] Bottle selection (A→0, B→2, Both→4) - [21-25] Padding (5 bytes) - - Args: - volume: Dispense volume in microliters. - syringe: Syringe selection (A, B, Both). - flow_rate: Flow rate (1-5). - offset_x: X offset (signed, 0.1mm units). - offset_y: Y offset (signed, 0.1mm units). - offset_z: Z offset (0.1mm units). - pump_delay_ms: Post-dispense delay in milliseconds. - pre_dispense: Whether to enable pre-dispense mode. - pre_dispense_volume: Pre-dispense volume in uL/tube (only used if pre_dispense=True). - num_pre_dispenses: Number of pre-dispenses (default 2). - column_mask: List of column indices (0-47) or None for all columns. - - Returns: - Command bytes (26 bytes). - """ - pre_disp_vol_int = int(pre_dispense_volume) if pre_dispense else 0 - bottle_byte = {"A": 0, "B": 2, "BOTH": 4}.get(syringe.upper(), 0) - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(syringe_to_byte(syringe)) # [1] Syringe - .u16(int(volume)) # [2-3] Volume (LE) - .u8(flow_rate) # [4] Flow rate - .i8(offset_x) # [5] Offset X - .i8(offset_y) # [6] Offset Y - .u16(offset_z) # [7-8] Offset Z (LE) - .u16(pump_delay_ms) # [9-10] Pump delay (LE) - .u16(pre_disp_vol_int) # [11-12] Pre-dispense vol (LE) - .u8(num_pre_dispenses) # [13] Num pre-dispenses - .raw_bytes(encode_column_mask(column_mask)) # [14-19] Column mask - .u8(bottle_byte) # [20] Bottle selection - .raw_bytes(b'\x00' * 5) # [21-25] Padding - .finish() - ) # fmt: skip - - def _build_syringe_prime_command( - self, - plate: Plate, - volume: float, - syringe: Literal["A", "B"], - flow_rate: int, - refills: int = 2, - pump_delay_ms: int = 0, - submerge_tips: bool = True, - submerge_duration_min: int = 0, - ) -> bytes: - """Build syringe prime command bytes. - - Protocol format (13 bytes): - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Syringe: A=0, B=1 - [2-3] Volume: 2 bytes, little-endian, in uL - [4] Flow rate: 1-5 - [5] Refills: byte (number of prime cycles) - [6-7] Pump delay: 2 bytes, little-endian, in ms - [8] Submerge tips (0 or 1) — "Submerge tips in fluid after prime" - [9-10] Submerge duration in minutes (LE uint16). 0 if submerge_tips=False. - [11] Bottle: derived from syringe (A->0, B->2) - [12] Padding - - Args: - volume: Prime volume in microliters. - syringe: Syringe selection (A, B). - flow_rate: Flow rate (1-5). - refills: Number of prime cycles. - pump_delay_ms: Delay between cycles in milliseconds (default 0). - submerge_tips: Submerge tips in fluid after prime (default True). - submerge_duration_min: Submerge duration in minutes (0-1439). Only encoded - when submerge_tips=True. - - Returns: - Command bytes (13 bytes). - """ - sub_total = submerge_duration_min if (submerge_tips and submerge_duration_min > 0) else 0 - bottle_byte = {"A": 0, "B": 2}.get(syringe.upper(), 0) - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(syringe_to_byte(syringe)) # [1] Syringe (A=0, B=1) - .u16(int(volume)) # [2-3] Volume (LE) - .u8(flow_rate) # [4] Flow rate - .u8(refills & 0xFF) # [5] Refills - .u16(pump_delay_ms) # [6-7] Pump delay (LE) - .u8(1 if submerge_tips else 0) # [8] Submerge tips - .u16(sub_total) # [9-10] Submerge duration (LE, minutes) - .u8(bottle_byte) # [11] Bottle selection - .u8(0x00) # [12] Padding - .finish() - ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py b/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py index 0565c0d490a..a2c529f6b45 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py +++ b/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py @@ -15,6 +15,7 @@ from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_head import CassetteHead from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import CassetteType from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import PeriFlowRate @@ -47,6 +48,24 @@ class PeristalticDispenser: def __init__(self, runtime: Runtime) -> None: self._runtime = runtime + def _at(self, head: Head) -> Positioning: + """Where a head works the plate on the carrier, when the caller names no position. + + A step carries the height it works at outright, so a default height that suits one plate is too + deep on a shallower one. Taking it from the plate is what keeps a defaulted call safe on every + plate the instrument works. + + Args: + head: Which head is doing the work. + + Returns: + The nominal position for that head, with no offset across or along the well. + + Raises: + RejectedError: If no plate has been set. + """ + return Positioning(z_steps=self._runtime.plate_record.height_for(head)) + async def dispense( self, volume: int, @@ -72,7 +91,8 @@ async def dispense( flow_rate: How fast to dispense. cassette_type: The cassette the step requires, or None to accept whatever is fitted. peri_pump: Which pump to drive, or None to leave the choice to the instrument. - positioning: Where in the well to dispense. + positioning: Where in the well to dispense. Defaults to the nominal height for this + head over the plate on the carrier, with no offset across or along the well. pre_dispense: Whether to pre-dispense first, at what volume and how many times. columns: Which columns to dispense into. rows: Which rows to dispense into. @@ -82,10 +102,15 @@ async def dispense( Raises: BiotekError: If the step cannot run, or fails while running. """ + where = positioning if positioning is not None else self._at("dispenser") step: PeriDispense if well_volumes is None and cassette_head is None: step = PeriDispense( - volume=volume, flow_rate=flow_rate, cassette_type=cassette_type, peri_pump=peri_pump + volume=volume, + flow_rate=flow_rate, + cassette_type=cassette_type, + peri_pump=peri_pump, + positioning=where, ) else: step = PeriRandomAccessDispense( @@ -94,11 +119,10 @@ async def dispense( cassette_type=cassette_type, peri_pump=peri_pump, cassette_head=cassette_head, + positioning=where, ) if well_volumes is not None: step.well_volumes = well_volumes - if positioning is not None: - step.positioning = positioning if pre_dispense is not None: step.pre_dispense = pre_dispense if columns is not None: @@ -204,16 +228,20 @@ async def wash_aspirate( volume: Volume per tube in µL. flow_rate: How fast to aspirate, as a position on the aspirate rate scale. peri_pump: Which pump to drive. - positioning: Where in the well to aspirate. + positioning: Where in the well to aspirate. Defaults to the nominal height for this + head over the plate on the carrier, with no offset across or along the well. columns: Which columns to aspirate. rows: Which row sections to aspirate. Raises: BiotekError: If the step cannot run, or fails while running. """ - step = PeriWashAspirate(volume=volume, flow_rate=flow_rate, peri_pump=peri_pump) - if positioning is not None: - step.positioning = positioning + step = PeriWashAspirate( + volume=volume, + flow_rate=flow_rate, + peri_pump=peri_pump, + positioning=positioning if positioning is not None else self._at("manifold aspirate"), + ) if columns is not None: step.columns = columns if rows is not None: @@ -239,7 +267,8 @@ async def wash_dispense( volume: Volume per tube in µL. flow_rate: How fast to dispense, as a position on the dispense rate scale. peri_pump: Which pump to drive. - positioning: Where in the well to dispense. + positioning: Where in the well to dispense. Defaults to the nominal height for this + head over the plate on the carrier, with no offset across or along the well. pre_dispense: Whether to pre-dispense first, at what volume and how many times. columns: Which columns to dispense into. rows: Which row sections to dispense into. @@ -247,9 +276,12 @@ async def wash_dispense( Raises: BiotekError: If the step cannot run, or fails while running. """ - step = PeriWashDispense(volume=volume, flow_rate=flow_rate, peri_pump=peri_pump) - if positioning is not None: - step.positioning = positioning + step = PeriWashDispense( + volume=volume, + flow_rate=flow_rate, + peri_pump=peri_pump, + positioning=positioning if positioning is not None else self._at("manifold aspirate"), + ) if pre_dispense is not None: step.pre_dispense = pre_dispense if columns is not None: diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py b/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py index 170fb7bff07..4cce9bea8f4 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py +++ b/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py @@ -9,6 +9,7 @@ from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.enums.steps.syringe import Syringe from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import SyringeBottle from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import PreDispense, Submerge @@ -29,6 +30,24 @@ class SyringeDispenser: def __init__(self, runtime: Runtime) -> None: self._runtime = runtime + def _at(self, head: Head) -> Positioning: + """Where a head works the plate on the carrier, when the caller names no position. + + A step carries the height it works at outright, so a default height that suits one plate is too + deep on a shallower one. Taking it from the plate is what keeps a defaulted call safe on every + plate the instrument works. + + Args: + head: Which head is doing the work. + + Returns: + The nominal position for that head, with no offset across or along the well. + + Raises: + RejectedError: If no plate has been set. + """ + return Positioning(z_steps=self._runtime.plate_record.height_for(head)) + async def dispense( self, volume: int, @@ -49,7 +68,8 @@ async def dispense( flow_rate: How fast to dispense. syringe_bottle: Which bottle to draw from. pump_delay: How long the pump waits between wells, in ms. - positioning: Where in the well to dispense. + positioning: Where in the well to dispense. Defaults to the nominal height for this + head over the plate on the carrier, with no offset across or along the well. pre_dispense: Whether to pre-dispense first, at what volume and how many times. columns: Which columns to dispense into. rows: Which rows to dispense into. Only instruments that select rows use this. @@ -64,9 +84,8 @@ async def dispense( syringe_bottle=syringe_bottle, pump_delay=pump_delay, selects_rows=rows is not None, + positioning=positioning if positioning is not None else self._at("dispenser"), ) - if positioning is not None: - step.positioning = positioning if pre_dispense is not None: step.pre_dispense = pre_dispense if columns is not None: diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/washer.py b/pylabrobot/agilent/biotek/lhc/devices/components/washer.py index 936977d3662..bce05646933 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/components/washer.py +++ b/pylabrobot/agilent/biotek/lhc/devices/components/washer.py @@ -15,6 +15,7 @@ from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TravelRate from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WashFormat @@ -55,6 +56,67 @@ class PlateWasher: def __init__(self, runtime: Runtime) -> None: self._runtime = runtime + def _at(self, head: Head) -> Positioning: + """Where a head works the plate on the carrier, when the caller names no position. + + A step carries the height it works at outright, so a default height that suits one plate is too + deep on a shallower one. Taking it from the plate is what keeps a defaulted call safe on every + plate the instrument works. + + Args: + head: Which head is doing the work. + + Returns: + The nominal position for that head, with no offset across or along the well. + + Raises: + RejectedError: If no plate has been set. + """ + return Positioning(z_steps=self._runtime.plate_record.height_for(head)) + + def _wash_aspirate(self) -> ManifoldAspirate: + """The aspirate a wash owns, at the height this plate is aspirated from. + + Returns: + The aspirate, marked as one a wash owns so it stores no well selection. + """ + return ManifoldAspirate(in_wash=True, positioning=self._at("manifold aspirate")) + + def _wash_dispense(self) -> ManifoldDispense: + """The dispense a wash owns, at the height this plate is dispensed into. + + Returns: + The dispense. + """ + return ManifoldDispense(positioning=self._at("manifold dispense")) + + def _strip_aspirate(self) -> StripAspirate: + """The aspirate a strip wash owns, at the height this plate is aspirated from. + + Returns: + The aspirate, marked as one a wash owns. + """ + return StripAspirate(in_wash=True, positioning=self._at("manifold aspirate")) + + def _strip_dispense(self) -> StripDispense: + """The dispense a strip wash owns, at the height this plate is dispensed into. + + Returns: + The dispense, marked as one a wash owns. + """ + return StripDispense(in_wash=True, positioning=self._at("dispenser")) + + def _syringe_dispense(self) -> SyringeDispense: + """The syringe dispense a 1536-well wash owns, at the height this plate is dispensed into. + + Returns: + The dispense, keeping the pre-dispense the step type defaults to. + """ + return SyringeDispense( + pre_dispense=PreDispense(enabled=True, volume=50, count=2), + positioning=self._at("dispenser"), + ) + async def wash( self, cycles: int = 3, @@ -72,30 +134,33 @@ async def wash( Args: cycles: How many wash cycles to run. wash_format: Whether to wash the whole plate, selected sectors or selected strips. - dispense: The dispense that refills the well each cycle. - aspirate: The aspirate that empties the well at the start of each cycle. + dispense: The dispense that refills the well each cycle. Defaults to one at the plate's + nominal dispensing height, which dispenses nothing until it is given a volume. + aspirate: The aspirate that empties the well at the start of each cycle. Defaults to one + at the plate's nominal aspirating height. stages: Which optional stages run. shake_soak: The pause after each dispense. bottom_wash: The dispense that washes the bottom of the well, when that stage runs. - final_aspirate: The aspirate that empties the well after the last cycle. + Defaults to one at the plate's nominal dispensing height. + final_aspirate: The aspirate that empties the well after the last cycle. Defaults to one + at the plate's nominal aspirating height. sectors: Which sectors to wash, when the format selects sectors. Raises: BiotekError: If the step cannot run, or fails while running. """ - step = ManifoldWash(cycles=cycles, wash_format=wash_format) - if dispense is not None: - step.dispense = dispense - if aspirate is not None: - step.aspirate = aspirate + step = ManifoldWash( + cycles=cycles, + wash_format=wash_format, + bottom_wash=bottom_wash if bottom_wash is not None else self._wash_dispense(), + aspirate=aspirate if aspirate is not None else self._wash_aspirate(), + dispense=dispense if dispense is not None else self._wash_dispense(), + final_aspirate=final_aspirate if final_aspirate is not None else self._wash_aspirate(), + ) if stages is not None: step.stages = stages if shake_soak is not None: step.shake_soak = shake_soak - if bottom_wash is not None: - step.bottom_wash = bottom_wash - if final_aspirate is not None: - step.final_aspirate = final_aspirate if sectors is not None: step.sectors = sectors await run_steps(self._runtime, [step]) @@ -117,7 +182,8 @@ async def aspirate( this is the filtration time in seconds instead. vacuum_filtration: Whether to pull the wells through a filter plate instead of aspirating from above. Not available on a 1536-well plate. - positioning: Where in the well to aspirate. + positioning: Where in the well to aspirate. Defaults to the nominal height for this + head over the plate on the carrier, with no offset across or along the well. secondary: Whether to aspirate a second time, in what pattern and where. columns: Which columns to aspirate. @@ -125,10 +191,11 @@ async def aspirate( BiotekError: If the step cannot run, or fails while running. """ step = ManifoldAspirate( - travel_rate=travel_rate, delay=delay, vacuum_filtration=vacuum_filtration + travel_rate=travel_rate, + delay=delay, + vacuum_filtration=vacuum_filtration, + positioning=positioning if positioning is not None else self._at("manifold aspirate"), ) - if positioning is not None: - step.positioning = positioning if secondary is not None: step.secondary = secondary if columns is not None: @@ -151,16 +218,20 @@ async def dispense( buffer: Which buffer inlet to draw from. flow_rate: How fast to dispense. The two slowest rates need the cell washing module and a 96-tube dual-action manifold. - positioning: Where in the well to dispense. + positioning: Where in the well to dispense. Defaults to the nominal height for this + head over the plate on the carrier, with no offset across or along the well. pre_dispense: Whether to pre-dispense first, and at what volume and rate. vacuum: Whether to hold the vacuum off until a volume has been dispensed. Raises: BiotekError: If the step cannot run, or fails while running. """ - step = ManifoldDispense(volume=volume, buffer=buffer, flow_rate=flow_rate) - if positioning is not None: - step.positioning = positioning + step = ManifoldDispense( + volume=volume, + buffer=buffer, + flow_rate=flow_rate, + positioning=positioning if positioning is not None else self._at("manifold dispense"), + ) if pre_dispense is not None: step.pre_dispense = pre_dispense if vacuum is not None: @@ -234,11 +305,14 @@ async def wash_1536( wash_format: Whether to wash the whole plate, selected sectors or selected strips. pre_dispense_before_volume: Volume per well in µL to pre-dispense before washing starts. pre_dispense_before_count: How many times to pre-dispense before washing starts. - dispense: The syringe dispense that refills the well each cycle. - aspirate: The aspirate that empties the well at the start of each cycle. + dispense: The syringe dispense that refills the well each cycle. Defaults to one at the + plate's nominal dispensing height, which dispenses nothing until it is given a volume. + aspirate: The aspirate that empties the well at the start of each cycle. Defaults to one + at the plate's nominal aspirating height. stages: Which optional stages run. shake_soak: The pause after each dispense. - final_aspirate: The aspirate that empties the well after the last cycle. + final_aspirate: The aspirate that empties the well after the last cycle. Defaults to one + at the plate's nominal aspirating height. Raises: BiotekError: If the step cannot run, or fails while running. @@ -248,17 +322,14 @@ async def wash_1536( wash_format=wash_format, pre_dispense_before_volume=pre_dispense_before_volume, pre_dispense_before_count=pre_dispense_before_count, + aspirate=aspirate if aspirate is not None else self._wash_aspirate(), + dispense=dispense if dispense is not None else self._syringe_dispense(), + final_aspirate=final_aspirate if final_aspirate is not None else self._wash_aspirate(), ) - if dispense is not None: - step.dispense = dispense - if aspirate is not None: - step.aspirate = aspirate if stages is not None: step.stages = stages if shake_soak is not None: step.shake_soak = shake_soak - if final_aspirate is not None: - step.final_aspirate = final_aspirate await run_steps(self._runtime, [step]) async def strip_wash( @@ -279,31 +350,34 @@ async def strip_wash( Args: cycles: How many wash cycles to run. wash_format: Whether to wash the whole plate, selected sectors or selected strips. - dispense: The dispense that refills the well each cycle. - aspirate: The aspirate that empties the well at the start of each cycle. + dispense: The dispense that refills the well each cycle. Defaults to one at the plate's + nominal dispensing height, which dispenses nothing until it is given a volume. + aspirate: The aspirate that empties the well at the start of each cycle. Defaults to one + at the plate's nominal aspirating height. stages: Which optional stages run. shake_soak: The pause after each dispense. bottom_wash: The dispense that washes the bottom of the well, when that stage runs. - final_aspirate: The aspirate that empties the well after the last cycle. + Defaults to one at the plate's nominal dispensing height. + final_aspirate: The aspirate that empties the well after the last cycle. Defaults to one + at the plate's nominal aspirating height. columns: Which columns to wash. rows: Which rows to wash. Raises: BiotekError: If the step cannot run, or fails while running. """ - step = StripWash(cycles=cycles, wash_format=wash_format) - if dispense is not None: - step.dispense = dispense - if aspirate is not None: - step.aspirate = aspirate + step = StripWash( + cycles=cycles, + wash_format=wash_format, + bottom_wash=bottom_wash if bottom_wash is not None else self._strip_dispense(), + aspirate=aspirate if aspirate is not None else self._strip_aspirate(), + dispense=dispense if dispense is not None else self._strip_dispense(), + final_aspirate=final_aspirate if final_aspirate is not None else self._strip_aspirate(), + ) if stages is not None: step.stages = stages if shake_soak is not None: step.shake_soak = shake_soak - if bottom_wash is not None: - step.bottom_wash = bottom_wash - if final_aspirate is not None: - step.final_aspirate = final_aspirate if columns is not None: step.columns = columns if rows is not None: @@ -325,7 +399,8 @@ async def strip_aspirate( travel_rate: How fast the tips descend into the well. The strip washer offers two rates the wash manifold does not. delay: How long to keep aspirating once the tips are down, in ms. - positioning: Where in the well to aspirate. + positioning: Where in the well to aspirate. Defaults to the nominal height for this + head over the plate on the carrier, with no offset across or along the well. secondary: Whether to aspirate a second time, in what pattern and where. columns: Which columns to aspirate. rows: Which rows to aspirate. @@ -333,9 +408,11 @@ async def strip_aspirate( Raises: BiotekError: If the step cannot run, or fails while running. """ - step = StripAspirate(travel_rate=travel_rate, delay=delay) - if positioning is not None: - step.positioning = positioning + step = StripAspirate( + travel_rate=travel_rate, + delay=delay, + positioning=positioning if positioning is not None else self._at("manifold aspirate"), + ) if secondary is not None: step.secondary = secondary if columns is not None: @@ -359,7 +436,8 @@ async def strip_dispense( Args: volume: Volume per well in µL. flow_rate: How fast to dispense. - positioning: Where in the well to dispense. + positioning: Where in the well to dispense. Defaults to the nominal height for this + head over the plate on the carrier, with no offset across or along the well. pre_dispense: Whether to pre-dispense first, at what volume, rate and how many times. vacuum: Whether to hold the vacuum off until a volume has been dispensed. columns: Which columns to dispense into. @@ -368,9 +446,11 @@ async def strip_dispense( Raises: BiotekError: If the step cannot run, or fails while running. """ - step = StripDispense(volume=volume, flow_rate=flow_rate) - if positioning is not None: - step.positioning = positioning + step = StripDispense( + volume=volume, + flow_rate=flow_rate, + positioning=positioning if positioning is not None else self._at("dispenser"), + ) if pre_dispense is not None: step.pre_dispense = pre_dispense if vacuum is not None: diff --git a/pylabrobot/agilent/biotek/lhc/devices/el406.py b/pylabrobot/agilent/biotek/lhc/devices/el406.py index b352d19321d..286123fbf88 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/el406.py +++ b/pylabrobot/agilent/biotek/lhc/devices/el406.py @@ -120,7 +120,6 @@ def __init__( ) -> None: self._runtime = Runtime( link=Link(port=port, family=self.family, name=name, timeout=timeout, io=io), - family=self.family, rules=rules_for(self.family), ) self.washer = PlateWasher(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/devices/execution.py b/pylabrobot/agilent/biotek/lhc/devices/execution.py index 4a7d5a07dfd..78ffaebebaf 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/execution.py +++ b/pylabrobot/agilent/biotek/lhc/devices/execution.py @@ -17,11 +17,12 @@ import logging from pylabrobot.agilent.biotek.lhc.devices.batch import batch +from pylabrobot.agilent.biotek.lhc.devices.queries import optional_byte from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType from pylabrobot.agilent.biotek.lhc.enums.plates.plate_restriction import PlateRestriction from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState -from pylabrobot.agilent.biotek.lhc.error_handling import BiotekError, ErrorKind, fail +from pylabrobot.agilent.biotek.lhc.error_handling import ErrorKind, fail from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step from pylabrobot.agilent.biotek.lhc.protocols.validation.protocol_pass import validate @@ -31,7 +32,6 @@ CommandNumber, command_for_step, ) -from pylabrobot.agilent.biotek.lhc.serialization.commands.configuration import ByteQuery from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import ( AbortStep, GetProtocolStatus, @@ -303,35 +303,15 @@ async def _read_instrument_facts(runtime: Runtime) -> None: runtime: The device's state, updated in place. """ if runtime.plate_restriction is None: - answer = await _optional_byte(runtime, CommandNumber.GET_PLATE_RESTRICTION) + answer = await optional_byte(runtime.link, CommandNumber.GET_PLATE_RESTRICTION) if answer is not None: runtime.plate_restriction = PlateRestriction(answer) if runtime.carrier_type is None: - answer = await _optional_byte(runtime, CommandNumber.GET_CARRIER_TYPE) + answer = await optional_byte(runtime.link, CommandNumber.GET_CARRIER_TYPE) if answer is not None: runtime.carrier_type = CarrierType(answer) -async def _optional_byte(runtime: Runtime, number: CommandNumber) -> int | None: - """Read a one-byte answer that not every firmware gives. - - Args: - runtime: The device's state. - number: Which query to send. - - Returns: - The byte, or None when the instrument would not answer -- either refusing the query, or - acknowledging it and sending no value back, which older firmware does for a query it does - not implement. - """ - command = ByteQuery(number) - try: - return command.parse(await runtime.link.request(command, operation=number.name)) - except (BiotekError, ValueError) as error: - logger.debug("%s does not answer %s: %s", runtime.link.name, number.name, error) - return None - - def _first_code(report: ValidationReport) -> int: """The code of the first thing that stops a protocol running. diff --git a/pylabrobot/agilent/biotek/lhc/devices/instrument_settings.py b/pylabrobot/agilent/biotek/lhc/devices/instrument_settings.py index caa1b57474b..004f2871bf2 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/instrument_settings.py +++ b/pylabrobot/agilent/biotek/lhc/devices/instrument_settings.py @@ -18,10 +18,15 @@ from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold -@dataclass +@dataclass(frozen=True) class InstrumentSettings: """The options an instrument is fitted with. + Read-only, because what it describes is: of the whole command vocabulary only the peristaltic + cassettes can be written, and those are reconciled when a batch opens rather than held here. + Everything else is hardware somebody fitted, or configuration set at the instrument itself, so a + record that could be edited would only ever mislead. A different instrument is a different record. + Attributes: family: Which instrument model this is. washer_manifold: The wash manifold fitted, or ``NOT_INSTALLED``. diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py index bd599034fb3..780d0294003 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py @@ -109,7 +109,6 @@ def __init__( ) -> None: self._runtime = Runtime( link=Link(port=port, family=self.family, name=name, timeout=timeout, io=io), - family=self.family, rules=rules_for(self.family), ) self.syringe_dispenser = SyringeDispenser(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py index 87889c8e7c2..50f4ac5c76d 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py @@ -126,7 +126,6 @@ def __init__( ) -> None: self._runtime = Runtime( link=Link(port=port, family=self.family, name=name, timeout=timeout, io=io), - family=self.family, rules=rules_for(self.family), reconciles_cassette_head=True, ) diff --git a/pylabrobot/agilent/biotek/lhc/devices/queries.py b/pylabrobot/agilent/biotek/lhc/devices/queries.py new file mode 100644 index 00000000000..3a7a3ee103d --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/queries.py @@ -0,0 +1,102 @@ +"""Reading one value off an instrument. + +Two callers ask an instrument for single values -- the fitted-options read and the check that +measures a protocol against what the instrument accepts -- and both need the same two shapes: a +value that must be there, and one the instrument may decline to give. An older firmware answers a +query it does not implement by acknowledging it and sending nothing back, so "would not say" is an +ordinary answer rather than a failure, and telling the two apart in one place is what keeps the +callers from each having their own idea of it. +""" + +from __future__ import annotations + +import logging + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.error_handling import BiotekError +from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber +from pylabrobot.agilent.biotek.lhc.serialization.commands.configuration import ByteQuery + +logger = logging.getLogger(__name__) + + +async def byte(link: Link, number: CommandNumber) -> int: + """Read a one-byte value. + + Args: + link: The open link to the instrument. + number: Which value to read. + + Returns: + The byte. + + Raises: + BiotekError: If the instrument refuses the query. + ValueError: If it answers without a value. + """ + command = ByteQuery(number) + return command.parse(await link.request(command, operation=number.name)) + + +async def flag(link: Link, number: CommandNumber) -> bool: + """Read a value that is yes or no. + + Args: + link: The open link to the instrument. + number: Which value to read. + + Returns: + Whether it is set. + + Raises: + BiotekError: If the instrument refuses the query. + ValueError: If it answers without a value. + """ + return bool(await byte(link, number)) + + +async def optional_byte(link: Link, number: CommandNumber) -> int | None: + """Read a one-byte value the instrument may not have. + + Args: + link: The open link to the instrument. + number: Which value to read. + + Returns: + The byte, or None when the instrument would not say -- either refusing the query, or answering + it with no value, which is what firmware without that query does. + """ + try: + return await byte(link, number) + except (BiotekError, ValueError) as error: + logger.debug("%s does not answer %s: %s", link.name, number.name, error) + return None + + +async def optional_flag(link: Link, number: CommandNumber) -> bool | None: + """Read a yes-or-no value the instrument may not have. + + Args: + link: The open link to the instrument. + number: Which value to read. + + Returns: + Whether it is set, or None when the instrument would not say. + """ + answer = await optional_byte(link, number) + return None if answer is None else bool(answer) + + +async def answers(link: Link, number: CommandNumber) -> bool: + """Whether the instrument answers a query at all, rather than what it answers. + + One option is reported this way: firmware that has it answers, and firmware that does not refuses. + + Args: + link: The open link to the instrument. + number: Which query to send. + + Returns: + Whether it was answered. + """ + return await optional_byte(link, number) is not None diff --git a/pylabrobot/agilent/biotek/lhc/devices/runtime.py b/pylabrobot/agilent/biotek/lhc/devices/runtime.py index 9faa646621f..1b07c567f6c 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/runtime.py +++ b/pylabrobot/agilent/biotek/lhc/devices/runtime.py @@ -32,8 +32,7 @@ class Runtime: """Everything shared behaviour needs from the device that owns it. Attributes: - link: The connection to the instrument. - family: Which model this is, which the instrument does not report and so is declared. + link: The connection to the instrument, which carries which model this is. rules: Which validation rules this model's firmware runs. settings: What the instrument has fitted. Read from the instrument by ``setup()``, and the record every step is encoded against. @@ -53,7 +52,6 @@ class Runtime: """ link: Link - family: InstrumentFamily rules: BuildRules = COMMON settings: InstrumentSettings = field(default_factory=InstrumentSettings) reconciles_cassette_head: bool = False @@ -90,6 +88,11 @@ def plate_type(self) -> PlateType: """ return self.plate_record.plate_type + @property + def family(self) -> InstrumentFamily: + """Which model this is, which the instrument does not report and so is declared.""" + return self.link.family + def forget_instrument_facts(self) -> None: """Forget what was read off the instrument about the plate it will accept. diff --git a/pylabrobot/agilent/biotek/lhc/devices/settings_query.py b/pylabrobot/agilent/biotek/lhc/devices/settings_query.py index f85456b8422..82fd9f18930 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/settings_query.py +++ b/pylabrobot/agilent/biotek/lhc/devices/settings_query.py @@ -15,6 +15,13 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.devices.queries import ( + answers, + byte, + flag, + optional_byte, + optional_flag, +) from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import StripWasherManifold @@ -24,11 +31,8 @@ from pylabrobot.agilent.biotek.lhc.enums.instrument.valve_box import ValveBox from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PERI_PUMP_TO_BYTE, PeriPump -from pylabrobot.agilent.biotek.lhc.error_handling import BiotekError from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber from pylabrobot.agilent.biotek.lhc.serialization.commands.configuration import ( - ByteQuery, - FlagQuery, GetSyringeBoxInfo, SelectorQuery, SyringeBox, @@ -73,20 +77,20 @@ async def _read_washer(link: Link, family: InstrumentFamily) -> InstrumentSettin Raises: BiotekError: If an option cannot be read. """ - valve_box = ValveBox(await _byte(link, CommandNumber.GET_EXT_VALVE_MODULE_INSTALLED)) + valve_box = ValveBox(await byte(link, CommandNumber.GET_EXT_VALVE_MODULE_INSTALLED)) return InstrumentSettings( family=family, - washer_manifold=WasherManifold(await _byte(link, CommandNumber.GET_WASHER_MANIFOLD_INSTALLED)), + washer_manifold=WasherManifold(await byte(link, CommandNumber.GET_WASHER_MANIFOLD_INSTALLED)), syringe_box=SyringeBoxType.NOT_INSTALLED, syringe_manifold=SyringeManifold.NOT_INSTALLED, buffer_switching=valve_box is not ValveBox.NOT_INSTALLED, valve_box=valve_box, - vacuum_filtration=await _flag(link, CommandNumber.GET_VACUUM_FILTRATION_INSTALLED), + vacuum_filtration=await flag(link, CommandNumber.GET_VACUUM_FILTRATION_INSTALLED), peri_pump=False, peri_pump_2=False, - ultrasonic=await _flag(link, CommandNumber.GET_ULTRASONIC_CLEANER_INSTALLED), - cell_washing=await _flag(link, CommandNumber.GET_CELL_WASHING_INSTALLED), - y_axis_installed=await _flag(link, CommandNumber.GET_Y_AXIS_INSTALLED), + ultrasonic=await flag(link, CommandNumber.GET_ULTRASONIC_CLEANER_INSTALLED), + cell_washing=await flag(link, CommandNumber.GET_CELL_WASHING_INSTALLED), + y_axis_installed=await flag(link, CommandNumber.GET_Y_AXIS_INSTALLED), half_ul_enabled=False, strip_washer_manifold=StripWasherManifold.NOT_INSTALLED, ) @@ -109,21 +113,19 @@ async def _read_dispenser(link: Link, family: InstrumentFamily) -> InstrumentSet BiotekError: If an option cannot be read. """ washes = family is InstrumentFamily.EL406 - syringe_manifold = SyringeManifold( - await _byte(link, CommandNumber.GET_SYRINGE_MANIFOLD_INSTALLED) - ) + syringe_manifold = SyringeManifold(await byte(link, CommandNumber.GET_SYRINGE_MANIFOLD_INSTALLED)) box = await _syringe_box(link) primary = await _peri_installed(link, "Primary") secondary = False if washes else await _peri_installed(link, "Secondary") valve_box = ( - ValveBox(await _byte(link, CommandNumber.GET_EXT_VALVE_MODULE_INSTALLED)) + ValveBox(await byte(link, CommandNumber.GET_EXT_VALVE_MODULE_INSTALLED)) if washes else ValveBox.NOT_INSTALLED ) return InstrumentSettings( family=family, washer_manifold=( - WasherManifold(await _byte(link, CommandNumber.GET_WASHER_MANIFOLD_INSTALLED)) + WasherManifold(await byte(link, CommandNumber.GET_WASHER_MANIFOLD_INSTALLED)) if washes else WasherManifold.NOT_INSTALLED ), @@ -132,16 +134,16 @@ async def _read_dispenser(link: Link, family: InstrumentFamily) -> InstrumentSet buffer_switching=valve_box is not ValveBox.NOT_INSTALLED, valve_box=valve_box, vacuum_filtration=( - await _flag(link, CommandNumber.GET_VACUUM_FILTRATION_INSTALLED) if washes else False + await flag(link, CommandNumber.GET_VACUUM_FILTRATION_INSTALLED) if washes else False ), peri_pump=primary, peri_pump_2=secondary, ultrasonic=( - await _flag(link, CommandNumber.GET_ULTRASONIC_CLEANER_INSTALLED) if washes else False + await flag(link, CommandNumber.GET_ULTRASONIC_CLEANER_INSTALLED) if washes else False ), - cell_washing=(await _flag(link, CommandNumber.GET_CELL_WASHING_INSTALLED) if washes else False), + cell_washing=(await flag(link, CommandNumber.GET_CELL_WASHING_INSTALLED) if washes else False), y_axis_installed=True, - half_ul_enabled=bool(await _optional_flag(link, CommandNumber.GET_IS_PERI_HALF_UL_SUPPORTED)), + half_ul_enabled=bool(await optional_flag(link, CommandNumber.GET_IS_PERI_HALF_UL_SUPPORTED)), strip_washer_manifold=StripWasherManifold.NOT_INSTALLED, syringe_box_size=SyringeBoxSize(box.box_size), ) @@ -166,16 +168,14 @@ async def _read_strip_washer_and_firmware( """ manifold = settings.strip_washer_manifold single_well = settings.single_well_enabled - if await _optional_flag(link, CommandNumber.IS_STRIP_WASHER_BOX_CONNECTED) and ( - await _optional_flag(link, CommandNumber.GET_STRIP_WASHER_HW_INSTALLED) + if await optional_flag(link, CommandNumber.IS_STRIP_WASHER_BOX_CONNECTED) and ( + await optional_flag(link, CommandNumber.GET_STRIP_WASHER_HW_INSTALLED) ): - fitted = await _optional_byte(link, CommandNumber.GET_STRIP_WASHER_MANIFOLD_TYPE) + fitted = await optional_byte(link, CommandNumber.GET_STRIP_WASHER_MANIFOLD_TYPE) if fitted is not None: manifold = StripWasherManifold(fitted) - single_well = bool( - await _optional_flag(link, CommandNumber.GET_SINGLE_WELL_DISPENSER_INSTALLED) - ) - basecode = await _optional_byte(link, CommandNumber.GET_WHICH_BASECODE_IS_INSTALLED) + single_well = bool(await optional_flag(link, CommandNumber.GET_SINGLE_WELL_DISPENSER_INSTALLED)) + basecode = await optional_byte(link, CommandNumber.GET_WHICH_BASECODE_IS_INSTALLED) return InstrumentSettings( family=settings.family, washer_manifold=settings.washer_manifold, @@ -193,43 +193,25 @@ async def _read_strip_washer_and_firmware( strip_washer_manifold=manifold, single_well_enabled=single_well, peri_wash_enabled=basecode == Basecode.PERI_WASH, - advanced_dispense_offsets=await _answers(link, CommandNumber.GET_FLUID_TRACKING_ENABLED), + advanced_dispense_offsets=await answers(link, CommandNumber.GET_FLUID_TRACKING_ENABLED), syringe_box_size=settings.syringe_box_size, ) -async def _byte(link: Link, number: CommandNumber) -> int: - """Read a one-byte option. - - Args: - link: The open link to the instrument. - number: Which option to read. - - Returns: - The byte. - - Raises: - BiotekError: If the option cannot be read. - """ - command = ByteQuery(number) - return command.parse(await link.request(command, operation=number.name)) - - -async def _flag(link: Link, number: CommandNumber) -> bool: - """Read an option that is fitted or not. +async def _syringe_box(link: Link) -> SyringeBox: + """Read which syringe box is fitted and how many bottles it holds. Args: link: The open link to the instrument. - number: Which option to read. Returns: - Whether it is fitted. + The box type and size. Raises: - BiotekError: If the option cannot be read. + BiotekError: If the answer cannot be read. """ - command = FlagQuery(number) - return command.parse_flag(await link.request(command, operation=number.name)) + command = GetSyringeBoxInfo() + return command.parse(await link.request(command, operation="syringe box")) async def _peri_installed(link: Link, pump: PeriPump) -> bool: @@ -247,68 +229,3 @@ async def _peri_installed(link: Link, pump: PeriPump) -> bool: """ command = SelectorQuery(CommandNumber.GET_SELECTED_PERI_INSTALLED, PERI_PUMP_TO_BYTE[pump]) return command.parse_flag(await link.request(command, operation=f"peri pump {pump.lower()}")) - - -async def _syringe_box(link: Link) -> SyringeBox: - """Read which syringe box is fitted and how many bottles it holds. - - Args: - link: The open link to the instrument. - - Returns: - The box type and size. - - Raises: - BiotekError: If the answer cannot be read. - """ - command = GetSyringeBoxInfo() - return command.parse(await link.request(command, operation="syringe box")) - - -async def _optional_byte(link: Link, number: CommandNumber) -> int | None: - """Read a one-byte option that not every firmware answers for. - - Args: - link: The open link to the instrument. - number: Which option to read. - - Returns: - The byte, or None when the instrument would not answer -- either refusing the query, or - acknowledging it and sending no value back, which older firmware does for a query it does - not implement. - """ - try: - return await _byte(link, number) - except (BiotekError, ValueError) as error: - logger.debug("%s does not answer %s: %s", link.name, number.name, error) - return None - - -async def _optional_flag(link: Link, number: CommandNumber) -> bool | None: - """Read an option that not every firmware answers for. - - Args: - link: The open link to the instrument. - number: Which option to read. - - Returns: - Whether it is fitted, or None when the instrument would not answer. - """ - answer = await _optional_byte(link, number) - return None if answer is None else bool(answer) - - -async def _answers(link: Link, number: CommandNumber) -> bool: - """Whether the instrument answers a query at all, rather than what it answers. - - One option is reported this way: a firmware that has it answers, and one that does not refuses - the query. - - Args: - link: The open link to the instrument. - number: Which query to send. - - Returns: - Whether it was answered. - """ - return await _optional_byte(link, number) is not None diff --git a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py index 0b6f6cd7e73..c6ab45355fa 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py +++ b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py @@ -100,7 +100,6 @@ def __init__( ) -> None: self._runtime = Runtime( link=Link(port=port, family=self.family, name=name, timeout=timeout, io=io), - family=self.family, rules=rules_for(self.family), ) self.washer = PlateWasher(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/__init__.py index bd6686319e1..f0ea56f3bc6 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/__init__.py @@ -2,16 +2,10 @@ from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily -from pylabrobot.agilent.biotek.lhc.enums.instrument.jig_location import JigLocation -from pylabrobot.agilent.biotek.lhc.enums.instrument.keypad_security import KeypadSecurity -from pylabrobot.agilent.biotek.lhc.enums.instrument.level_sensor_state import LevelSensorDataState -from pylabrobot.agilent.biotek.lhc.enums.instrument.offset_manifold import OffsetManifold -from pylabrobot.agilent.biotek.lhc.enums.instrument.product_type import ProductType from pylabrobot.agilent.biotek.lhc.enums.instrument.sensor import Sensor from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import ( StripWasherManifold, ) -from pylabrobot.agilent.biotek.lhc.enums.instrument.subsystem import Subsystem from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_size import SyringeBoxSize from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/basecode.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/basecode.py index dbb5bedab90..7d0da43ca87 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/basecode.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/basecode.py @@ -1,3 +1,5 @@ +"""Which firmware variant an instrument is running.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/instrument_family.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/instrument_family.py index 0e7917f437a..14ad2bb8f1d 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/instrument_family.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/instrument_family.py @@ -1,3 +1,5 @@ +"""Which hardware family a model belongs to.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/jig_location.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/jig_location.py deleted file mode 100644 index 4cd0e51f86b..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/jig_location.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -import enum - - -class JigLocation(enum.IntEnum): - """Which of the two carrier positions a calibration jig is placed in.""" - - LEFT = 0 - RIGHT = 1 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/keypad_security.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/keypad_security.py deleted file mode 100644 index aa81ca15b15..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/keypad_security.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -import enum - - -class KeypadSecurity(enum.IntEnum): - """Whether the instrument's front-panel keypad accepts input. - - Locking the keypad prevents a bystander from interfering with a run in progress. - """ - - NONE = 0 - LOCKED = 1 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/level_sensor_state.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/level_sensor_state.py deleted file mode 100644 index fad5d4507f3..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/level_sensor_state.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -import enum - - -class LevelSensorDataState(enum.IntEnum): - """Whether a stored level-sensor calibration record holds measured data. - - A record reads back as :attr:`DEFAULT` until a calibration has written measurements into it. - """ - - VALID = 51 - DEFAULT = 119 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/offset_manifold.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/offset_manifold.py deleted file mode 100644 index aa84f4058e7..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/offset_manifold.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -import enum - - -class OffsetManifold(enum.IntEnum): - """A manifold whose stored X/Y/Z position offsets can be read or written. - - Each fitted manifold carries its own calibrated offsets, because the tubes sit at a different - place relative to the carrier for every manifold geometry. - """ - - PERI_PUMP = 0 - SYRINGE_8_TUBE = 1 - SYRINGE_16_7_TUBE = 2 - SYRINGE_16_TUBE = 3 - SYRINGE_32_TUBE_LARGE_BORE = 4 - SYRINGE_32_TUBE_SMALL_BORE = 5 - WASHER_128_TUBE = 6 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/product_type.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/product_type.py deleted file mode 100644 index 066343c7179..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/product_type.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -import enum - - -class ProductType(enum.IntEnum): - """A model in the BioTek washer/dispenser family. - - Identifies which instrument a connection is expected to talk to. The model is declared rather - than discovered: nothing in the wire protocol reports it. - """ - - UNDEFINED = 0 - EL406 = 1 - ELX405 = 2 - MICROFLO = 3 - MULTIFLO = 4 - MODEL_405_TS = 5 - MULTIFLO_FX = 6 - MODEL_50_TS = 7 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/sensor.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/sensor.py index 4b68311b39a..a85ad9b13c5 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/sensor.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/sensor.py @@ -1,3 +1,5 @@ +"""The sensors an instrument can be asked about.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/strip_washer_manifold.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/strip_washer_manifold.py index f4014089a68..94b7f517858 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/strip_washer_manifold.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/strip_washer_manifold.py @@ -1,3 +1,5 @@ +"""Which strip wash manifold is fitted.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/subsystem.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/subsystem.py deleted file mode 100644 index 113e8ba5843..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/subsystem.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -import enum - - -class Subsystem(enum.IntEnum): - """A liquid-moving subsystem, addressed by calibration and verification routines. - - The verify-jig members are not pumps but the measurement fixtures used to calibrate the - dispense, aspirate and single-well positions. - """ - - PERI_PUMP_PRIMARY = 0 - SYRINGE_A = 1 - SYRINGE_B = 2 - WASHER = 3 - PERI_PUMP_SECONDARY = 4 - DISPENSER_VERIFY_JIG = 5 - STRIP_WASHER_ASPIRATE = 6 - STRIP_WASHER_DISPENSE = 7 - ASPIRATE_VERIFY_JIG = 8 - SINGLE_WELL_VERIFY_JIG = 9 diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_size.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_size.py index 993d426e8be..e9619fa7f63 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_size.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_size.py @@ -1,3 +1,5 @@ +"""How many bottles the fitted syringe box holds.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_type.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_type.py index 6ff1519c546..03164489b4c 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_type.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_box_type.py @@ -1,3 +1,5 @@ +"""Which syringe box is fitted.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_manifold.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_manifold.py index 1dfe0b1eaa5..2acf3e959ba 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_manifold.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/syringe_manifold.py @@ -1,3 +1,5 @@ +"""Which syringe manifold is fitted.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/valve_box.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/valve_box.py index bb7d510c96e..ba49269ee2e 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/valve_box.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/valve_box.py @@ -1,3 +1,5 @@ +"""Which valve box is fitted.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/instrument/washer_manifold.py b/pylabrobot/agilent/biotek/lhc/enums/instrument/washer_manifold.py index 1c90c3f6099..823a209788f 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/instrument/washer_manifold.py +++ b/pylabrobot/agilent/biotek/lhc/enums/instrument/washer_manifold.py @@ -1,3 +1,5 @@ +"""Which wash manifold is fitted.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/motion/__init__.py index 10383d0b2d1..b36a2ec1ed7 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/__init__.py @@ -1,12 +1,9 @@ """Motors, homing and the plate carrier.""" -from pylabrobot.agilent.biotek.lhc.enums.motion.basecode_motor_405ts import Basecode405TSMotor -from pylabrobot.agilent.biotek.lhc.enums.motion.basecode_motor_406 import Basecode406Motor -from pylabrobot.agilent.biotek.lhc.enums.motion.basecode_motor_multiflo import ( - BasecodeMultiFloMotor, -) -from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_speed import CarrierSpeed +from __future__ import annotations + from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType from pylabrobot.agilent.biotek.lhc.enums.motion.motor import Motor from pylabrobot.agilent.biotek.lhc.enums.motion.motor_home_type import MotorHomeType -from pylabrobot.agilent.biotek.lhc.enums.motion.motor_sensor import MotorSensor + +__all__ = ["CarrierType", "Motor", "MotorHomeType"] diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_405ts.py b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_405ts.py deleted file mode 100644 index 548acd57dd9..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_405ts.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -import enum - - -class Basecode405TSMotor(enum.IntEnum): - """Motor numbering used by 405 TS firmware fault codes.""" - - CARRIER_X = 0 - CARRIER_Y = 1 - WASH_HEAD_Z = 2 - LEVEL_SENSE_Y = 3 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_406.py b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_406.py deleted file mode 100644 index f1ec08b5222..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_406.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -import enum - - -class Basecode406Motor(enum.IntEnum): - """Motor numbering used by EL406 firmware fault codes.""" - - CARRIER_X = 0 - CARRIER_Y = 1 - DISPENSE_HEAD_Z = 2 - WASH_HEAD_Z = 3 - SYRINGE_A = 4 - SYRINGE_B = 5 - PERI_PUMP_PRIMARY = 6 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_multiflo.py b/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_multiflo.py deleted file mode 100644 index 728cdc3eb7a..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/basecode_motor_multiflo.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -import enum - - -class BasecodeMultiFloMotor(enum.IntEnum): - """Motor numbering used by MultiFlo firmware fault codes.""" - - CARRIER_X = 0 - CARRIER_Y = 1 - DISPENSE_HEAD_Z = 2 - PERI_PUMP_SECONDARY = 3 - SYRINGE_A = 4 - SYRINGE_B = 5 - PERI_PUMP_PRIMARY = 6 - STRIP_WASHER_SYRINGE = 7 - ASPIRATE_HEAD_Z = 8 - PERI_RANDOM_ACCESS_Y = 9 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_speed.py b/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_speed.py deleted file mode 100644 index d8fb7679489..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_speed.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -import enum - - -class CarrierSpeed(enum.IntEnum): - """How fast the carrier travels in and out. - - The slow setting reduces splashing when carrying full wells. - """ - - DEFAULT = 0 - SLOW = 1 diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_type.py b/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_type.py index eb100d5dd2d..9a10c9ad16f 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_type.py +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/carrier_type.py @@ -1,3 +1,5 @@ +"""Which plate carrier is fitted.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/motor.py b/pylabrobot/agilent/biotek/lhc/enums/motion/motor.py index 2b26932937d..367d5f6657b 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/motor.py +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/motor.py @@ -1,3 +1,5 @@ +"""The motors a homing or verification command can address.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/motor_home_type.py b/pylabrobot/agilent/biotek/lhc/enums/motion/motor_home_type.py index 9fc6786ad45..ab3da590013 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/motor_home_type.py +++ b/pylabrobot/agilent/biotek/lhc/enums/motion/motor_home_type.py @@ -1,3 +1,5 @@ +"""What a homing command should do.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/motion/motor_sensor.py b/pylabrobot/agilent/biotek/lhc/enums/motion/motor_sensor.py deleted file mode 100644 index 53784da33d0..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/motion/motor_sensor.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -import enum - - -class MotorSensor(enum.IntEnum): - """Which of a motor's position sensors a fault refers to.""" - - NONE = 0 - HOME = 1 - AUX_1 = 2 - AUX_2 = 3 diff --git a/pylabrobot/agilent/biotek/lhc/enums/plates/plate_restriction.py b/pylabrobot/agilent/biotek/lhc/enums/plates/plate_restriction.py index f61fd96fe97..d08cc37bdbb 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/plates/plate_restriction.py +++ b/pylabrobot/agilent/biotek/lhc/enums/plates/plate_restriction.py @@ -1,3 +1,5 @@ +"""Which plates an instrument has been configured to accept.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/plates/plate_type.py b/pylabrobot/agilent/biotek/lhc/enums/plates/plate_type.py index f8107c623de..5ef4b0ed09e 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/plates/plate_type.py +++ b/pylabrobot/agilent/biotek/lhc/enums/plates/plate_type.py @@ -1,3 +1,5 @@ +"""The labware formats an instrument can be set to.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/status/activity.py b/pylabrobot/agilent/biotek/lhc/enums/status/activity.py index 4d5a7b3fbb5..2becef9ba85 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/status/activity.py +++ b/pylabrobot/agilent/biotek/lhc/enums/status/activity.py @@ -1,3 +1,5 @@ +"""The timed phase a running step is in.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/status/run_state.py b/pylabrobot/agilent/biotek/lhc/enums/status/run_state.py index 71dcc8e05b6..bb52f60a10d 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/status/run_state.py +++ b/pylabrobot/agilent/biotek/lhc/enums/status/run_state.py @@ -1,3 +1,5 @@ +"""What the instrument is doing, as a status poll reports it.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/__init__.py b/pylabrobot/agilent/biotek/lhc/enums/steps/__init__.py index 38b0366c08b..2a200d31022 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/__init__.py @@ -18,7 +18,6 @@ CASSETTE_TYPE_TO_BYTE, CassetteType, ) -from pylabrobot.agilent.biotek.lhc.enums.steps.fill_pattern import FILL_PATTERN_TO_BYTE, FillPattern from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import ( PERI_FLOW_RATE_TO_BYTE, PeriFlowRate, @@ -31,7 +30,6 @@ from pylabrobot.agilent.biotek.lhc.enums.steps.shake_axis import SHAKE_AXIS_TO_BYTE, ShakeAxis from pylabrobot.agilent.biotek.lhc.enums.steps.shake_intensity import ( SHAKE_INTENSITY_TO_BYTE, - SHAKE_INTENSITY_TO_FREQUENCY, ShakeIntensity, ) from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction @@ -44,9 +42,7 @@ from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import ( STRIP_TRAVEL_RATES, TRAVEL_RATE_TO_BYTE, - TRAVEL_RATE_TO_SPEED, WASHER_TRAVEL_RATES, TravelRate, - is_cell_washing, ) from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WASH_FORMAT_TO_BYTE, WashFormat diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/buffer.py b/pylabrobot/agilent/biotek/lhc/enums/steps/buffer.py index 17adb305924..94295d719e8 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/buffer.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/buffer.py @@ -1,3 +1,5 @@ +"""Which buffer inlet a step draws from.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_head.py b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_head.py index fe98c6a4529..18ae5812afe 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_head.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_head.py @@ -1,3 +1,5 @@ +"""How a peristaltic cassette's tubes map onto wells.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_mode.py b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_mode.py index 725931d4394..69bc16de876 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_mode.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_mode.py @@ -1,3 +1,5 @@ +"""What to do when the fitted cassette is not the one a step needs.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_type.py b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_type.py index 5f663b14601..3ab9b77a683 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_type.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/cassette_type.py @@ -1,3 +1,5 @@ +"""Which peristaltic cassette a step requires.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/fill_pattern.py b/pylabrobot/agilent/biotek/lhc/enums/steps/fill_pattern.py deleted file mode 100644 index 7780f096c09..00000000000 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/fill_pattern.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -FillPattern = Literal["Column", "Row"] -"""The order in which a random-access dispense visits the selected wells. - -A step that does not choose an order carries None. -""" - -FILL_PATTERN_TO_BYTE: dict[FillPattern, int] = {"Column": 0, "Row": 1} -"""The value each pattern is encoded as in a step command. No pattern encodes 255.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/peri_flow_rate.py b/pylabrobot/agilent/biotek/lhc/enums/steps/peri_flow_rate.py index 221fa4cec30..34367babbee 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/peri_flow_rate.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/peri_flow_rate.py @@ -1,3 +1,5 @@ +"""How fast a peristaltic pump runs.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/peri_pump.py b/pylabrobot/agilent/biotek/lhc/enums/steps/peri_pump.py index 4eeea6269c0..9f9a74b174b 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/peri_pump.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/peri_pump.py @@ -1,3 +1,5 @@ +"""Which peristaltic pump a step drives.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/secondary_aspirate_pattern.py b/pylabrobot/agilent/biotek/lhc/enums/steps/secondary_aspirate_pattern.py index 3b38519bdbf..906ae43e2e5 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/secondary_aspirate_pattern.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/secondary_aspirate_pattern.py @@ -1,3 +1,5 @@ +"""The path a second aspirate traces in the well.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/shake_axis.py b/pylabrobot/agilent/biotek/lhc/enums/steps/shake_axis.py index 2d861f7fc5a..eefbff20939 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/shake_axis.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/shake_axis.py @@ -1,3 +1,5 @@ +"""Which axis the carrier shakes along.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/shake_intensity.py b/pylabrobot/agilent/biotek/lhc/enums/steps/shake_intensity.py index d3db2551912..dc0a4f13a37 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/shake_intensity.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/shake_intensity.py @@ -1,3 +1,5 @@ +"""How vigorously the carrier shakes.""" + from __future__ import annotations from typing import Literal @@ -16,11 +18,3 @@ "Fast": 4, } """The value each intensity is encoded as in a step command.""" - -SHAKE_INTENSITY_TO_FREQUENCY: dict[ShakeIntensity, float | None] = { - "Variable": None, - "Slow": 3.5, - "Medium": 5.0, - "Fast": 8.0, -} -"""Shake frequency in Hz per intensity, or None where the frequency is swept.""" diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/step_action.py b/pylabrobot/agilent/biotek/lhc/enums/steps/step_action.py index 11dca338556..d6b3f830bd7 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/step_action.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/step_action.py @@ -1,3 +1,5 @@ +"""What a protocol entry does, beyond operating the instrument.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/step_type.py b/pylabrobot/agilent/biotek/lhc/enums/steps/step_type.py index 69d78e857d2..363b334113d 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/step_type.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/step_type.py @@ -1,3 +1,5 @@ +"""Which operation a protocol step performs.""" + from __future__ import annotations import enum diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/syringe.py b/pylabrobot/agilent/biotek/lhc/enums/steps/syringe.py index 31055f92be0..4f9ff508154 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/syringe.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/syringe.py @@ -1,3 +1,5 @@ +"""Which syringe a step drives.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/syringe_bottle.py b/pylabrobot/agilent/biotek/lhc/enums/steps/syringe_bottle.py index 41ace424ba8..b147651fc16 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/syringe_bottle.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/syringe_bottle.py @@ -1,3 +1,5 @@ +"""Which supply bottle a driven syringe draws from.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/travel_rate.py b/pylabrobot/agilent/biotek/lhc/enums/steps/travel_rate.py index 15fbd30f555..4eb80776584 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/travel_rate.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/travel_rate.py @@ -1,3 +1,5 @@ +"""How fast an aspirate head travels down through a well.""" + from __future__ import annotations from typing import Literal @@ -31,22 +33,6 @@ } """The value each rate is encoded as in a step command.""" -TRAVEL_RATE_TO_SPEED: dict[TravelRate, float] = { - "1": 4.1, - "2": 5.0, - "3": 7.3, - "4": 9.4, - "5": 9.4, - "0 CW": 1.0, - "1 CW": 4.1, - "2 CW": 5.0, - "3 CW": 7.3, - "4 CW": 9.4, - "6 CW": 14.7, - "7 CW": 30.0, -} -"""Descent speed in mm/s per rate.""" - WASHER_TRAVEL_RATES: tuple[TravelRate, ...] = ( "1", "2", @@ -63,15 +49,3 @@ STRIP_TRAVEL_RATES: tuple[TravelRate, ...] = WASHER_TRAVEL_RATES + ("0 CW", "7 CW") """The rates a strip washer aspirate step accepts.""" - - -def is_cell_washing(rate: TravelRate) -> bool: - """Whether a rate needs the cell washing module. - - Args: - rate: The travel rate to test. - - Returns: - True for the single-speed ``CW`` rates. - """ - return rate.endswith(" CW") diff --git a/pylabrobot/agilent/biotek/lhc/enums/steps/wash_format.py b/pylabrobot/agilent/biotek/lhc/enums/steps/wash_format.py index 40566eab18b..dbfc7e9b0d6 100644 --- a/pylabrobot/agilent/biotek/lhc/enums/steps/wash_format.py +++ b/pylabrobot/agilent/biotek/lhc/enums/steps/wash_format.py @@ -1,3 +1,5 @@ +"""How much of a plate a wash covers.""" + from __future__ import annotations from typing import Literal diff --git a/pylabrobot/agilent/biotek/lhc/error_handling/error_codes.py b/pylabrobot/agilent/biotek/lhc/error_handling/error_codes.py index 540fc4152e7..62a07e0765b 100644 --- a/pylabrobot/agilent/biotek/lhc/error_handling/error_codes.py +++ b/pylabrobot/agilent/biotek/lhc/error_handling/error_codes.py @@ -377,7 +377,7 @@ 0x6010: "The data is invalid or out-of-range.", 0x6011: "This step type can not be downloaded.", 0x6012: ( - "Illegal characters in protocol name; valid characters are letters, numbers, " "spaces, or _-%&" + "Illegal characters in protocol name; valid characters are letters, numbers, spaces, or _-%&" ), 0x6013: "The protocol name length must be 16 characters or less.", 0x6015: "The specified volume exceeds the cassette maximum limit.", diff --git a/pylabrobot/agilent/biotek/lhc/plate_geometry/plate_record.py b/pylabrobot/agilent/biotek/lhc/plate_geometry/plate_record.py index 38a9eb32db2..cb97c455c96 100644 --- a/pylabrobot/agilent/biotek/lhc/plate_geometry/plate_record.py +++ b/pylabrobot/agilent/biotek/lhc/plate_geometry/plate_record.py @@ -3,9 +3,17 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Literal from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType +Head = Literal["dispenser", "manifold dispense", "manifold aspirate"] +"""Which head a nominal height belongs to. + +A plate is worked from a different height by each of them, so asking for a height means saying +which one is doing the work. +""" + @dataclass(frozen=True) class PlateRecord: @@ -38,3 +46,23 @@ class PlateRecord: def wells(self) -> int: """How many wells the plate has.""" return self.columns * self.rows + + def height_for(self, head: Head) -> int: + """The nominal height this plate is worked from by one head. + + A step carries the height it works at outright rather than an offset from anything, so this is + what a step should be given when the caller does not name a height of its own. Getting it from + the plate is what stops a height that suits one plate being used on a shallower one. + + Args: + head: Which head is doing the work. + + Returns: + The height, in motor steps. + """ + heights: dict[Head, int] = { + "dispenser": self.dispenser_height, + "manifold dispense": self.manifold_dispense_height, + "manifold aspirate": self.manifold_aspirate_height, + } + return heights[head] diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/groups.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/groups.py index b6e44a598b8..6f771ef78b9 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/groups.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/groups.py @@ -98,7 +98,7 @@ class SecondaryAspirate: """ pattern: SecondaryAspiratePattern = "None" - positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=30)) @property def enabled(self) -> bool: diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/positioning.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/positioning.py index 7c7b81d818f..0a08c5edbbc 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/positioning.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/positioning.py @@ -9,19 +9,24 @@ class Positioning: """An X, Y and Z offset from the nominal position for the plate in use. - Values are in the instrument's own motor steps, positive Z being further down into the well. - What range each axis accepts depends on the step and on the plate, and is checked by validation - rather than here. + The values are the instrument's own motor steps, not millimetres, and the field names say so. + This is what a protocol file stores and what a step command carries, and the conversion to + millimetres is not one number: it differs per axis, per instrument model and per head. Keeping the + step model in the unit the file uses is what lets a protocol be read and written without an + instrument to ask. + + Positive Z is further down into the well. What range each axis accepts depends on the step and on + the plate, and is checked by validation rather than here. Attributes: - z: Depth offset. - x: Offset across the plate. - y: Offset along the plate. + z_steps: Depth offset, in motor steps. + x_steps: Offset across the plate, in motor steps. + y_steps: Offset along the plate, in motor steps. """ - z: int = 0 - x: int = 0 - y: int = 0 + z_steps: int = 0 + x_steps: int = 0 + y_steps: int = 0 def to_definition(self) -> str: """The three fields a protocol file stores, which are ordered Z, X, Y. @@ -29,7 +34,7 @@ def to_definition(self) -> str: Returns: The fields, ``|``-separated. """ - return f"{self.z}|{self.x}|{self.y}" + return f"{self.z_steps}|{self.x_steps}|{self.y_steps}" @classmethod def from_definition(cls, z: str, x: str, y: str) -> Positioning: @@ -45,4 +50,4 @@ def from_definition(cls, z: str, x: str, y: str) -> Positioning: Returns: The offsets. """ - return cls(z=int(z), x=int(x), y=int(y)) + return cls(z_steps=int(z), x_steps=int(x), y_steps=int(y)) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py index 06c8894bb2f..ed17486ce28 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_aspirate.py @@ -46,7 +46,7 @@ class ManifoldAspirate(Step): vacuum_filtration: bool = False travel_rate: TravelRate = "3" delay: int = 0 - positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=22)) secondary: SecondaryAspirate = field(default_factory=SecondaryAspirate) radius: str = "0" columns: WellMask = field(default_factory=WellMask.all_columns) @@ -107,7 +107,9 @@ def from_definition(cls, text: str) -> ManifoldAspirate: travel_rate=definition.TRAVEL_RATES[travel_rate], delay=definition.number(delay, 16), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 8), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 8), + y_steps=definition.signed(y, 8), ), secondary=SecondaryAspirate.from_definition(pattern, secondary_z, secondary_x, secondary_y), radius=radius, @@ -132,13 +134,13 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: u8(1 if self.vacuum_filtration else 0) + u16(self.delay) + u8(TRAVEL_RATE_TO_BYTE[self.travel_rate]) - + i8(self.positioning.x) - + i8(self.positioning.y) - + i16(self.positioning.z) + + i8(self.positioning.x_steps) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + u8(SECONDARY_ASPIRATE_PATTERN_TO_BYTE[self.secondary.pattern]) - + i8(self.secondary.positioning.x) - + i8(self.secondary.positioning.y) - + i16(self.secondary.positioning.z) + + i8(self.secondary.positioning.x_steps) + + i8(self.secondary.positioning.y_steps) + + i16(self.secondary.positioning.z_steps) + u16(0) + u16(columns), _PAYLOAD_LENGTH, diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py index fd317653f8b..b1efe7a0cf4 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/manifold_dispense.py @@ -44,7 +44,7 @@ class ManifoldDispense(Step): buffer: Buffer = "A" volume: int = 0 flow_rate: int = 7 - positioning: Positioning = field(default_factory=lambda: Positioning(z=120)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=120)) pre_dispense: PreDispense = field(default_factory=lambda: PreDispense(volume=0, flow_rate=9)) vacuum: VacuumDelay = field(default_factory=VacuumDelay) check_buffer: bool = True @@ -102,7 +102,9 @@ def from_definition(cls, text: str) -> ManifoldDispense: volume=definition.number(volume, 16), flow_rate=definition.number(flow_rate, 8), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 8), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 8), + y_steps=definition.signed(y, 8), ), pre_dispense=PreDispense( enabled=definition.flag(pre_enabled), @@ -128,9 +130,9 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: u8(ord(self.buffer)) + u16(self.volume) + u8(self.flow_rate) - + i8(self.positioning.x) - + i8(self.positioning.y) - + i16(self.positioning.z) + + i8(self.positioning.x_steps) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + u16(self.pre_dispense.wire_volume) + u8(self.pre_dispense.flow_rate) + u16(self.vacuum.wire_volume), diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py index 8f32f43621a..f66f6e82e1f 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_dispense.py @@ -59,7 +59,7 @@ class PeriDispense(Step): volume: int = 10 flow_rate: PeriFlowRate = "High" cassette_type: CassetteType | None = "Any" - positioning: Positioning = field(default_factory=lambda: Positioning(z=336)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=333)) pre_dispense: PreDispense = field( default_factory=lambda: PreDispense(enabled=True, volume=10, count=2) ) @@ -130,7 +130,9 @@ def from_definition(cls, text: str) -> PeriDispense: flow_rate=PERI_FLOW_RATES[flow_rate], cassette_type=BYTE_TO_CASSETTE_TYPE.get(int(cassette)), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 16), + y_steps=definition.signed(y, 8), ), pre_dispense=PreDispense( enabled=definition.flag(pre_enabled), @@ -157,20 +159,20 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: """ pump = NO_PUMP if self.peri_pump is None else PERI_PUMP_TO_BYTE[self.peri_pump] if settings.advanced_dispense_offsets: - offsets = i16(self.positioning.x) + offsets = i16(self.positioning.x_steps) else: cassette = ( NO_CASSETTE_REQUIREMENT if self.cassette_type is None else CASSETTE_TYPE_TO_BYTE[self.cassette_type] ) - offsets = u8(cassette) + i8(self.positioning.x) + offsets = u8(cassette) + i8(self.positioning.x_steps) return pad( u16(self.volume) + u8(PERI_FLOW_RATE_TO_BYTE[self.flow_rate]) + offsets - + i8(self.positioning.y) - + i16(self.positioning.z) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + u16(self.pre_dispense.wire_volume) + u8(self.pre_dispense.count) + self.columns.to_bytes() diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py index 2a112393af9..37ad17c3b10 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_random_access_dispense.py @@ -119,7 +119,9 @@ def from_definition(cls, text: str) -> PeriRandomAccessDispense: flow_rate=PERI_FLOW_RATES[flow_rate], cassette_type=BYTE_TO_CASSETTE_TYPE.get(int(cassette)), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 16), + y_steps=definition.signed(y, 8), ), pre_dispense=PreDispense( enabled=definition.flag(pre_enabled), @@ -150,9 +152,9 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: return pad( u16(self.volume) + u8(PERI_FLOW_RATE_TO_BYTE[self.flow_rate]) - + i16(self.positioning.x) - + i8(self.positioning.y) - + i16(self.positioning.z) + + i16(self.positioning.x_steps) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + u16(self.pre_dispense.wire_volume) + u8(self.pre_dispense.count) + bytes(value for row in self.well_volumes.values for value in row) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py index a244e1cc75a..c63a3ef0ba1 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_aspirate.py @@ -42,7 +42,7 @@ class PeriWashAspirate(Step): volume: int = 100 flow_rate: int = 2 - positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=22)) peri_pump: PeriPump | None = "Primary" columns: WellMask = field(default_factory=WellMask.all_columns) rows: WellMask = field(default_factory=WellMask.all_rows) @@ -86,7 +86,9 @@ def from_definition(cls, text: str) -> PeriWashAspirate: volume=definition.number(volume, 16), flow_rate=definition.number(flow_rate, 8), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 16), + y_steps=definition.signed(y, 8), ), peri_pump=_BYTE_TO_PERI_PUMP.get(int(pump)), columns=WellMask.from_definition(columns), @@ -109,9 +111,9 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: return pad( u16(self.volume) + u8(self.flow_rate) - + i16(self.positioning.x) - + i8(self.positioning.y) - + i16(self.positioning.z) + + i16(self.positioning.x_steps) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + self.columns.to_bytes() + self.rows.to_bytes_inverted() + u8(pump), diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py index d4029d5bab0..4f107d3f124 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/peri_wash_dispense.py @@ -44,7 +44,7 @@ class PeriWashDispense(Step): volume: int = 100 flow_rate: int = 2 - positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=22)) peri_pump: PeriPump | None = "Primary" pre_dispense: PreDispense = field( default_factory=lambda: PreDispense(enabled=True, volume=25, count=2) @@ -103,7 +103,9 @@ def from_definition(cls, text: str) -> PeriWashDispense: volume=definition.number(volume, 16), flow_rate=definition.number(flow_rate, 8), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 16), + y_steps=definition.signed(y, 8), ), peri_pump=_BYTE_TO_PERI_PUMP.get(int(pump)), pre_dispense=PreDispense( @@ -131,9 +133,9 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: return pad( u16(self.volume) + u8(self.flow_rate) - + i16(self.positioning.x) - + i8(self.positioning.y) - + i16(self.positioning.z) + + i16(self.positioning.x_steps) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + u16(self.pre_dispense.wire_volume) + u8(self.pre_dispense.count) + self.columns.to_bytes() diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py index 17f749b7a90..970fc735a6f 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_aspirate.py @@ -45,7 +45,7 @@ class StripAspirate(Step): travel_rate: TravelRate = "3" delay: int = 0 - positioning: Positioning = field(default_factory=lambda: Positioning(z=30)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=22)) secondary: SecondaryAspirate = field(default_factory=SecondaryAspirate) columns: WellMask = field(default_factory=WellMask.all_columns) rows: WellMask = field(default_factory=WellMask.all_rows) @@ -93,7 +93,9 @@ def from_definition(cls, text: str) -> StripAspirate: travel_rate=definition.TRAVEL_RATES[travel_rate], delay=definition.number(delay, 16), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 16), + y_steps=definition.signed(y, 8), ), secondary=SecondaryAspirate.from_definition(pattern, secondary_z, secondary_x, secondary_y), columns=WellMask.from_definition(masks[0]) if masks else WellMask.all_columns(), @@ -116,13 +118,13 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: payload = ( u16(self.delay) + u8(TRAVEL_RATE_TO_BYTE[self.travel_rate]) - + i16(self.positioning.x) - + i8(self.positioning.y) - + i16(self.positioning.z) + + i16(self.positioning.x_steps) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + u8(SECONDARY_ASPIRATE_PATTERN_TO_BYTE[self.secondary.pattern]) - + i16(self.secondary.positioning.x) - + i8(self.secondary.positioning.y) - + i16(self.secondary.positioning.z) + + i16(self.secondary.positioning.x_steps) + + i8(self.secondary.positioning.y_steps) + + i16(self.secondary.positioning.z_steps) ) if self.in_wash: return pad(payload, _IN_WASH_PAYLOAD_LENGTH) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py index 68498adedba..90f8d84eaf3 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/strip_dispense.py @@ -48,7 +48,7 @@ class StripDispense(Step): volume: int = 50 flow_rate: int = 5 - positioning: Positioning = field(default_factory=lambda: Positioning(z=336)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=333)) pre_dispense: PreDispense = field( default_factory=lambda: PreDispense(volume=50, flow_rate=5, count=2) ) @@ -116,7 +116,9 @@ def from_definition(cls, text: str) -> StripDispense: volume=definition.number(volume, 16), flow_rate=definition.number(flow_rate, 8), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 16), + y_steps=definition.signed(y, 8), ), pre_dispense=PreDispense( enabled=definition.flag(pre_enabled), @@ -146,9 +148,9 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: payload = ( u16(self.volume) + u8(self.flow_rate) - + i16(self.positioning.x) - + i8(self.positioning.y) - + i16(self.positioning.z) + + i16(self.positioning.x_steps) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + u16(self.pre_dispense.volume if pre_dispensing else 0) + u8(self.pre_dispense.flow_rate if self.in_wash else self.flow_rate) + u8(self.pre_dispense.count) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py index e3d7596db85..29aa37cda53 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/syringe_dispense.py @@ -51,7 +51,7 @@ class SyringeDispense(Step): syringe: Syringe = "A" volume: int = 50 flow_rate: int = 2 - positioning: Positioning = field(default_factory=lambda: Positioning(z=336)) + positioning: Positioning = field(default_factory=lambda: Positioning(z_steps=333)) pre_dispense: PreDispense = field(default_factory=lambda: PreDispense(volume=50, count=2)) pump_delay: int = 0 columns: WellMask = field(default_factory=WellMask.all_columns) @@ -122,7 +122,9 @@ def from_definition(cls, text: str) -> SyringeDispense: volume=definition.number(volume, 16), flow_rate=definition.number(flow_rate, 8), positioning=Positioning( - z=definition.signed(z, 16), x=definition.signed(x, 16), y=definition.signed(y, 8) + z_steps=definition.signed(z, 16), + x_steps=definition.signed(x, 16), + y_steps=definition.signed(y, 8), ), pre_dispense=PreDispense( enabled=definition.flag(pre_enabled), @@ -150,16 +152,16 @@ def to_bytes(self, settings: InstrumentSettings) -> bytes: The payload. """ if settings.advanced_dispense_offsets: - offset_x, length = i16(self.positioning.x), _WIDE_OFFSET_PAYLOAD_LENGTH + offset_x, length = i16(self.positioning.x_steps), _WIDE_OFFSET_PAYLOAD_LENGTH else: - offset_x, length = i8(self.positioning.x), _PAYLOAD_LENGTH + offset_x, length = i8(self.positioning.x_steps), _PAYLOAD_LENGTH payload = ( u8(SYRINGE_TO_BYTE[self.syringe] - 1) + u16(self.volume) + u8(self.flow_rate) + offset_x - + i8(self.positioning.y) - + i16(self.positioning.z) + + i8(self.positioning.y_steps) + + i16(self.positioning.z_steps) + u16(self.pump_delay) + u16(self.pre_dispense.wire_volume) + u8(self.pre_dispense.count) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py index 34226fe2261..5c811284dcb 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/steps/wash_1536.py @@ -177,7 +177,7 @@ def _aspirate_extract(aspirate: ManifoldAspirate) -> bytes: return ( u16(aspirate.delay) + u8(TRAVEL_RATE_TO_BYTE[aspirate.travel_rate]) - + i8(aspirate.positioning.x) - + i8(aspirate.positioning.y) - + i16(aspirate.positioning.z) + + i8(aspirate.positioning.x_steps) + + i8(aspirate.positioning.y_steps) + + i16(aspirate.positioning.z_steps) ) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/checks.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/checks.py index f3f242fee75..5507fc143be 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/validation/checks.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/checks.py @@ -348,8 +348,7 @@ def syringe_volume( if value < minimum or value > maximum: return Rejection( VOLUME, - f"{prefix}Volume for the specified Flow Rate\r\nand Plate Type must be " - f"{minimum}..{maximum}", + f"{prefix}Volume for the specified Flow Rate\r\nand Plate Type must be {minimum}..{maximum}", ) if value != int(value) and manifold is SyringeManifold.TUBE_16: return Rejection(FRACTIONAL_VOLUME) diff --git a/pylabrobot/agilent/biotek/lhc/protocols/validation/step_checks.py b/pylabrobot/agilent/biotek/lhc/protocols/validation/step_checks.py index 9b10291fc18..6136f1df2a4 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/validation/step_checks.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/validation/step_checks.py @@ -183,9 +183,9 @@ def check_manifold_dispense( ) return rejection rejection = ( - checks.offset_x(step.positioning.x, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) - or checks.offset_y(step.positioning.y, *_manifold_y_range(settings), prefix) - or checks.offset_z(step.positioning.z, *_manifold_z_range(settings), prefix) + checks.offset_x(step.positioning.x_steps, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) + or checks.offset_y(step.positioning.y_steps, *_manifold_y_range(settings), prefix) + or checks.offset_z(step.positioning.z_steps, *_manifold_z_range(settings), prefix) ) if rejection is not None: return rejection @@ -245,9 +245,9 @@ def check_manifold_aspirate( rejection = ( checks.aspirate_delay(step.delay, False, prefix) or checks.travel_rate(step.travel_rate, WASHER_TRAVEL_RATES, prefix) - or checks.offset_x(step.positioning.x, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) - or checks.offset_y(step.positioning.y, *_manifold_y_range(settings), prefix) - or checks.offset_z(step.positioning.z, *_manifold_z_range(settings), prefix) + or checks.offset_x(step.positioning.x_steps, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) + or checks.offset_y(step.positioning.y_steps, *_manifold_y_range(settings), prefix) + or checks.offset_z(step.positioning.z_steps, *_manifold_z_range(settings), prefix) ) if rejection is not None: return rejection @@ -257,9 +257,9 @@ def check_manifold_aspirate( return None prefix = "Secondary Aspirate " return ( - checks.offset_x(step.secondary.positioning.x, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) - or checks.offset_y(step.secondary.positioning.y, *_manifold_y_range(settings), prefix) - or checks.offset_z(step.secondary.positioning.z, *_manifold_z_range(settings), prefix) + checks.offset_x(step.secondary.positioning.x_steps, *checks.OFFSET_X_MANIFOLD_RANGE, prefix) + or checks.offset_y(step.secondary.positioning.y_steps, *_manifold_y_range(settings), prefix) + or checks.offset_z(step.secondary.positioning.z_steps, *_manifold_z_range(settings), prefix) ) @@ -318,11 +318,11 @@ def check_syringe_dispense( or checks.syringe_volume( step.volume, step.flow_rate, settings.syringe_manifold, plate.wells, maximum, prefix ) - or checks.offset_x(step.positioning.x, *x_range, prefix) - or checks.offset_y(step.positioning.y, *y_range, prefix) + or checks.offset_x(step.positioning.x_steps, *x_range, prefix) + or checks.offset_y(step.positioning.y_steps, *y_range, prefix) or checks.column_selection(step.columns.values, prefix) or checks.pump_delay(step.pump_delay, prefix) - or checks.offset_z(step.positioning.z, *checks.OFFSET_Z_RANGE, prefix) + or checks.offset_z(step.positioning.z_steps, *checks.OFFSET_Z_RANGE, prefix) ) if rejection is not None or not step.pre_dispense.enabled: return rejection @@ -395,9 +395,9 @@ def check_peri_dispense( checks.cassette_type(step.cassette_type) or checks.volume_or_half_microlitre(step.volume, 1, maximum, half_allowed, prefix) or checks.peri_flow_rate(step.flow_rate, prefix) - or checks.offset_x(step.positioning.x, *x_range, prefix) - or checks.offset_y(step.positioning.y, *y_range, prefix) - or checks.offset_z(step.positioning.z, *checks.OFFSET_Z_RANGE, prefix) + or checks.offset_x(step.positioning.x_steps, *x_range, prefix) + or checks.offset_y(step.positioning.y_steps, *y_range, prefix) + or checks.offset_z(step.positioning.z_steps, *checks.OFFSET_Z_RANGE, prefix) or checks.column_selection(step.columns.values, prefix) or checks.row_selection(step.rows.values, plate.rows // 8, prefix) ) @@ -575,9 +575,9 @@ def check_strip_aspirate( rejection = ( checks.aspirate_delay(step.delay, False, prefix) or checks.travel_rate(step.travel_rate, STRIP_TRAVEL_RATES, prefix) - or checks.offset_x(step.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) - or checks.offset_y(step.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) - or checks.offset_z(step.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + or checks.offset_x(step.positioning.x_steps, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.offset_y(step.positioning.y_steps, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_z(step.positioning.z_steps, *_STRIP_OFFSET_Z_RANGE, prefix) ) if rejection is not None: return rejection @@ -587,9 +587,9 @@ def check_strip_aspirate( return None prefix = "Secondary Aspirate " return ( - checks.offset_x(step.secondary.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) - or checks.offset_y(step.secondary.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) - or checks.offset_z(step.secondary.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + checks.offset_x(step.secondary.positioning.x_steps, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.offset_y(step.secondary.positioning.y_steps, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_z(step.secondary.positioning.z_steps, *_STRIP_OFFSET_Z_RANGE, prefix) ) @@ -616,9 +616,9 @@ def check_strip_dispense( rejection = ( checks.flow_rate(step.flow_rate, 1, 11, prefix) or checks.strip_dispense_volume(step.volume, step.flow_rate, manifold, prefix) - or checks.offset_x(step.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) - or checks.offset_y(step.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) - or checks.offset_z(step.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + or checks.offset_x(step.positioning.x_steps, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.offset_y(step.positioning.y_steps, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_z(step.positioning.z_steps, *_STRIP_OFFSET_Z_RANGE, prefix) ) if rejection is not None: return rejection @@ -712,12 +712,12 @@ def check_peri_wash_aspirate( prefix = "PW-Aspirate " return ( checks.volume(step.volume, 1, _WIDE_VOLUME_MAXIMUM, prefix) - or checks.offset_y(step.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) - or checks.offset_x(step.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.offset_y(step.positioning.y_steps, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_x(step.positioning.x_steps, *_STRIP_OFFSET_X_RANGE, prefix) or checks.row_selection(step.rows.values, plate.rows // 8, prefix) or checks.column_selection(step.columns.values, prefix) or checks.flow_rate(step.flow_rate, 0, 4, prefix) - or checks.offset_z(step.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + or checks.offset_z(step.positioning.z_steps, *_STRIP_OFFSET_Z_RANGE, prefix) ) @@ -740,14 +740,14 @@ def check_peri_wash_dispense( prefix = "PW-Dispense " return ( checks.volume(step.volume, 1, _WIDE_VOLUME_MAXIMUM, prefix) - or checks.offset_y(step.positioning.y, *_STRIP_OFFSET_Y_RANGE, prefix) - or checks.offset_x(step.positioning.x, *_STRIP_OFFSET_X_RANGE, prefix) + or checks.offset_y(step.positioning.y_steps, *_STRIP_OFFSET_Y_RANGE, prefix) + or checks.offset_x(step.positioning.x_steps, *_STRIP_OFFSET_X_RANGE, prefix) or checks.volume(step.pre_dispense.volume, 25, _WIDE_VOLUME_MAXIMUM, "Pre-dispense ") or checks.count(step.pre_dispense.count, "Pre-dispense ") or checks.row_selection(step.rows.values, plate.rows // 8, prefix) or checks.column_selection(step.columns.values, prefix) or checks.flow_rate(step.flow_rate, 0, 7, prefix) - or checks.offset_z(step.positioning.z, *_STRIP_OFFSET_Z_RANGE, prefix) + or checks.offset_z(step.positioning.z_steps, *_STRIP_OFFSET_Z_RANGE, prefix) ) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/__init__.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/__init__.py index 73cec19ad12..f35d62a8c25 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/commands/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/__init__.py @@ -4,7 +4,6 @@ from pylabrobot.agilent.biotek.lhc.serialization.commands.configuration import ( ByteQuery, - ByteWrite, FlagQuery, GetSyringeBoxInfo, SelectorQuery, @@ -15,7 +14,6 @@ HomeVerifyMotors, ResetInstrument, RunSelfCheck, - SetSensorEnabled, ) from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( FirmwareVersion, @@ -37,7 +35,6 @@ __all__ = [ "AbortStep", "ByteQuery", - "ByteWrite", "ExitProtocol", "FirmwareVersion", "FlagQuery", @@ -56,6 +53,5 @@ "RunStep", "SelectorQuery", "SelectorWrite", - "SetSensorEnabled", "SyringeBox", ] diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py index 260a5311668..4fb4f3b3ee8 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/configuration.py @@ -103,19 +103,6 @@ def parse_flag(self, answer: bytes) -> bool: return bool(self.parse(answer)) -class ByteWrite(Command): - """A command that sends one byte and reads back nothing but the status.""" - - def __init__(self, number: CommandNumber, value: int) -> None: - """Build the command. - - Args: - number: Which command to send. - value: The byte to send. - """ - super().__init__(number=number, payload=bytes([value])) - - class SelectorWrite(Command): """A command that sends a selector byte and a value byte, and reads back only the status.""" diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/diagnostics.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/diagnostics.py index 52c0d3c99f9..035abfccc55 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/commands/diagnostics.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/diagnostics.py @@ -49,18 +49,3 @@ def __init__(self, home_type: int, motor: int) -> None: payload=bytes([home_type, motor]), timeout=_HOME_TIMEOUT, ) - - -class SetSensorEnabled(Command): - """Switch a sensor on or off.""" - - def __init__(self, sensor: int, enabled: bool) -> None: - """Build the command. - - Args: - sensor: Which sensor to switch. - enabled: Whether it should be on. - """ - super().__init__( - number=CommandNumber.SET_SENSOR_ENABLED, payload=bytes([sensor, 1 if enabled else 0]) - ) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/frame.py b/pylabrobot/agilent/biotek/lhc/serialization/frame.py index b563d85d786..9f2a2eff2e4 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/frame.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/frame.py @@ -98,8 +98,3 @@ def from_bytes(cls, raw: bytes) -> Header: payload_length=int.from_bytes(raw[7:9], "little"), check=int.from_bytes(raw[9:11], "little"), ) - - @property - def is_valid(self) -> bool: - """Whether this looks like a reply header at all, which is what its first byte says.""" - return self.start in (START_MARKER, VERSION_MARKER) diff --git a/pylabrobot/agilent/biotek/lhc/tests/corpus_tests.py b/pylabrobot/agilent/biotek/lhc/tests/corpus_tests.py index 67bd3c1cb6d..b82010095e6 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/corpus_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/corpus_tests.py @@ -108,6 +108,7 @@ def test_the_entries_survive_being_written_again(path: Path): @pytest.mark.parametrize("path", readable(), ids=lambda path: path.name) def test_every_step_of_a_supported_instruments_protocol_reads(path: Path): + """Every step of a supported instruments protocol reads.""" protocol = protocol_file.read(path) assert protocol.build_steps() or not protocol.device_entries diff --git a/pylabrobot/agilent/biotek/lhc/tests/definition_tests.py b/pylabrobot/agilent/biotek/lhc/tests/definition_tests.py index 84aad878387..0ac082c808f 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/definition_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/definition_tests.py @@ -38,12 +38,14 @@ def test_the_definition_says_which_step_it_is(): + """The definition says which step it is.""" step = step_from_definition("DV103|9|A|40|9|True|5|True|04:00") assert isinstance(step, ManifoldPrime) assert step.step_type is StepType.MANIFOLD_PRIME def test_every_field_lands_where_the_writer_puts_it(): + """Every field lands where the writer puts it.""" step = ManifoldPrime.from_definition("DV103|9|C|95|9|True|50|True|00:20") assert step == ManifoldPrime( buffer="C", @@ -79,11 +81,13 @@ def test_the_format_marker_is_optional_and_is_not_kept(text: str): def test_a_newer_marker_is_refused_whole(): + """A newer marker is refused whole.""" with pytest.raises(ValueError, match="newer than DV103"): ManifoldPrime.from_definition("DV104|9|A|40|5|True|5|True|04:00") def test_fields_reports_where_the_step_type_sits(): + """Fields reports where the step type sits.""" assert definition.fields("DV103|9|A") == (["DV103", "9", "A"], 1) assert definition.fields("9|A") == (["9", "A"], 0) @@ -99,6 +103,7 @@ def test_fields_reports_where_the_step_type_sits(): def test_only_a_definition_older_than_the_current_format_may_be_short( found: list[str], older: bool ): + """Only a definition older than the current format may be short.""" assert definition.is_older_format(found) is older @@ -113,6 +118,7 @@ def test_a_sub_steps_own_type_field_is_not_read(): def test_every_step_type_reads_back_as_its_own_class(): + """Every step type reads back as its own class.""" for step_type, cls in STEP_CLASSES.items(): text = cls().to_definition() assert type(step_from_definition(text)) is cls, step_type.name @@ -210,6 +216,7 @@ def test_a_short_definition_of_the_current_format_is_another_products(): def test_a_definition_at_full_length_is_untouched(): + """A definition at full length is untouched.""" full = "DV103|5|1|5000|5|5|0|True|False|00:05|1" assert SyringePrime.from_definition(full).to_definition() == full diff --git a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py index 752dd10f56c..d76f3f6d2b4 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py @@ -16,12 +16,26 @@ from pylabrobot.agilent.biotek.lhc.devices.components.syringe_dispenser import SyringeDispenser from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings +from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import ( + StripWasherManifold, +) from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol, ProtocolEntry +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_wash import ManifoldWash +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_dispense import PeriDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_aspirate import PeriWashAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_wash_dispense import PeriWashDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_aspirate import StripAspirate +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_dispense import StripDispense +from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber from pylabrobot.agilent.biotek.lhc.tests.helpers import ( ACCEPTS_EVERY_PLATE, @@ -52,6 +66,27 @@ } """What the fake instrument answers, which is a fully equipped instrument of the original model.""" +PUMP_READY = {**ANSWERS, CommandNumber.GET_SELECTED_PERI_STATE: bytes([2])} +"""The same instrument, with its peristaltic pumps in a state to turn, which is what opening a +batch for a step that drives one checks before letting it run.""" + +PERI_WASHING = { + **PUMP_READY, + CommandNumber.GET_WHICH_BASECODE_IS_INSTALLED: bytes([int(Basecode.PERI_WASH)]), +} +"""An instrument running the firmware that carries the peristaltic wash step types. Which +variant is installed is only asked of the newest model, so this is only worth answering for +one.""" + +STRIP_WASHING = { + **PUMP_READY, + CommandNumber.IS_STRIP_WASHER_BOX_CONNECTED: bytes([1]), + CommandNumber.GET_STRIP_WASHER_HW_INSTALLED: bytes([1]), + CommandNumber.GET_STRIP_WASHER_MANIFOLD_TYPE: bytes([int(StripWasherManifold.PLATE_96_WELL)]), +} +"""An instrument with a strip washer fitted, carrying the manifold that works 96-well and +384-well plates.""" + class DeviceTestCase(unittest.IsolatedAsyncioTestCase): """A model built onto a fake instrument.""" @@ -80,24 +115,29 @@ class TestTheLifecycle(DeviceTestCase): """Opening a device, and what it learns while doing so.""" async def test_setup_reads_what_is_fitted(self): + """Setup reads what is fitted.""" device, _ = await self.build(EL406) self.assertIs(device.settings.family, InstrumentFamily.EL406) self.assertTrue(device.settings.peri_pump) async def test_setup_proves_something_is_listening_before_reading_anything(self): + """Setup proves something is listening before reading anything.""" _, io = await self.build(EL406) self.assertEqual(io.sent[0], int(CommandNumber.PING)) async def test_a_device_reports_its_own_name(self): + """A device reports its own name.""" device, _ = await self.build(EL406) self.assertEqual(device.name, "EL406") async def test_stopping_closes_the_link(self): + """Stopping closes the link.""" device, io = await self.build(EL406) await device.stop() self.assertFalse(io.is_open) async def test_the_serial_number_and_the_firmware_can_be_read(self): + """The serial number and the firmware can be read.""" device, _ = await self.build(EL406) self.assertEqual(await device.get_serial_number(), "SN0001") self.assertEqual((await device.get_firmware_version()).software_version.strip(), "2.22.6") @@ -107,21 +147,25 @@ class TestThePlate(DeviceTestCase): """Telling a device what is on its carrier.""" async def test_a_plate_is_resolved_to_a_format_the_model_works(self): + """A plate is resolved to a format the model works.""" device, _ = await self.build(EL406) self.assertIsNotNone(device.plate) self.assertIs(device.plate.plate_type, PlateType.PLATE_96_WELL) async def test_a_format_can_be_named_outright(self): + """A format can be named outright.""" device, _ = await self.build(EL406) device.set_plate(make_plate(384), plate_type=PlateType.PLATE_384_WELL_PCR) self.assertIs(device.plate.plate_type, PlateType.PLATE_384_WELL_PCR) async def test_a_plate_the_model_does_not_work_is_an_error_naming_what_it_does(self): + """A plate the model does not work is an error naming what it does.""" device, _ = await self.build(Washer405TS) with self.assertRaisesRegex(ValueError, "it offers"): device.set_plate(make_plate(6)) async def test_forgetting_the_plate_stops_everything(self): + """Forgetting the plate stops everything.""" device, _ = await self.build(EL406) device.clear_plate() self.assertIsNone(device.plate) @@ -133,6 +177,7 @@ class TestThePalette(DeviceTestCase): """What a model offers, which is its own list narrowed by what is fitted.""" async def test_a_wash_only_model_offers_no_dispensing(self): + """A wash only model offers no dispensing.""" device, _ = await self.build(Washer405TS) offered = device.get_available_steps() self.assertIn(StepType.MANIFOLD_WASH, offered) @@ -140,6 +185,7 @@ async def test_a_wash_only_model_offers_no_dispensing(self): self.assertNotIn(StepType.PERI_DISPENSE, offered) async def test_a_dispenser_only_model_offers_no_washing(self): + """A dispenser only model offers no washing.""" device, _ = await self.build(MultiFlo) offered = device.get_available_steps() self.assertIn(StepType.PERI_DISPENSE, offered) @@ -167,12 +213,14 @@ class TestTheCapabilityObjects(DeviceTestCase): """Which capability objects each model exposes, and what calling one does.""" async def test_a_wash_only_model_exposes_only_a_washer(self): + """A wash only model exposes only a washer.""" device, _ = await self.build(Washer405TS) self.assertIsInstance(device.washer, PlateWasher) with self.assertRaises(AttributeError): device.syringe_dispenser # noqa: B018 - the point is that it is not there async def test_a_dispenser_only_model_exposes_no_washer(self): + """A dispenser only model exposes no washer.""" device, _ = await self.build(MultiFlo) self.assertIsInstance(device.syringe_dispenser, SyringeDispenser) self.assertIsInstance(device.peristaltic_dispenser, PeristalticDispenser) @@ -180,12 +228,14 @@ async def test_a_dispenser_only_model_exposes_no_washer(self): device.washer # noqa: B018 - the point is that it is not there async def test_the_newest_model_exposes_all_three(self): + """The newest model exposes all three.""" device, _ = await self.build(MultiFloFX) self.assertIsInstance(device.washer, PlateWasher) self.assertIsInstance(device.syringe_dispenser, SyringeDispenser) self.assertIsInstance(device.peristaltic_dispenser, PeristalticDispenser) async def test_one_call_brackets_itself_in_a_batch(self): + """One call brackets itself in a batch.""" device, io = await self.build(EL406) await device.washer.prime(volume=40_000) self.assertEqual(io.sent.count(int(CommandNumber.INIT_PROTOCOL)), 1) @@ -193,6 +243,7 @@ async def test_one_call_brackets_itself_in_a_batch(self): self.assertIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) async def test_several_calls_in_one_batch_share_the_opening(self): + """Several calls in one batch share the opening.""" device, io = await self.build(EL406) async with device.batch(): await device.washer.prime(volume=40_000) @@ -201,6 +252,7 @@ async def test_several_calls_in_one_batch_share_the_opening(self): self.assertEqual(io.sent.count(int(CommandNumber.EXIT_PROTOCOL)), 1) async def test_a_capability_method_sends_the_step_it_names(self): + """A capability method sends the step it names.""" device, io = await self.build(EL406) await device.washer.dispense(volume=100, buffer="B", flow_rate=5) self.assertIn(int(CommandNumber.MANIFOLD_DISPENSE), io.sent) @@ -210,12 +262,14 @@ class TestRunningAProtocol(DeviceTestCase): """Handing a device a whole protocol rather than one operation.""" async def test_a_list_of_steps_runs_in_one_batch(self): + """A list of steps runs in one batch.""" device, io = await self.build(EL406) await device.run_protocol([ManifoldPrime(volume=40_000), ManifoldPrime(volume=10_000)]) self.assertEqual(io.sent.count(int(CommandNumber.INIT_PROTOCOL)), 1) self.assertEqual(io.sent.count(int(CommandNumber.MANIFOLD_PRIME)), 2) async def test_a_protocol_object_has_its_steps_built_from_its_entries(self): + """A protocol object has its steps built from its entries.""" device, io = await self.build(EL406) protocol = Protocol( entries=[ @@ -230,10 +284,12 @@ async def test_a_protocol_object_has_its_steps_built_from_its_entries(self): self.assertIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) async def test_the_check_can_be_asked_for_on_its_own(self): + """The check can be asked for on its own.""" device, _ = await self.build(EL406) self.assertTrue(await device.can_run([ManifoldPrime(volume=40_000)])) async def test_a_step_built_outright_can_be_run(self): + """A step built outright can be run.""" device, io = await self.build(EL406) await device.run_step(ManifoldPrime(volume=40_000)) self.assertIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) @@ -243,21 +299,25 @@ class TestTheServiceSurface(DeviceTestCase): """The operations that are about the instrument rather than about liquid.""" async def test_resetting_sends_its_own_command(self): + """Resetting sends its own command.""" device, io = await self.build(EL406) await device.reset() self.assertIn(int(CommandNumber.RESET_INSTRUMENT), io.sent) async def test_homing_everything_is_the_default(self): + """Homing everything is the default.""" device, io = await self.build(EL406) await device.home() self.assertIn(int(CommandNumber.HOME_VERIFY_MOTORS), io.sent) async def test_the_self_check_sends_its_own_command(self): + """The self check sends its own command.""" device, io = await self.build(EL406) await device.self_check() self.assertIn(int(CommandNumber.RUN_SELF_CHECK), io.sent) async def test_shaking_runs_as_a_step(self): + """Shaking runs as a step.""" device, io = await self.build(EL406) await device.shake(duration=5, soak_duration=30) self.assertIn(int(CommandNumber.SHAKE_SOAK), io.sent) @@ -267,10 +327,12 @@ class TestComparingWhatAProtocolExpects(DeviceTestCase): """The one use a protocol's declared options are put to, and only when asked.""" async def test_a_settings_record_can_be_compared_directly(self): + """A settings record can be compared directly.""" device, _ = await self.build(EL406) self.assertTrue(device.compare_settings(device.settings)) async def test_a_protocol_without_a_document_is_an_explicit_error(self): + """A protocol without a document is an explicit error.""" device, _ = await self.build(EL406) with self.assertRaisesRegex(ValueError, "no fitted-options document"): device.compare_settings(Protocol(protocol_name="rinse")) @@ -283,3 +345,246 @@ async def test_a_protocol_written_for_another_instrument_still_runs(self): self.assertFalse(device.compare_settings(declared)) await device.run_protocol([ManifoldPrime(volume=40_000)]) self.assertIn(int(CommandNumber.MANIFOLD_PRIME), io.sent) + + +class TestTheHeightADefaultedCallWorksAt(DeviceTestCase): + """Where a capability method puts the head when the caller names no position. + + A step carries the height it works at outright, as an absolute head position rather than an + offset from the labware, and nothing downstream measures it against the plate: a step is checked + against the instrument's travel, not against how deep the well is. One fixed default is therefore + only ever right for one format -- on a shallower plate the same number is a head driven further + down than the well is deep. A defaulted call takes the height from the plate on the carrier + instead, and these hold every capability method that positions a head to that. + + Which model and which plate each test uses is decided by what will run: a manifold step is not + offered on a 1536-well plate, the peristaltic wash step types need the firmware that carries + them, and the strip step types need a strip washer fitted. + """ + + NOMINALS = { + 96: {"dispenser": 336, "manifold dispense": 121, "manifold aspirate": 29}, + 384: {"dispenser": 333, "manifold dispense": 120, "manifold aspirate": 22}, + 1536: {"dispenser": 250, "manifold dispense": 94, "manifold aspirate": 42}, + } + """The nominal height of each head over each plate on the original model, pinned so a change to + the plate table shows up here as well as in the payloads it moves.""" + + FX_NOMINALS = { + 96: {"dispenser": 336, "manifold dispense": 336, "manifold aspirate": 84}, + 384: {"dispenser": 333, "manifold dispense": 333, "manifold aspirate": 64}, + } + """The same for the newest model, which keeps a plate table of its own: it dispenses through the + wash manifold at the dispensing height rather than a height of its own, and holds the manifold + markedly higher to aspirate.""" + + def assertSent(self, device, io, command: CommandNumber, expected): + """Assert the step sent for a command is the one given. + + Comparing whole payloads rather than the height's own bytes saves the assertion from having to + know where in the payload the field sits, and catches a height that landed in the field of a + different step. What goes out is the plate the step is run on followed by the step itself. + + Args: + device: The device that sent it, which is what the step is encoded against. + io: The fake instrument it was sent to. + command: Which command to look for. + expected: The step that should have been sent. + """ + on_plate = bytes([int(device.plate.plate_type)]) + self.assertEqual( + io.payload_of(command).hex(), (on_plate + expected.to_bytes(device.settings)).hex() + ) + + async def test_the_plates_this_uses_are_the_formats_it_names(self): + """The plates this uses are the formats it names. + + Every test below reads heights off the plate the device resolved, so labware that resolved to a + different format than intended would make all of them pass while proving nothing. + """ + types = { + 96: PlateType.PLATE_96_WELL, + 384: PlateType.PLATE_384_WELL, + 1536: PlateType.PLATE_1536_WELL, + } + for cls, nominals in ((EL406, self.NOMINALS), (MultiFloFX, self.FX_NOMINALS)): + for wells, heights in nominals.items(): + with self.subTest(model=cls.__name__, wells=wells): + device, _ = await self.build(cls, wells=wells) + self.assertIs(device.plate.plate_type, types[wells]) + for head, height in heights.items(): + self.assertEqual(device.plate.height_for(head), height) + + async def test_a_defaulted_manifold_dispense_works_at_the_plates_dispensing_height(self): + """A defaulted manifold dispense works at the plate's dispensing height.""" + for wells in (96, 384): + with self.subTest(wells=wells): + device, io = await self.build(EL406, wells=wells) + await device.washer.dispense(volume=100) + self.assertSent( + device, + io, + CommandNumber.MANIFOLD_DISPENSE, + ManifoldDispense( + volume=100, + positioning=Positioning(z_steps=self.NOMINALS[wells]["manifold dispense"]), + ), + ) + + async def test_a_defaulted_manifold_aspirate_works_at_the_plates_aspirating_height(self): + """A defaulted manifold aspirate works at the plate's aspirating height.""" + for wells in (96, 384): + with self.subTest(wells=wells): + device, io = await self.build(EL406, wells=wells) + await device.washer.aspirate() + self.assertSent( + device, + io, + CommandNumber.MANIFOLD_ASPIRATE, + ManifoldAspirate( + positioning=Positioning(z_steps=self.NOMINALS[wells]["manifold aspirate"]) + ), + ) + + async def test_a_defaulted_syringe_dispense_works_at_the_plates_dispensing_height(self): + """A defaulted syringe dispense works at the plate's dispensing height.""" + for wells in (96, 384): + with self.subTest(wells=wells): + device, io = await self.build(EL406, wells=wells) + await device.syringe_dispenser.dispense(volume=100) + self.assertSent( + device, + io, + CommandNumber.SYRINGE_DISPENSE, + SyringeDispense( + volume=100, positioning=Positioning(z_steps=self.NOMINALS[wells]["dispenser"]) + ), + ) + + async def test_a_defaulted_peristaltic_dispense_works_at_the_plates_dispensing_height(self): + """A defaulted peristaltic dispense works at the plate's dispensing height. + + The peristaltic dispenser is offered on every plate the instrument works, which makes this the + one capability method the whole range of heights can be seen through: 336 steps over a 96-well + plate down to 250 over a 1536-well one. + """ + for wells in (96, 384, 1536): + with self.subTest(wells=wells): + device, io = await self.build(EL406, wells=wells, answers=PUMP_READY) + await device.peristaltic_dispenser.dispense(volume=10) + self.assertSent( + device, + io, + CommandNumber.PERI_DISPENSE, + PeriDispense( + volume=10, positioning=Positioning(z_steps=self.NOMINALS[wells]["dispenser"]) + ), + ) + + async def test_a_defaulted_peristaltic_wash_works_at_the_plates_aspirating_height(self): + """A defaulted peristaltic wash works at the plate's aspirating height. + + Both halves of a peristaltic wash work at the aspirating height, the dispense included, which + is the instrument's own pairing rather than this package's. + """ + for wells in (96, 384): + with self.subTest(wells=wells): + device, io = await self.build(MultiFloFX, wells=wells, answers=PERI_WASHING) + aspirating = Positioning(z_steps=self.FX_NOMINALS[wells]["manifold aspirate"]) + await device.peristaltic_dispenser.wash_aspirate() + await device.peristaltic_dispenser.wash_dispense(volume=100) + self.assertSent( + device, io, CommandNumber.PERI_WASH_ASPIRATE, PeriWashAspirate(positioning=aspirating) + ) + self.assertSent( + device, + io, + CommandNumber.PERI_WASH_DISPENSE, + PeriWashDispense(volume=100, positioning=aspirating), + ) + + async def test_a_defaulted_strip_step_works_at_the_plates_heights(self): + """A defaulted strip step works at the plate's heights. + + A strip dispense comes out of a dispenser and a strip aspirate through the wash manifold, so + the two take their heights from different rows of the plate's record. + """ + for wells in (96, 384): + with self.subTest(wells=wells): + device, io = await self.build(MultiFloFX, wells=wells, answers=STRIP_WASHING) + await device.washer.strip_dispense(volume=100) + await device.washer.strip_aspirate() + self.assertSent( + device, + io, + CommandNumber.STRIP_DISPENSE, + StripDispense( + volume=100, positioning=Positioning(z_steps=self.FX_NOMINALS[wells]["dispenser"]) + ), + ) + self.assertSent( + device, + io, + CommandNumber.STRIP_ASPIRATE, + StripAspirate( + positioning=Positioning(z_steps=self.FX_NOMINALS[wells]["manifold aspirate"]) + ), + ) + + async def test_the_steps_a_defaulted_wash_owns_are_all_at_the_plates_heights(self): + """The steps a defaulted wash owns are all at the plate's heights. + + A wash sends four steps of its own inside one payload, each with a height of its own, which is + where a height put into a neighbouring field would do the most damage. The plate here is the + 96-well one because its heights are the two that differ from what the step classes default to, + so a sub-step left at its own default fails rather than passing by coincidence. + """ + device, io = await self.build(EL406, wells=96) + aspirating = Positioning(z_steps=self.NOMINALS[96]["manifold aspirate"]) + dispensing = Positioning(z_steps=self.NOMINALS[96]["manifold dispense"]) + # The wash's own dispense needs a volume: the step type defaults to zero, which is below what + # the instrument will dispense, so a wash that names no volume is refused before it is sent. + await device.washer.wash( + cycles=2, dispense=ManifoldDispense(volume=100, positioning=dispensing) + ) + self.assertSent( + device, + io, + CommandNumber.MANIFOLD_WASH, + ManifoldWash( + cycles=2, + bottom_wash=ManifoldDispense(positioning=dispensing), + aspirate=ManifoldAspirate(in_wash=True, positioning=aspirating), + dispense=ManifoldDispense(volume=100, positioning=dispensing), + final_aspirate=ManifoldAspirate(in_wash=True, positioning=aspirating), + ), + ) + + async def test_a_named_position_is_left_alone(self): + """A named position is left alone. + + The plate is only where a default comes from. A caller who names a height means it, including + one the plate would not have chosen. + """ + device, io = await self.build(EL406, wells=384) + named = Positioning(z_steps=60, x_steps=-3, y_steps=4) + await device.washer.dispense(volume=100, positioning=named) + self.assertSent( + device, io, CommandNumber.MANIFOLD_DISPENSE, ManifoldDispense(volume=100, positioning=named) + ) + + async def test_a_step_built_outright_keeps_the_height_its_class_defaults_to(self): + """A step built outright keeps the height its class defaults to. + + Only the capability methods reach the plate. A step constructed by hand and handed to + :meth:`run_step` carries the height its own class defaults to, which is the nominal for a + 384-well plate -- the shallowest of the formats every model works. + """ + device, io = await self.build(EL406, wells=96) + await device.run_step(ManifoldDispense(volume=100)) + self.assertSent( + device, + io, + CommandNumber.MANIFOLD_DISPENSE, + ManifoldDispense(volume=100, positioning=Positioning(z_steps=120)), + ) diff --git a/pylabrobot/agilent/biotek/lhc/tests/error_handling_tests.py b/pylabrobot/agilent/biotek/lhc/tests/error_handling_tests.py index 0b7e6b4bc57..b641aa26ea0 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/error_handling_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/error_handling_tests.py @@ -44,13 +44,16 @@ def test_every_code_classifies(self): assert isinstance(classify(code), ErrorKind) def test_every_code_has_a_message(self): + """Every code has a message.""" for code in range(0, 0x10000, 101): assert isinstance(error_message(code), str) def test_a_code_with_no_row_has_no_message_rather_than_a_made_up_one(self): + """A code with no row has no message rather than a made up one.""" assert error_message(0x0001) == "" def test_success_is_not_an_error(self): + """Success is not an error.""" assert classify(NO_ERROR) is ErrorKind.NONE raise_for_status(NO_ERROR) @@ -78,9 +81,11 @@ class TestWhichExceptionACodeBecomes: """The class is the caller's decision, so the mapping is what matters.""" def test_success_raises_nothing(self): + """Success raises nothing.""" raise_for_status(0) def test_a_refused_request_is_its_own_kind(self): + """A refused request is its own kind.""" with pytest.raises(RejectedError): raise_for_status(0x6029) @@ -92,6 +97,7 @@ def test_a_transport_failure_is_a_link_failure(self, code: int): raise_for_status(code) def test_every_exception_is_one_of_ours(self): + """Every exception is one of ours.""" for code in range(0x6000, 0x6100, 3): try: raise_for_status(code) @@ -100,6 +106,7 @@ def test_every_exception_is_one_of_ours(self): assert error.code == normalize(code) def test_a_failure_this_package_found_itself_carries_no_code(self): + """A failure this package found itself carries no code.""" error = fail(ErrorKind.LINK, "the port went away", operation="write") assert error.code == NO_ERROR assert "the port went away" in str(error) @@ -110,11 +117,13 @@ class TestWhatAFailureSays: """The text a caller sees, which has to name what was being attempted.""" def test_a_failure_names_the_operation_and_the_code(self): + """A failure names the operation and the code.""" info = info_for(0x6029, InstrumentFamily.EL406, operation="opening the batch") assert "opening the batch" in str(info) assert "0x6029" in str(info) def test_a_description_carries_the_code_and_its_message(self): + """A description carries the code and its message.""" assert "6029" in describe(0x6029) or "24617" in describe(0x6029) def test_the_family_changes_what_a_motor_code_means(self): diff --git a/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py b/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py index 094c3431284..a661b0e0f7d 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py @@ -63,7 +63,7 @@ async def opened( family=FAMILY, busy_after_step=busy_after_step, ) - state = Runtime(link=link, family=FAMILY, rules=rules_for(FAMILY), settle=0) + state = Runtime(link=link, rules=rules_for(FAMILY), settle=0) if with_plate: state.plate = resolve(make_plate(96), FAMILY) await link.setup() @@ -86,6 +86,7 @@ class TestRunningAStep(ExecutionTestCase): """Sending one step and waiting for it.""" async def test_a_step_is_sent_with_the_plate_in_front_of_it(self): + """A step is sent with the plate in front of it.""" state, io = await self.opened() async with batch(state): await execution.run_step(state, ManifoldPrime(volume=40_000)) @@ -94,6 +95,7 @@ async def test_a_step_is_sent_with_the_plate_in_front_of_it(self): self.assertEqual(payload[1:], ManifoldPrime(volume=40_000).to_bytes(state.settings)) async def test_a_step_is_polled_until_it_stops_being_busy(self): + """A step is polled until it stops being busy.""" state, io = await self.opened(busy_polls=2) async with batch(state): await execution.run_step(state, ManifoldPrime(), interval=0) @@ -124,11 +126,13 @@ async def test_a_status_the_instrument_reports_becomes_an_exception(self): await execution.status(state) async def test_nothing_runs_without_a_plate(self): + """Nothing runs without a plate.""" state, _ = await self.opened(with_plate=False) with self.assertRaisesRegex(RejectedError, "no plate"): await execution.run_step(state, ManifoldPrime()) async def test_the_status_reports_what_the_instrument_is_doing(self): + """The status reports what the instrument is doing.""" state, _ = await self.opened() self.assertIs((await execution.status(state)).state, RunState.READY) @@ -137,6 +141,7 @@ class TestTheBatchBracket(ExecutionTestCase): """Opening and closing the batch a step has to run inside.""" async def test_a_batch_opens_and_closes_around_the_block(self): + """A batch opens and closes around the block.""" state, io = await self.opened() async with batch(state): self.assertTrue(state.in_batch) @@ -147,6 +152,7 @@ async def test_a_batch_opens_and_closes_around_the_block(self): ) async def test_a_batch_inside_a_batch_does_nothing(self): + """A batch inside a batch does nothing.""" state, io = await self.opened() async with batch(state): async with batch(state): @@ -158,6 +164,7 @@ async def test_a_batch_inside_a_batch_does_nothing(self): self.assertEqual(self.count(io, CommandNumber.EXIT_PROTOCOL), 1) async def test_a_batch_closes_even_when_the_block_fails(self): + """A batch closes even when the block fails.""" state, io = await self.opened() with self.assertRaises(RuntimeError): async with batch(state): @@ -167,6 +174,7 @@ async def test_a_batch_closes_even_when_the_block_fails(self): self.assertFalse(state.port.locked()) async def test_the_instrument_is_released_when_the_batch_cannot_open(self): + """The instrument is released when the batch cannot open.""" state, _ = await self.opened(status=0x6029) with self.assertRaises(BiotekError): async with batch(state): @@ -175,6 +183,7 @@ async def test_the_instrument_is_released_when_the_batch_cannot_open(self): self.assertFalse(state.in_batch) async def test_homing_before_the_close_is_asked_for_not_assumed(self): + """Homing before the close is asked for not assumed.""" state, io = await self.opened() async with batch(state): pass @@ -193,6 +202,7 @@ class TestCheckingAProtocol(ExecutionTestCase): """The pass that decides whether a protocol may run, and what skipping it costs.""" async def test_a_protocol_is_checked_before_the_batch_opens(self): + """A protocol is checked before the batch opens.""" state, io = await self.opened() await execution.run_steps(state, [ManifoldPrime(volume=40_000)]) # What the instrument accepts is read as part of the check, so before the open. @@ -223,18 +233,21 @@ async def test_skipping_the_check_gives_up_what_the_check_reserved(self): self.assertFalse(state.reservations.uses_primary) async def test_the_check_reports_rather_than_raises(self): + """The check reports rather than raises.""" state, _ = await self.opened() report = await execution.can_run(state, [ManifoldPrime(volume=40_000)]) self.assertTrue(report) self.assertIn("can run", str(report)) async def test_what_the_instrument_accepts_is_read_once(self): + """What the instrument accepts is read once.""" state, io = await self.opened() await execution.can_run(state, [ManifoldPrime()]) await execution.can_run(state, [ManifoldPrime()]) self.assertEqual(self.count(io, CommandNumber.GET_PLATE_RESTRICTION), 1) async def test_a_new_plate_makes_the_next_check_ask_again(self): + """A new plate makes the next check ask again.""" state, io = await self.opened() await execution.can_run(state, [ManifoldPrime()]) state.forget_instrument_facts() @@ -246,16 +259,19 @@ class TestRunControl(ExecutionTestCase): """Stopping a running step, and letting it go on.""" async def test_abort_sends_its_own_command(self): + """Abort sends its own command.""" state, io = await self.opened() await execution.abort(state) self.assertEqual(io.sent, [int(CommandNumber.ABORT_STEP)]) async def test_pause_sends_its_own_command(self): + """Pause sends its own command.""" state, io = await self.opened() await execution.pause(state) self.assertEqual(io.sent, [int(CommandNumber.PAUSE_STEP)]) async def test_resume_sends_its_own_command(self): + """Resume sends its own command.""" state, io = await self.opened() await execution.resume(state) self.assertEqual(io.sent, [int(CommandNumber.RESUME_STEP)]) diff --git a/pylabrobot/agilent/biotek/lhc/tests/hardware_tests.py b/pylabrobot/agilent/biotek/lhc/tests/hardware_tests.py index 03e26667f5b..2be3fab323a 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/hardware_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/hardware_tests.py @@ -54,6 +54,7 @@ async def test_the_fitted_options_read_back(self): self.assertTrue(self.device.get_available_steps()) async def test_the_status_can_be_read_without_a_batch_open(self): + """The status can be read without a batch open.""" self.assertIsNotNone((await self.device.get_status()).state) async def test_a_batch_opens_and_closes(self): diff --git a/pylabrobot/agilent/biotek/lhc/tests/link_tests.py b/pylabrobot/agilent/biotek/lhc/tests/link_tests.py index dc4c02cb550..febd93e5141 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/link_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/link_tests.py @@ -67,10 +67,12 @@ def test_a_serial_port_is_passed_through_unexamined(self): assert transport_for("/dev/ttyS4").port == "/dev/ttyS4" def test_no_port_is_an_error(self): + """No port is an error.""" with pytest.raises(ValueError, match="no port"): transport_for("") def test_a_link_needs_either_a_port_or_a_transport(self): + """A link needs either a port or a transport.""" with pytest.raises(ValueError, match="either a port or a transport"): Link() @@ -79,15 +81,18 @@ class TestTheFrame: """The bytes around a payload, which every command shares.""" def test_the_checksum_covers_the_header_and_the_payload(self): + """The checksum covers the header and the payload.""" header = Header(number=115, payload_length=2) assert checksum(header.to_bytes(), b"\x01\x02") != checksum(header.to_bytes(), b"\x01\x03") def test_a_header_packs_and_unpacks(self): + """A header packs and unpacks.""" header = Header(number=141, payload_length=1, check=0x1234) assert len(header.to_bytes()) == HEADER_LENGTH assert Header.from_bytes(header.to_bytes()) == header def test_a_command_frames_itself_as_a_header_and_its_payload(self): + """A command frames itself as a header and its payload.""" command = Command(number=141, payload=b"\x04") framed = command.to_bytes() assert len(framed) == HEADER_LENGTH + 1 @@ -95,6 +100,7 @@ def test_a_command_frames_itself_as_a_header_and_its_payload(self): assert framed[HEADER_LENGTH:] == b"\x04" def test_a_reply_carries_its_status_before_its_answer(self): + """A reply carries its status before its answer.""" command = Command(number=256) assert command.parse_reply((0).to_bytes(2, "little") + b"abc") == (0, b"abc") @@ -108,11 +114,13 @@ class TestOneExchange(unittest.IsolatedAsyncioTestCase): """Sending a command over the link and reading what comes back.""" async def test_a_closed_link_refuses_to_send(self): + """A closed link refuses to send.""" link, _ = fake_link() with self.assertRaisesRegex(LinkError, "not open"): await link.request(Ping()) async def test_opening_twice_does_nothing(self): + """Opening twice does nothing.""" link, io = fake_link() await link.setup() await link.setup() @@ -120,6 +128,7 @@ async def test_opening_twice_does_nothing(self): self.assertTrue(io.is_open) async def test_closing_a_closed_link_does_nothing(self): + """Closing a closed link does nothing.""" link, _ = fake_link() await link.stop() self.assertFalse(link.is_open) @@ -132,12 +141,14 @@ async def test_the_header_and_the_payload_are_written_separately(self): self.assertEqual(io.payload_of(CommandNumber.INIT_PROTOCOL), b"\x04") async def test_an_answer_comes_back_with_the_status_split_off(self): + """An answer comes back with the status split off.""" link, _ = fake_link(answers={CommandNumber.GET_SERIAL_NUMBER: b"SN0001".ljust(24)}) await link.setup() command = GetSerialNumber() self.assertEqual(command.parse(await link.request(command)), "SN0001") async def test_a_status_the_instrument_reports_is_raised_as_what_failed(self): + """A status the instrument reports is raised as what failed.""" link, _ = fake_link(status=0x6029) await link.setup() with self.assertRaises(RejectedError): @@ -168,6 +179,8 @@ def _answer(self, header: Header, payload: bytes) -> None: await link.request(Ping()) async def test_an_instrument_that_never_acknowledges_times_out(self): + """An instrument that never acknowledges times out.""" + class Silent(FakeInstrument): """A fake instrument that takes a command and says nothing.""" @@ -187,6 +200,8 @@ def _answer(self, header: Header, payload: bytes) -> None: await link.request(Ping()) async def test_a_reply_that_stops_part_way_times_out(self): + """A reply that stops part way times out.""" + class Truncating(FakeInstrument): """A fake instrument whose reply stops after the acknowledgement.""" @@ -210,6 +225,8 @@ def _answer(self, header: Header, payload: bytes) -> None: await link.request(Command(number=int(CommandNumber.PING))) async def test_a_reply_that_does_not_add_up_is_refused(self): + """A reply that does not add up is refused.""" + class Corrupting(FakeInstrument): """A fake instrument whose replies do not match their own checksum.""" diff --git a/pylabrobot/agilent/biotek/lhc/tests/payload_bytes_tests.py b/pylabrobot/agilent/biotek/lhc/tests/payload_bytes_tests.py index 3ad3fa0f4b2..ca61458e981 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/payload_bytes_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/payload_bytes_tests.py @@ -76,6 +76,7 @@ def test_the_table_covers_every_step_type(): def test_the_table_has_no_repeated_definitions(): + """The table has no repeated definitions.""" definitions = [definition for _, definition, _ in ROWS] assert len(set(definitions)) == len(definitions) @@ -86,6 +87,7 @@ def test_the_table_has_no_repeated_definitions(): ids=[f"{kind}-{index}" for index, (kind, _, _) in enumerate(ROWS)], ) def test_a_step_sends_the_bytes_it_should(definition: str, expected: str): + """A step sends the bytes it should.""" assert step_from_definition(definition).to_bytes(SETTINGS).hex() == expected @@ -146,8 +148,8 @@ def aspirate(secondary_z: int) -> ManifoldAspirate: in_wash=True, travel_rate=cast(TravelRate, travel), delay=0, - positioning=Positioning(z=asp_z, x=asp_x, y=asp_y), - secondary=SecondaryAspirate(pattern="None", positioning=Positioning(z=secondary_z)), + positioning=Positioning(z_steps=asp_z, x_steps=asp_x, y_steps=asp_y), + secondary=SecondaryAspirate(pattern="None", positioning=Positioning(z_steps=secondary_z)), ) def dispense() -> ManifoldDispense: @@ -160,7 +162,7 @@ def dispense() -> ManifoldDispense: buffer="A", volume=volume, flow_rate=flow, - positioning=Positioning(z=disp_z), + positioning=Positioning(z_steps=disp_z), pre_dispense=PreDispense(enabled=False, volume=0, flow_rate=9), vacuum=VacuumDelay(enabled=False, volume=0), ) @@ -253,6 +255,7 @@ def dispense() -> ManifoldDispense: ids=[label for label, _, _ in WASHES], ) def test_a_wash_sends_the_bytes_it_should(arguments: dict[str, Any], expected: str): + """A wash sends the bytes it should.""" assert wash(**arguments).to_bytes(SETTINGS).hex() == expected diff --git a/pylabrobot/agilent/biotek/lhc/tests/plate_geometry_tests.py b/pylabrobot/agilent/biotek/lhc/tests/plate_geometry_tests.py index be6712c7540..4d8eaafb9ba 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/plate_geometry_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/plate_geometry_tests.py @@ -27,6 +27,7 @@ class TestTheOfferedPlates: @pytest.mark.parametrize("family", EVERY_FAMILY) def test_every_model_offers_something(self, family: InstrumentFamily): + """Every model offers something.""" offered = plates_for(family) assert offered assert len({record.plate_type for record in offered}) == len(offered) @@ -37,14 +38,17 @@ def test_a_dispenser_only_model_works_a_plate_at_one_height(self): assert record.dispenser_height == record.manifold_dispense_height def test_a_model_with_a_wash_manifold_measures_two_heights(self): + """A model with a wash manifold measures two heights.""" record = find(PlateType.PLATE_96_WELL, InstrumentFamily.EL406) assert record is not None assert record.dispenser_height != record.manifold_dispense_height def test_a_format_a_model_does_not_offer_is_not_found(self): + """A format a model does not offer is not found.""" assert find(PlateType.PLATE_6_WELL, InstrumentFamily.EL406) is None def test_a_record_knows_how_many_wells_it_has(self): + """A record knows how many wells it has.""" record = find(PlateType.PLATE_384_WELL, InstrumentFamily.EL406) assert record is not None assert record.wells == 384 @@ -63,6 +67,7 @@ class TestResolvingLabware: ], ) def test_columns_and_rows_decide_the_format(self, wells: int, plate_type: PlateType): + """Columns and rows decide the format.""" assert resolve(make_plate(wells), InstrumentFamily.EL406).plate_type is plate_type def test_well_depth_separates_a_deep_well_plate_from_a_standard_one(self): @@ -75,16 +80,19 @@ def test_well_depth_separates_a_deep_well_plate_from_a_standard_one(self): assert deep.plate_type is PlateType.PLATE_96_DEEP_WELL def test_a_format_can_be_named_instead_of_resolved(self): + """A format can be named instead of resolved.""" resolved = resolve( make_plate(384), InstrumentFamily.EL406, plate_type=PlateType.PLATE_384_WELL_PCR ) assert resolved.plate_type is PlateType.PLATE_384_WELL_PCR def test_a_named_format_the_model_does_not_offer_is_refused(self): + """A named format the model does not offer is refused.""" with pytest.raises(ValueError, match="does not offer"): resolve(make_plate(96), InstrumentFamily.EL406, plate_type=PlateType.PLATE_6_WELL) def test_labware_no_format_matches_is_refused_naming_what_is_offered(self): + """Labware no format matches is refused naming what is offered.""" with pytest.raises(ValueError, match="works no 3x2 plate"): resolve(make_plate(6), InstrumentFamily.EL406) diff --git a/pylabrobot/agilent/biotek/lhc/tests/protocol_file_tests.py b/pylabrobot/agilent/biotek/lhc/tests/protocol_file_tests.py index a69dd5b0883..bae0ab5431a 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/protocol_file_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/protocol_file_tests.py @@ -48,6 +48,7 @@ class TestReadingADocument: """What a protocol file's own records become.""" def test_the_protocol_carries_what_the_file_says_about_itself(self): + """The protocol carries what the file says about itself.""" protocol = protocol_file.from_xml(DOCUMENT) assert protocol.protocol_name == "RINSE" assert protocol.instrument_name == "EL406" @@ -56,12 +57,14 @@ def test_the_protocol_carries_what_the_file_says_about_itself(self): assert protocol.plate_type_number == 4 def test_every_entry_is_kept_including_the_ones_that_do_not_operate_the_instrument(self): + """Every entry is kept including the ones that do not operate the instrument.""" protocol = protocol_file.from_xml(DOCUMENT) assert len(protocol.entries) == 2 assert protocol.entries[0].action is StepAction.REMARK assert protocol.entries[1].action is StepAction.CUSTOM def test_only_the_entries_that_operate_the_instrument_are_steps(self): + """Only the entries that operate the instrument are steps.""" protocol = protocol_file.from_xml(DOCUMENT) assert len(protocol.device_entries) == 1 assert protocol.device_entries[0].step_type is StepType.MANIFOLD_PRIME @@ -82,11 +85,13 @@ def test_an_entry_that_will_not_read_names_itself(self): protocol.build_steps() def test_an_unknown_action_is_refused(self): + """An unknown action is refused.""" document = DOCUMENT.replace("eStepActionRemark", "eStepActionSomethingElse") with pytest.raises(ValueError, match="unknown step action"): protocol_file.from_xml(document) def test_text_that_is_not_a_protocol_is_refused(self): + """Text that is not a protocol is refused.""" with pytest.raises(ValueError, match="not a protocol document"): protocol_file.from_xml(" Date: Tue, 1 Sep 2026 11:25:21 +0200 Subject: [PATCH 08/19] added some docs and made minor fixes --- docs/_static/devices.json | 1 + .../agilent/405ts/hello-world.ipynb | 835 ++++++++++++++++++ docs/user_guide/agilent/index.md | 1 + .../agilent/biotek/lhc/devices/el406.py | 20 +- .../agilent/biotek/lhc/devices/multiflo.py | 18 +- .../agilent/biotek/lhc/devices/multiflo_fx.py | 18 +- .../biotek/lhc/devices/washer_405ts.py | 14 +- 7 files changed, 872 insertions(+), 35 deletions(-) create mode 100644 docs/user_guide/agilent/405ts/hello-world.ipynb diff --git a/docs/_static/devices.json b/docs/_static/devices.json index cd08c6eb55c..56fe1e62eab 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -58,6 +58,7 @@ "api": "pylabrobot.agilent.biotek.lhc.Washer405TS", "api_version": "v1", "code_slug": "agilent/biotek/lhc", + "doc_slug": "agilent/405ts/hello-world", "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.agilent.com/en/product/microplate-instrumentation/automated-liquid-dispensing-handling/automated-microplate-washers-dispensers/biotek-405-ts-microplate-washer-1623261" }, diff --git a/docs/user_guide/agilent/405ts/hello-world.ipynb b/docs/user_guide/agilent/405ts/hello-world.ipynb new file mode 100644 index 00000000000..a6452b46c7a --- /dev/null +++ b/docs/user_guide/agilent/405ts/hello-world.ipynb @@ -0,0 +1,835 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ma-00", + "metadata": {}, + "source": [ + "# Agilent BioTek 405 TS plate washer quickstart\n", + "\n", + "The 405 TS is a microplate washer. Everything it does goes through a single wash manifold: it\n", + "primes its fluid lines, dispenses buffer into wells, aspirates them empty, and washes a plate by\n", + "repeating the two. It carries no syringes and no peristaltic pumps, so it dispenses nothing of its\n", + "own beyond what the manifold delivers.\n", + "\n", + "This quickstart connects to the washer, reads what it is and what it has fitted, tells it which\n", + "plate is on its carrier, primes the lines, runs a small batch, explains where in the well a step\n", + "works, runs a protocol file, and disconnects.\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Communication | USB through an FTDI interface, or a serial port |\n", + "| Serial parameters | 38400 baud, 8 data bits, 2 stop bits, no parity, no flow control |\n", + "| Operations | Priming, dispensing, aspirating, washing, auto-clean, shake and soak |\n", + "| Plate formats | 6 to 1536 wells, resolved from the PyLabRobot plate resource |\n", + "| Buffer inlets | A, B, C, D |\n", + "| Manifold reach | Depth 1-255 motor steps, across the well -60 to 60, along the well -60 to 60 |\n", + "| Protocol files | `.LHC` protocol files are read, checked and run |\n", + "\n", + "```{warning}\n", + "This driver has not yet been checked against a real 405 TS. `setup()` says so in the log every time\n", + "it runs. Verify every protocol on labware and fluid you can afford to lose, keep a hand on the\n", + "power switch the first time the carrier moves, and please report what you find on the\n", + "[PyLabRobot forum](https://discuss.pylabrobot.org) so the warning can be removed.\n", + "```\n", + "\n", + "```{warning}\n", + "Follow the manufacturer's installation, fluid-handling and safety instructions. A prime and a\n", + "dispense both move fluid: the buffer bottle must be full and the waste bottle empty enough before\n", + "anything in this notebook runs.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-01", + "metadata": {}, + "source": [ + "```{device-card} biotek-405-ts\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-02", + "metadata": {}, + "source": [ + "## How it communicates\n", + "\n", + "PyLabRobot frames each command as an 11-byte header and a payload, writes it to the instrument,\n", + "reads back an acknowledgement and the reply, and turns a non-zero status into a typed exception.\n", + "Which of the two transports carries those bytes is decided by the port string alone, and nothing\n", + "above that point knows which one it got.\n", + "\n", + "Operations that move fluid do not answer when they are done. The driver sends them, then polls the\n", + "instrument's run state until it stops reporting a step in progress, which is why every method that\n", + "touches the instrument is awaited and can take as long as the physical operation does.\n", + "\n", + "Install PyLabRobot with the FTDI dependencies if the washer is on USB.\n", + "\n", + "Reading `.LHC` protocol files additionally needs `pycryptodome`, which is not a PyLabRobot\n", + "dependency: the file format is encrypted, and the cipher is not in the standard library. Leave it\n", + "out if you only build protocols in Python — `read()` raises a `RuntimeError` telling you to install\n", + "it if you later try to read a file without it." + ] + }, + { + "cell_type": "code", + "id": "co-03", + "metadata": {}, + "source": [ + "%pip install \"pylabrobot[ftdi]\" pycryptodome" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-04", + "metadata": {}, + "source": [ + "## Physical setup and finding the port\n", + "\n", + "Install, plumb and power the washer according to the manufacturer's instructions. Connect the\n", + "buffer bottles to the inlets the protocol names, connect the waste bottle, and connect the\n", + "instrument to the computer.\n", + "\n", + "Then find the port string:\n", + "\n", + "- **USB.** List the attached FTDI devices and use the reported serial number:\n", + "\n", + " ```bash\n", + " python -m pylibftdi.examples.list_devices\n", + " ```\n", + "\n", + " The port is then `ftdi:`, for example `ftdi:183193P`. The form the instrument's own\n", + " protocol files record, `USB 405 TS sn:183193P`, is accepted as well.\n", + "\n", + "- **Serial.** Pass the operating system's own name for the port: `/dev/ttyUSB0`, `/dev/ttyS4` or\n", + " `COM3`. Anything that is not a USB serial number is taken to be a serial port and passed through\n", + " unexamined, so no particular naming pattern is required.\n", + "\n", + "Keep the carrier and the area around it clear from here on. Nothing in this notebook moves the\n", + "carrier before the batch section, but a fault can home the motors at any time." + ] + }, + { + "cell_type": "markdown", + "id": "ma-05", + "metadata": {}, + "source": [ + "## Turn on logging\n", + "\n", + "The driver reports what it is doing through the standard library's `logging`, and says nothing\n", + "otherwise. Without this cell the untested-driver warning in the next section, and every step the\n", + "instrument runs, pass silently." + ] + }, + { + "cell_type": "code", + "id": "co-06", + "metadata": {}, + "source": [ + "import logging\n", + "\n", + "logging.basicConfig(level=logging.INFO, format=\"%(levelname)s %(name)s: %(message)s\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-07", + "metadata": {}, + "source": [ + "## Build the washer\n", + "\n", + "Constructing the object opens nothing and touches no hardware. It records which port to use, which\n", + "model this is, and what to call the instrument in logs and error messages.\n", + "\n", + "Replace the port with the one found above." + ] + }, + { + "cell_type": "code", + "id": "co-08", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc import Washer405TS\n", + "\n", + "device = Washer405TS(port=\"ftdi:YOUR_SERIAL\", name=\"405 TS\")\n", + "device" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-09", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` opens the link, asks whether anything is listening, and reads the options the instrument\n", + "has fitted. That read is not optional: every step is encoded against it and every check measures\n", + "against it, so a failure here stops the notebook rather than being carried past.\n", + "\n", + "It raises a `BiotekError` if the port will not open, if nothing answers on it, or if the fitted\n", + "options cannot be read." + ] + }, + { + "cell_type": "code", + "id": "co-10", + "metadata": {}, + "source": [ + "await device.setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-11", + "metadata": {}, + "source": [ + "## Ask what answered, and check it is a 405 TS\n", + "\n", + "**The model is declared, not discovered.** The instrument does not report which model it is, so it\n", + "is the class you constructed that decides how every step is encoded and which options are read.\n", + "Pointing `Washer405TS` at a different model does not raise: it connects, and then encodes steps for\n", + "the wrong machine.\n", + "\n", + "So checking is worth doing by hand, and there is a fact to check it against. The firmware version\n", + "record carries a part number, which says which instrument the installed firmware image is built\n", + "for. Read it once for an instrument you have identified physically, and assert it from then on.\n", + "\n", + "The serial number identifies the individual instrument; the part number identifies the model." + ] + }, + { + "cell_type": "code", + "id": "co-12", + "metadata": {}, + "source": [ + "print(\"serial number: \", await device.get_serial_number())\n", + "\n", + "version = await device.get_firmware_version()\n", + "print(\"part number: \", version.part_number)\n", + "print(\"firmware: \", version.software_version)\n", + "print(\"data version: \", version.data_version)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-13", + "metadata": {}, + "source": [ + "If the part number is not the one this instrument reported when you identified it physically,\n", + "stop here: close the link and construct the model that is actually attached. Note that\n", + "`device.settings.family` is not the check — it echoes what was declared, not what is attached." + ] + }, + { + "cell_type": "code", + "id": "co-14", + "metadata": {}, + "source": [ + "EXPECTED_PART_NUMBER = version.part_number # replace with the value you recorded for this washer\n", + "\n", + "if version.part_number != EXPECTED_PART_NUMBER:\n", + " await device.stop()\n", + " raise RuntimeError(\n", + " f\"{device.name} reports firmware {version.part_number}, \"\n", + " f\"expected {EXPECTED_PART_NUMBER}; this may not be a 405 TS\"\n", + " )" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-15", + "metadata": {}, + "source": [ + "## Read the configuration\n", + "\n", + "What `setup()` read is kept as a read-only record of what somebody fitted to this instrument. It is\n", + "read-only because the instrument is: of its whole command vocabulary, almost nothing about the\n", + "configuration can be written, so a record that could be edited would only mislead.\n", + "\n", + "It is worth looking at, because it decides what the checks below allow. The cell washing module\n", + "unlocks the slowest travel and flow rates; buffer switching decides which inlets can be named; the\n", + "Y axis decides whether the manifold can be offset along the well at all." + ] + }, + { + "cell_type": "code", + "id": "co-16", + "metadata": {}, + "source": [ + "settings = device.settings\n", + "\n", + "print(\"family: \", settings.family.name)\n", + "print(\"wash manifold: \", settings.washer_manifold.name)\n", + "print(\"valve box: \", settings.valve_box.name)\n", + "print(\"buffer switching: \", settings.buffer_switching)\n", + "print(\"vacuum filtration: \", settings.vacuum_filtration)\n", + "print(\"cell washing: \", settings.cell_washing)\n", + "print(\"ultrasonic: \", settings.ultrasonic)\n", + "print(\"Y axis: \", settings.y_axis_installed)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-17", + "metadata": {}, + "source": [ + "## Ask what it can run\n", + "\n", + "A model can be built to run a fixed set of operations, and a particular instrument runs the subset\n", + "its fitted hardware supports. This is that subset, and it is the answer to \"why was my step\n", + "refused\" before you have written the step." + ] + }, + { + "cell_type": "code", + "id": "co-18", + "metadata": {}, + "source": [ + "for step_type in device.get_available_steps():\n", + " print(step_type.name)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-19", + "metadata": {}, + "source": [ + "## Tell it which plate is on the carrier\n", + "\n", + "Nothing runs until a plate has been set. Every step carries the height it works at, measured from\n", + "the nominal heights of the format on the carrier, so without one there is nothing to measure from\n", + "and the driver raises `RejectedError` rather than guessing.\n", + "\n", + "The format is resolved from the PyLabRobot plate resource itself — its columns, its rows, and how\n", + "deep its wells are. Labware that does not land on exactly one of the formats this model works is an\n", + "error naming the candidates, never a nearest fit." + ] + }, + { + "cell_type": "code", + "id": "co-20", + "metadata": {}, + "source": [ + "from pylabrobot.resources import cor_96_wellplate_360uL_Fb\n", + "\n", + "plate = cor_96_wellplate_360uL_Fb(name=\"plate\")\n", + "device.set_plate(plate)\n", + "\n", + "print(device.plate)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-21", + "metadata": {}, + "source": [ + "Some formats are never resolved from a resource, because they share their column and row count\n", + "with an ordinary plate and differ in something the resource does not carry — a half-area well, a\n", + "flange, a tube, or that it is calibration labware. Name one of those outright, and use the same\n", + "argument when a plate is to be worked as something other than what it resolves to." + ] + }, + { + "cell_type": "code", + "id": "co-22", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType\n", + "\n", + "device.set_plate(plate, plate_type=PlateType.PLATE_96_HALF_WELL)\n", + "print(device.plate)\n", + "\n", + "device.set_plate(plate) # back to what it resolves to on its own" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-23", + "metadata": {}, + "source": [ + "## Check before running\n", + "\n", + "`can_run()` measures steps against the instrument as it is now — what is fitted, which plate is on\n", + "the carrier, and what the plate will accept — and touches nothing. It is what `run_protocol()` does\n", + "first, so calling it yourself is how you see a refusal without moving anything.\n", + "\n", + "The report is truthy when everything can run, and prints as the list of what cannot." + ] + }, + { + "cell_type": "code", + "id": "co-24", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime\n", + "\n", + "report = await device.can_run([ManifoldPrime(volume=10_000, buffer=\"A\", flow_rate=9)])\n", + "print(report)\n", + "print(\"can run:\", bool(report))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-25", + "metadata": {}, + "source": [ + "A step this washer has no hardware for is refused with the reason, rather than failing partway\n", + "through. A syringe dispense is the clearest case: there are no syringes on a 405 TS." + ] + }, + { + "cell_type": "code", + "id": "co-26", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense\n", + "\n", + "print(await device.can_run([SyringeDispense(volume=50)]))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-27", + "metadata": {}, + "source": [ + "## Prime the wash manifold\n", + "\n", + "Priming pumps buffer through the manifold until its lines are full and sends it to waste. Nothing\n", + "is dispensed into the plate, which makes it the safest operation to try first — but it does move\n", + "fluid, so check the buffer and waste bottles before running this cell.\n", + "\n", + "The volume is in microlitres; the instrument meters it at millilitre resolution. The default is\n", + "40 mL, which is a full prime of dry lines; 10 mL is enough to see the pump run.\n", + "\n", + "The call returns when the instrument reports the step finished, which takes as long as the pumping\n", + "does." + ] + }, + { + "cell_type": "code", + "id": "co-28", + "metadata": {}, + "source": [ + "await device.washer.prime(volume=10_000, buffer=\"A\", flow_rate=9)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-29", + "metadata": {}, + "source": [ + "## Run several operations in one batch\n", + "\n", + "Opening a batch homes the motors, takes the instrument so that nothing else can interleave a run on\n", + "it, and holds it until the block ends. Every operation opens one; doing it once around several\n", + "operations is what stops the motors being homed between each of them.\n", + "\n", + "The block below primes the lines and then dispenses 100 µL of buffer A into every well. **This one\n", + "dispenses into the plate**, so put a plate on the carrier that you are willing to fill.\n", + "\n", + "`home_on_close=True` drives the transport home before the batch closes. The instrument does not do\n", + "this by itself; ask for it when the next thing to touch the plate is a person.\n", + "\n", + "Nesting is allowed and does nothing: an operation called inside an open batch joins it rather than\n", + "opening a second one." + ] + }, + { + "cell_type": "code", + "id": "co-30", + "metadata": {}, + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.washer.prime(volume=10_000, buffer=\"A\")\n", + " await device.washer.dispense(volume=100, buffer=\"A\", flow_rate=7)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-31", + "metadata": {}, + "source": [ + "A wash is the two of them repeated, and is one step rather than a loop: the instrument runs the\n", + "cycles itself. This aspirates each well empty and refills it, three times." + ] + }, + { + "cell_type": "code", + "id": "co-32", + "metadata": {}, + "source": [ + "await device.washer.wash(cycles=3, dispense=None, aspirate=None)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-33", + "metadata": {}, + "source": [ + "## Where in the well a step works\n", + "\n", + "Every operation that reaches into the plate takes a `positioning`, and defaults it to the nominal\n", + "position for that head over the format on the carrier. Three numbers:\n", + "\n", + "- `z_steps` — how deep the manifold goes. **This is a height, not an offset**: it defaults to the\n", + " plate record's own nominal height for the head, and giving a larger number reaches further down\n", + " into the well. On a 405 TS it must be 1-255.\n", + "- `x_steps` — across the well, -60 to 60 for the wash manifold.\n", + "- `y_steps` — along the well, -60 to 60 on a 405 TS. Needs the Y axis to be fitted.\n", + "\n", + "The nominal heights come from the plate record, so they change with the plate and differ per head:\n", + "dispensing sits higher than aspirating, which has to reach the bottom of the well." + ] + }, + { + "cell_type": "code", + "id": "co-34", + "metadata": {}, + "source": [ + "print(\"nominal manifold dispense height:\", device.plate.manifold_dispense_height)\n", + "print(\"nominal manifold aspirate height:\", device.plate.manifold_aspirate_height)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-35", + "metadata": {}, + "source": [ + "To dispense a little higher than nominal — down the side of the well rather than into the middle\n", + "of it — build a `Positioning` from the nominal height rather than from a number you have written\n", + "down, so the same code stays right when the plate changes." + ] + }, + { + "cell_type": "code", + "id": "co-36", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n", + "\n", + "await device.washer.dispense(\n", + " volume=100,\n", + " buffer=\"A\",\n", + " positioning=Positioning(\n", + " z_steps=device.plate.manifold_dispense_height - 10, # 10 steps higher in the well\n", + " x_steps=20, # toward one side\n", + " y_steps=0,\n", + " ),\n", + ")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-37", + "metadata": {}, + "source": [ + "### Why motor steps and not millimetres\n", + "\n", + "PyLabRobot's convention is millimetres, and this is the one place the package deviates from it. The\n", + "field names say so outright — `z_steps`, not `z` — because the conversion is not a single number:\n", + "it differs per axis, per model, and per head, and only part of it is established.\n", + "\n", + "What is known: the across-the-plate axis is **0.04572 mm per motor step**, confirmed twice over\n", + "against a published maximum offset. The depth axis has at least two scales, chosen by a property\n", + "that follows the head, and which head takes which is not settled. The along-the-plate axis is not\n", + "established at all.\n", + "\n", + "Converting on the strength of that would put a manifold at the wrong depth on some head of some\n", + "model, so the package does not convert. If you need millimetres on your instrument, measure them:\n", + "drive a known offset on each axis and see where the manifold goes. A step is also what a protocol\n", + "file stores, which is what lets a protocol be read, checked and written with no instrument to\n", + "ask." + ] + }, + { + "cell_type": "markdown", + "id": "ma-38", + "metadata": {}, + "source": [ + "## Watch a step, and stop it\n", + "\n", + "`get_status()` reports what the instrument is doing, which timed phase a running step is in, and\n", + "how many seconds are left in it. It can be called at any time, including while a step is running.\n", + "\n", + "An operation does not return until its step has finished, so pausing or aborting means asking from\n", + "somewhere else while it runs. In a notebook that is a task." + ] + }, + { + "cell_type": "code", + "id": "co-39", + "metadata": {}, + "source": [ + "import asyncio\n", + "\n", + "running = asyncio.create_task(device.washer.prime(volume=40_000, buffer=\"A\"))\n", + "await asyncio.sleep(2)\n", + "\n", + "status = await device.get_status()\n", + "print(\"state: \", status.state.name)\n", + "print(\"activity: \", status.activity.name)\n", + "print(\"remaining:\", status.remaining, \"s\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-40", + "metadata": {}, + "source": [ + "Pause holds the step where it is; resume carries on from there." + ] + }, + { + "cell_type": "code", + "id": "co-41", + "metadata": {}, + "source": [ + "await device.pause()\n", + "await asyncio.sleep(2)\n", + "print((await device.get_status()).state.name)\n", + "\n", + "await device.resume()\n", + "await running" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-42", + "metadata": {}, + "source": [ + "Abort stops the running step instead. The operation that was waiting for it raises\n", + "`AbortedError`, which is a `BiotekError`, so a protocol run ends where it was stopped rather than\n", + "carrying on to the next step." + ] + }, + { + "cell_type": "code", + "id": "co-43", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.error_handling import AbortedError, BiotekError\n", + "\n", + "running = asyncio.create_task(device.washer.prime(volume=40_000, buffer=\"A\"))\n", + "await asyncio.sleep(2)\n", + "await device.abort()\n", + "\n", + "try:\n", + " await running\n", + "except AbortedError as error:\n", + " print(\"stopped:\", error)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-44", + "metadata": {}, + "source": [ + "## Run a protocol file\n", + "\n", + "A `.LHC` protocol file is read into a `Protocol`: what it will run, and everything the file records\n", + "alongside it. Reading needs `pycryptodome`, installed at the top of this notebook.\n", + "\n", + "Reading a file never fails on a step it cannot understand — the file's own records are kept as they\n", + "are, so a protocol from another model still reads, prints and writes. `build_steps()` is what turns\n", + "those records into steps, and it names the one that will not read." + ] + }, + { + "cell_type": "code", + "id": "co-45", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc import read\n", + "\n", + "protocol = read(\"path/to/your/protocol.LHC\")\n", + "\n", + "print(\"name: \", protocol.protocol_name)\n", + "print(\"written by:\", protocol.lhc_version)\n", + "print(\"written for:\", protocol.instrument_name)\n", + "print(\"plate: \", protocol.plate_type or protocol.plate_type_number)\n", + "print(\"entries: \", len(protocol.entries), \"of which\", len(protocol.device_entries), \"operate the instrument\")\n", + "\n", + "for index, step in enumerate(protocol.build_steps()):\n", + " print(f\" step {index}: {type(step).__name__}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-46", + "metadata": {}, + "source": [ + "### What the file says about its instrument\n", + "\n", + "A protocol file records the options the instrument had fitted when it was written. Nothing runs\n", + "against that record — steps are encoded against the instrument in front of you — and nothing writes\n", + "it to the instrument. It is good for exactly one question, worth asking about a file that came from\n", + "another machine: was this written for a differently equipped washer?\n", + "\n", + "`compare_settings()` is truthy when the two agree, and prints as the options that differ. It raises\n", + "`ValueError` for a file that carries no such record, which is how the oldest releases wrote one." + ] + }, + { + "cell_type": "code", + "id": "co-47", + "metadata": {}, + "source": [ + "comparison = device.compare_settings(protocol)\n", + "print(comparison)\n", + "print(\"same configuration:\", bool(comparison))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-48", + "metadata": {}, + "source": [ + "### Running it\n", + "\n", + "`run_protocol()` checks the protocol, opens one batch around the whole run, sends each step and\n", + "polls it to completion. The check is the same `can_run()` from above and happens automatically, so\n", + "a protocol that cannot run raises before anything moves.\n", + "\n", + "**This runs whatever the protocol does**, which for most washer protocols means filling and\n", + "emptying every well of the plate on the carrier. Read the steps printed above first.\n", + "\n", + "The entries that sequence a run rather than operate the instrument — delays, loops, remarks — are\n", + "not run; the device steps go in file order. They are still there on `protocol.entries` to\n", + "inspect." + ] + }, + { + "cell_type": "code", + "id": "co-49", + "metadata": {}, + "source": [ + "await device.run_protocol(protocol, home_on_close=True)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-50", + "metadata": {}, + "source": [ + "Steps built in Python run the same way. `run_protocol()` takes a list of steps as readily as a\n", + "protocol, and `run_step()` runs a single one." + ] + }, + { + "cell_type": "code", + "id": "co-51", + "metadata": {}, + "source": [ + "await device.run_protocol(\n", + " [\n", + " ManifoldPrime(volume=10_000, buffer=\"A\", flow_rate=9),\n", + " ManifoldPrime(volume=5_000, buffer=\"B\", flow_rate=9),\n", + " ],\n", + " home_on_close=True,\n", + ")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-52", + "metadata": {}, + "source": [ + "## Home the transport and disconnect\n", + "\n", + "Homing drives the transport to its home position and confirms it arrived. Do it before a person\n", + "reaches for the plate, unless the last batch already closed with `home_on_close=True`.\n", + "\n", + "`stop()` closes the link. It does nothing on an instrument that is already closed, so it is safe to\n", + "run this cell twice, and it is worth running from a `finally` in a script so that a failed run does\n", + "not leave the port open." + ] + }, + { + "cell_type": "code", + "id": "co-53", + "metadata": {}, + "source": [ + "await device.home()\n", + "await device.stop()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-54", + "metadata": {}, + "source": [ + "```{note}\n", + "The fluid left in the manifold after a run is the instrument's problem, not the driver's. Follow the\n", + "manufacturer's shutdown and maintenance procedure — `device.washer.auto_clean(...)` soaks the\n", + "manifold in cleaning fluid, and most maintenance routines ship as protocol files you can run with\n", + "`run_protocol()`.\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/agilent/index.md b/docs/user_guide/agilent/index.md index 31b65a9b774..5460ada353f 100644 --- a/docs/user_guide/agilent/index.md +++ b/docs/user_guide/agilent/index.md @@ -3,6 +3,7 @@ ```{toctree} :maxdepth: 1 +405ts/hello-world benchcel/hello-world vspin/index ``` diff --git a/pylabrobot/agilent/biotek/lhc/devices/el406.py b/pylabrobot/agilent/biotek/lhc/devices/el406.py index 286123fbf88..ef9b7d093d6 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/el406.py +++ b/pylabrobot/agilent/biotek/lhc/devices/el406.py @@ -82,16 +82,16 @@ class EL406: A single operation and a whole protocol take the same path -- checked, bracketed in a batch, polled to completion -- so: - ```python - device = EL406(port="/dev/ttyUSB0") - await device.setup() - device.set_plate(plate) - await device.washer.wash(cycles=3) - async with device.batch(): - await device.washer.prime() - await device.syringe_dispenser.dispense(volume=50) - await device.stop() - ``` + .. code-block:: python + + device = EL406(port="/dev/ttyUSB0") + await device.setup() + device.set_plate(plate) + await device.washer.wash(cycles=3) + async with device.batch(): + await device.washer.prime() + await device.syringe_dispenser.dispense(volume=50) + await device.stop() Args: port: The port the instrument is on. A string carrying a device serial number names a USB diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py index 780d0294003..7aa548df970 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py @@ -73,15 +73,15 @@ class MultiFlo: Two ways of dispensing, reached as :attr:`syringe_dispenser` and :attr:`peristaltic_dispenser`. There is no wash manifold, so washing a plate is not something this model does. - ```python - device = MultiFlo(port="/dev/ttyUSB0") - await device.setup() - device.set_plate(plate) - async with device.batch(): - await device.peristaltic_dispenser.prime(volume=300) - await device.peristaltic_dispenser.dispense(volume=50) - await device.stop() - ``` + .. code-block:: python + + device = MultiFlo(port="/dev/ttyUSB0") + await device.setup() + device.set_plate(plate) + async with device.batch(): + await device.peristaltic_dispenser.prime(volume=300) + await device.peristaltic_dispenser.dispense(volume=50) + await device.stop() Args: port: The port the instrument is on. A string carrying a device serial number names a USB diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py index 50f4ac5c76d..ca3fc130001 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py @@ -89,15 +89,15 @@ class MultiFloFX: Dispensing is reached as :attr:`syringe_dispenser` and :attr:`peristaltic_dispenser`, and gentle medium exchange through the peristaltic wash manifolds is on the latter. - ```python - device = MultiFloFX(port="ftdi:FT1ABCDE") - await device.setup() - device.set_plate(plate) - async with device.batch(): - await device.peristaltic_dispenser.wash_aspirate(volume=100) - await device.peristaltic_dispenser.wash_dispense(volume=100) - await device.stop() - ``` + .. code-block:: python + + device = MultiFloFX(port="ftdi:FT1ABCDE") + await device.setup() + device.set_plate(plate) + async with device.batch(): + await device.peristaltic_dispenser.wash_aspirate(volume=100) + await device.peristaltic_dispenser.wash_dispense(volume=100) + await device.stop() Args: port: The port the instrument is on. A string carrying a device serial number names a USB diff --git a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py index c6ab45355fa..a58c36d83ba 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py +++ b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py @@ -67,13 +67,13 @@ class Washer405TS: syringes and no peristaltic pumps, so it dispenses nothing of its own; a protocol written for a washer-dispenser has its dispensing steps refused by :meth:`can_run`. - ```python - device = Washer405TS(port="/dev/ttyUSB0") - await device.setup() - device.set_plate(plate) - await device.washer.wash(cycles=3) - await device.stop() - ``` + .. code-block:: python + + device = Washer405TS(port="/dev/ttyUSB0") + await device.setup() + device.set_plate(plate) + await device.washer.wash(cycles=3) + await device.stop() Args: port: The port the instrument is on. A string carrying a device serial number names a USB From 725e35095934d27ce9bb058204dd7072af270c38 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Tue, 1 Sep 2026 15:32:18 +0200 Subject: [PATCH 09/19] bugfix --- .../agilent/biotek/lhc/devices/el406.py | 10 +++- .../agilent/biotek/lhc/devices/multiflo.py | 10 +++- .../agilent/biotek/lhc/devices/multiflo_fx.py | 10 +++- .../agilent/biotek/lhc/devices/runtime.py | 26 ++++++++-- .../biotek/lhc/devices/washer_405ts.py | 10 +++- .../agilent/biotek/lhc/tests/device_tests.py | 49 +++++++++++++++++++ .../biotek/lhc/tests/execution_tests.py | 9 +++- 7 files changed, 111 insertions(+), 13 deletions(-) diff --git a/pylabrobot/agilent/biotek/lhc/devices/el406.py b/pylabrobot/agilent/biotek/lhc/devices/el406.py index ef9b7d093d6..8c9a05ee340 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/el406.py +++ b/pylabrobot/agilent/biotek/lhc/devices/el406.py @@ -133,7 +133,13 @@ def name(self) -> str: @property def settings(self) -> InstrumentSettings: - """What the instrument reported as fitted when :meth:`setup` last ran.""" + """What the instrument reported as fitted when :meth:`setup` last ran. + + Raises: + RejectedError: If :meth:`setup` has not run. There is no default to fall back on: a record + nobody read would describe some other machine, and answering from one is how a check comes + to allow hardware this instrument does not have. + """ return self._runtime.settings @property @@ -174,7 +180,7 @@ async def setup(self) -> None: link = self._runtime.link await link.setup() await link.request(Ping(), operation="ping") - self._runtime.settings = await settings_query.read_settings(link, self.family) + self._runtime.reported_settings = await settings_query.read_settings(link, self.family) self._runtime.forget_instrument_facts() logger.info("%s is ready: %s", self.name, self._runtime.settings) diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py index 7aa548df970..db6580bc939 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py @@ -121,7 +121,13 @@ def name(self) -> str: @property def settings(self) -> InstrumentSettings: - """What the instrument reported as fitted when :meth:`setup` last ran.""" + """What the instrument reported as fitted when :meth:`setup` last ran. + + Raises: + RejectedError: If :meth:`setup` has not run. There is no default to fall back on: a record + nobody read would describe some other machine, and answering from one is how a check comes + to allow hardware this instrument does not have. + """ return self._runtime.settings @property @@ -162,7 +168,7 @@ async def setup(self) -> None: link = self._runtime.link await link.setup() await link.request(Ping(), operation="ping") - self._runtime.settings = await settings_query.read_settings(link, self.family) + self._runtime.reported_settings = await settings_query.read_settings(link, self.family) self._runtime.forget_instrument_facts() logger.info("%s is ready: %s", self.name, self._runtime.settings) diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py index ca3fc130001..87871be4e33 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py @@ -140,7 +140,13 @@ def name(self) -> str: @property def settings(self) -> InstrumentSettings: - """What the instrument reported as fitted when :meth:`setup` last ran.""" + """What the instrument reported as fitted when :meth:`setup` last ran. + + Raises: + RejectedError: If :meth:`setup` has not run. There is no default to fall back on: a record + nobody read would describe some other machine, and answering from one is how a check comes + to allow hardware this instrument does not have. + """ return self._runtime.settings @property @@ -181,7 +187,7 @@ async def setup(self) -> None: link = self._runtime.link await link.setup() await link.request(Ping(), operation="ping") - self._runtime.settings = await settings_query.read_settings(link, self.family) + self._runtime.reported_settings = await settings_query.read_settings(link, self.family) self._runtime.forget_instrument_facts() logger.info("%s is ready: %s", self.name, self._runtime.settings) diff --git a/pylabrobot/agilent/biotek/lhc/devices/runtime.py b/pylabrobot/agilent/biotek/lhc/devices/runtime.py index 1b07c567f6c..ccc8cea4a86 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/runtime.py +++ b/pylabrobot/agilent/biotek/lhc/devices/runtime.py @@ -34,8 +34,9 @@ class Runtime: Attributes: link: The connection to the instrument, which carries which model this is. rules: Which validation rules this model's firmware runs. - settings: What the instrument has fitted. Read from the instrument by ``setup()``, and the - record every step is encoded against. + reported_settings: What the instrument reported fitted, or None until ``setup()`` has asked + it. Read it through :attr:`settings`, which refuses to hand over a record the instrument has + not given. reconciles_cassette_head: Whether opening a batch reconciles the peristaltic dispense head as well as the cassettes. Only one model carries a head that can be set. plate: The plate on the carrier, or None while none has been set. @@ -53,7 +54,7 @@ class Runtime: link: Link rules: BuildRules = COMMON - settings: InstrumentSettings = field(default_factory=InstrumentSettings) + reported_settings: InstrumentSettings | None = None reconciles_cassette_head: bool = False plate: PlateRecord | None = None reservations: Reservations = field(default_factory=Reservations) @@ -63,6 +64,25 @@ class Runtime: in_batch: bool = False port: asyncio.Lock = field(default_factory=asyncio.Lock) + @property + def settings(self) -> InstrumentSettings: + """What the instrument reported fitted. + + Raises: + RejectedError: If the instrument has not been read. Nothing can be encoded or checked without + that: a step is encoded against what is fitted, and a check measures it against the same + record, so answering from a default would describe some other machine. In particular it + would describe a fully equipped instrument of the first model, and so answer "yes, that can + run" for hardware this instrument does not have. + """ + if self.reported_settings is None: + raise fail( + ErrorKind.REJECTED, + "the instrument has not been read; call setup() before encoding or checking anything", + operation="settings", + ) + return self.reported_settings + @property def plate_record(self) -> PlateRecord: """The plate on the carrier. diff --git a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py index a58c36d83ba..0610f8d3ab4 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py +++ b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py @@ -111,7 +111,13 @@ def name(self) -> str: @property def settings(self) -> InstrumentSettings: - """What the instrument reported as fitted when :meth:`setup` last ran.""" + """What the instrument reported as fitted when :meth:`setup` last ran. + + Raises: + RejectedError: If :meth:`setup` has not run. There is no default to fall back on: a record + nobody read would describe some other machine, and answering from one is how a check comes + to allow hardware this instrument does not have. + """ return self._runtime.settings @property @@ -152,7 +158,7 @@ async def setup(self) -> None: link = self._runtime.link await link.setup() await link.request(Ping(), operation="ping") - self._runtime.settings = await settings_query.read_settings(link, self.family) + self._runtime.reported_settings = await settings_query.read_settings(link, self.family) self._runtime.forget_instrument_facts() logger.info("%s is ready: %s", self.name, self._runtime.settings) diff --git a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py index d76f3f6d2b4..aebc0fd017f 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py @@ -24,6 +24,7 @@ from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType +from pylabrobot.agilent.biotek.lhc.error_handling import RejectedError from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol, ProtocolEntry from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate @@ -588,3 +589,51 @@ async def test_a_step_built_outright_keeps_the_height_its_class_defaults_to(self CommandNumber.MANIFOLD_DISPENSE, ManifoldDispense(volume=100, positioning=Positioning(z_steps=120)), ) + + +class TestNothingIsAnsweredBeforeTheInstrumentIsRead(DeviceTestCase): + """What a device says about hardware it has not asked about yet. + + A record nobody read describes some other machine, and the one that used to stand in was a fully + equipped instrument of the first model -- so a 405 TS would report syringes it does not have and + a check would allow a step it cannot run. Every question about the fitted hardware is refused + until `setup()` has asked the instrument. + """ + + def unopened(self, cls): + """Build a device without setting it up. + + Args: + cls: Which model to build. + + Returns: + The device. + """ + return cls(port="fake", io=FakeInstrument(answers=ANSWERS)) + + def test_the_fitted_options_are_refused(self): + with self.assertRaises(RejectedError): + _ = self.unopened(Washer405TS).settings + + def test_what_it_can_run_is_refused(self): + with self.assertRaises(RejectedError): + self.unopened(Washer405TS).get_available_steps() + + async def test_a_check_is_refused_rather_than_answered_from_a_default(self): + """The dangerous one: a 405 TS has no syringes, and this used to report that it had.""" + device = self.unopened(Washer405TS) + device.set_plate(make_plate(96)) + with self.assertRaises(RejectedError): + await device.can_run([SyringeDispense(volume=50)]) + + async def test_the_same_check_answers_once_the_instrument_has_been_read(self): + device, _ = await self.build(Washer405TS) + self.assertFalse(await device.can_run([SyringeDispense(volume=50)])) + + async def test_what_it_can_run_follows_what_is_fitted(self): + """A pump that is not there takes its steps with it, which is what the default hid.""" + answers = {**ANSWERS, CommandNumber.GET_SELECTED_PERI_INSTALLED: bytes([0])} + device, _ = await self.build(MultiFlo, answers=answers) + available = device.get_available_steps() + self.assertNotIn(StepType.PERI_DISPENSE, available) + self.assertIn(StepType.SYRINGE_DISPENSE, available) diff --git a/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py b/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py index a661b0e0f7d..9582227d77f 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py @@ -63,7 +63,12 @@ async def opened( family=FAMILY, busy_after_step=busy_after_step, ) - state = Runtime(link=link, rules=rules_for(FAMILY), settle=0) + state = Runtime( + link=link, + rules=rules_for(FAMILY), + reported_settings=InstrumentSettings(family=FAMILY), + settle=0, + ) if with_plate: state.plate = resolve(make_plate(96), FAMILY) await link.setup() @@ -215,7 +220,7 @@ async def test_a_protocol_that_cannot_run_is_refused_before_anything_moves(self) """A syringe prime on an instrument with no syringe box cannot run, and the batch is never opened for it.""" state, io = await self.opened() - state.settings = InstrumentSettings( + state.reported_settings = InstrumentSettings( family=FAMILY, syringe_box=SyringeBoxType.NOT_INSTALLED, syringe_manifold=SyringeManifold.NOT_INSTALLED, From 50f010116247f7aae4b4cb66a57610885438117f Mon Sep 17 00:00:00 2001 From: StefanMa Date: Tue, 8 Sep 2026 15:27:10 +0200 Subject: [PATCH 10/19] added firmware version check --- .../agilent/biotek/lhc/devices/el406.py | 10 +- .../agilent/biotek/lhc/devices/handshake.py | 125 ++++++++++++++++++ .../agilent/biotek/lhc/devices/multiflo.py | 10 +- .../agilent/biotek/lhc/devices/multiflo_fx.py | 10 +- .../biotek/lhc/devices/washer_405ts.py | 10 +- .../biotek/lhc/error_handling/__init__.py | 4 + .../biotek/lhc/error_handling/errors.py | 4 + .../agilent/biotek/lhc/tests/device_tests.py | 80 ++++++++++- 8 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 pylabrobot/agilent/biotek/lhc/devices/handshake.py diff --git a/pylabrobot/agilent/biotek/lhc/devices/el406.py b/pylabrobot/agilent/biotek/lhc/devices/el406.py index 8c9a05ee340..cdcadee8a6d 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/el406.py +++ b/pylabrobot/agilent/biotek/lhc/devices/el406.py @@ -9,7 +9,12 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport from pylabrobot.agilent.biotek.lhc.devices import batch as batching -from pylabrobot.agilent.biotek.lhc.devices import execution, settings_document, settings_query +from pylabrobot.agilent.biotek.lhc.devices import ( + execution, + handshake, + settings_document, + settings_query, +) from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( PeristalticDispenser, @@ -46,7 +51,6 @@ FirmwareVersion, GetFirmwareVersion, GetSerialNumber, - Ping, ) from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import RunStatus from pylabrobot.resources import Plate @@ -179,7 +183,7 @@ async def setup(self) -> None: ) link = self._runtime.link await link.setup() - await link.request(Ping(), operation="ping") + await handshake.check_communications(link) self._runtime.reported_settings = await settings_query.read_settings(link, self.family) self._runtime.forget_instrument_facts() logger.info("%s is ready: %s", self.name, self._runtime.settings) diff --git a/pylabrobot/agilent/biotek/lhc/devices/handshake.py b/pylabrobot/agilent/biotek/lhc/devices/handshake.py new file mode 100644 index 00000000000..2624a4d29d3 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/devices/handshake.py @@ -0,0 +1,125 @@ +"""Proving what is on the line before anything is read off it. + +An answered liveness check says something is listening; it does not say what. The firmware version +record read straight after it says two more things -- whether the basecode installed is one built +for the model being driven, and whether the settings data behind it is as new as this package reads +-- so a wrong instrument, or firmware too old for what a protocol will ask of it, is found while +opening rather than part-way through a plate. +""" + +from __future__ import annotations + +import logging + +from pylabrobot.agilent.biotek.lhc.comm.link import Link +from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily +from pylabrobot.agilent.biotek.lhc.error_handling import ( + SETTINGS_DATA_TOO_OLD, + WRONG_BASECODE_PART_NUMBER, + ErrorKind, + fail, +) +from pylabrobot.agilent.biotek.lhc.serialization.commands.queries import ( + FirmwareVersion, + GetFirmwareVersion, + Ping, +) + +logger = logging.getLogger(__name__) + +BASECODE_PART_NUMBERS: dict[InstrumentFamily, str] = { + InstrumentFamily.EL406: "718", + InstrumentFamily.MULTIFLO: "721", + InstrumentFamily.MODEL_405_TS: "117", + InstrumentFamily.MULTIFLO_FX: "126", +} +"""What a basecode part number begins with, per family. + +The part number is seven characters and its first three say which family the firmware was built +for, so it is what distinguishes one instrument of this family from another on a link that frames +every model identically. A family absent here is one whose prefix is not known, and is not checked. +""" + +SETTINGS_DATA_VERSION = 103.0 +"""The oldest instrument settings data version this package reads. + +Read as a number rather than compared as text: the field is a decimal that has gained digits over +time, so ``103`` sorts above ``99`` only numerically. +""" + + +async def check_communications(link: Link) -> FirmwareVersion: + """Prove something is listening, and that it is the model being driven. + + Two frames: the liveness check, then the firmware version record. + + Args: + link: The open link to the instrument. + + Returns: + The version record the instrument answered with. + + Raises: + BiotekError: If nothing answers, the record cannot be read, the basecode was built for another + family, or the instrument's settings data is older than the oldest this package reads. + """ + await link.request(Ping(), operation="ping") + command = GetFirmwareVersion() + try: + version = command.parse(await link.request(command, operation="ping")) + except ValueError as error: + # Firmware keeping no record acknowledges the query and answers nothing, which leaves the + # instrument unidentified: it is on the line, but there is nothing to say it is the right one. + raise fail( + ErrorKind.FIRMWARE, + f"{link.name} did not say which basecode it runs: {error}", + operation="ping", + code=WRONG_BASECODE_PART_NUMBER, + ) from error + logger.info( + "%s runs basecode %s, firmware %s, settings data %s", + link.name, + version.part_number.strip(), + version.software_version.strip(), + version.data_version.strip(), + ) + expected = BASECODE_PART_NUMBERS.get(link.family) + if expected is not None and not version.part_number.startswith(expected): + raise fail( + ErrorKind.FIRMWARE, + f"{link.name} runs basecode {version.part_number.strip()}, where a " + f"{link.family.name} runs one beginning {expected}; this is not the instrument this driver " + f"drives", + operation="ping", + code=WRONG_BASECODE_PART_NUMBER, + ) + data_version = _as_number(version.data_version) + if data_version is None: + logger.warning( + "%s did not report a settings data version, so how old its basecode is stays unknown", + link.name, + ) + elif data_version < SETTINGS_DATA_VERSION: + raise fail( + ErrorKind.FIRMWARE, + f"{link.name} reports settings data version {version.data_version.strip()}, older than the " + f"{SETTINGS_DATA_VERSION:g} this package reads; its basecode needs updating", + operation="ping", + code=SETTINGS_DATA_TOO_OLD, + ) + return version + + +def _as_number(field: str) -> float | None: + """Read a version field that is meant to be a decimal number. + + Args: + field: The field as the instrument wrote it, space-padded. + + Returns: + The number, or None for a field that does not hold one. + """ + try: + return float(field) + except ValueError: + return None diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py index db6580bc939..dcb439d0527 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py @@ -9,7 +9,12 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport from pylabrobot.agilent.biotek.lhc.devices import batch as batching -from pylabrobot.agilent.biotek.lhc.devices import execution, settings_document, settings_query +from pylabrobot.agilent.biotek.lhc.devices import ( + execution, + handshake, + settings_document, + settings_query, +) from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for from pylabrobot.agilent.biotek.lhc.devices.components.peristaltic_dispenser import ( PeristalticDispenser, @@ -45,7 +50,6 @@ FirmwareVersion, GetFirmwareVersion, GetSerialNumber, - Ping, ) from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import RunStatus from pylabrobot.resources import Plate @@ -167,7 +171,7 @@ async def setup(self) -> None: ) link = self._runtime.link await link.setup() - await link.request(Ping(), operation="ping") + await handshake.check_communications(link) self._runtime.reported_settings = await settings_query.read_settings(link, self.family) self._runtime.forget_instrument_facts() logger.info("%s is ready: %s", self.name, self._runtime.settings) diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py index 87871be4e33..c3691ce83fb 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py @@ -9,7 +9,12 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport from pylabrobot.agilent.biotek.lhc.devices import batch as batching -from pylabrobot.agilent.biotek.lhc.devices import execution, settings_document, settings_query +from pylabrobot.agilent.biotek.lhc.devices import ( + execution, + handshake, + settings_document, + settings_query, +) from pylabrobot.agilent.biotek.lhc.devices.build_rules import ( BASECODE_STEP_TYPES, basecode_for, @@ -50,7 +55,6 @@ FirmwareVersion, GetFirmwareVersion, GetSerialNumber, - Ping, ) from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import RunStatus from pylabrobot.resources import Plate @@ -186,7 +190,7 @@ async def setup(self) -> None: ) link = self._runtime.link await link.setup() - await link.request(Ping(), operation="ping") + await handshake.check_communications(link) self._runtime.reported_settings = await settings_query.read_settings(link, self.family) self._runtime.forget_instrument_facts() logger.info("%s is ready: %s", self.name, self._runtime.settings) diff --git a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py index 0610f8d3ab4..0b091dd7f38 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py +++ b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py @@ -9,7 +9,12 @@ from pylabrobot.agilent.biotek.lhc.comm.link import Link from pylabrobot.agilent.biotek.lhc.comm.transport import DEFAULT_READ_TIMEOUT, Transport from pylabrobot.agilent.biotek.lhc.devices import batch as batching -from pylabrobot.agilent.biotek.lhc.devices import execution, settings_document, settings_query +from pylabrobot.agilent.biotek.lhc.devices import ( + execution, + handshake, + settings_document, + settings_query, +) from pylabrobot.agilent.biotek.lhc.devices.build_rules import rules_for from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings @@ -42,7 +47,6 @@ FirmwareVersion, GetFirmwareVersion, GetSerialNumber, - Ping, ) from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import RunStatus from pylabrobot.resources import Plate @@ -157,7 +161,7 @@ async def setup(self) -> None: ) link = self._runtime.link await link.setup() - await link.request(Ping(), operation="ping") + await handshake.check_communications(link) self._runtime.reported_settings = await settings_query.read_settings(link, self.family) self._runtime.forget_instrument_facts() logger.info("%s is ready: %s", self.name, self._runtime.settings) diff --git a/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py b/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py index e527ece3ba1..0e5f84c847c 100644 --- a/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py @@ -7,7 +7,9 @@ NOT_ACKNOWLEDGED, PORT_WOULD_NOT_OPEN, REPLY_TIMED_OUT, + SETTINGS_DATA_TOO_OLD, UNKNOWN_CODE, + WRONG_BASECODE_PART_NUMBER, WRITE_FAILED, AbortedError, BiotekError, @@ -36,7 +38,9 @@ "NOT_ACKNOWLEDGED", "PORT_WOULD_NOT_OPEN", "REPLY_TIMED_OUT", + "SETTINGS_DATA_TOO_OLD", "UNKNOWN_CODE", + "WRONG_BASECODE_PART_NUMBER", "WRITE_FAILED", "AbortedError", "BiotekError", diff --git a/pylabrobot/agilent/biotek/lhc/error_handling/errors.py b/pylabrobot/agilent/biotek/lhc/error_handling/errors.py index 96627cb996d..4e49dc29607 100644 --- a/pylabrobot/agilent/biotek/lhc/error_handling/errors.py +++ b/pylabrobot/agilent/biotek/lhc/error_handling/errors.py @@ -35,6 +35,10 @@ class says what kind of thing went wrong -- which is what decides the caller's n REPLY_TIMED_OUT = 0x6053 PORT_WOULD_NOT_OPEN = 0x6058 +# What the host finds in the firmware version record itself, rather than in a reply's status. +WRONG_BASECODE_PART_NUMBER = 0x6002 +SETTINGS_DATA_TOO_OLD = 0x6003 + _LINK_FAULT_RANGE = (0x8100, 0x81FF) _CODE_MASK = 0xFFFF _HEX_FROM = 256 diff --git a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py index aebc0fd017f..1f80d006a83 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py @@ -24,7 +24,13 @@ from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType -from pylabrobot.agilent.biotek.lhc.error_handling import RejectedError +from pylabrobot.agilent.biotek.lhc.devices.handshake import BASECODE_PART_NUMBERS +from pylabrobot.agilent.biotek.lhc.error_handling import ( + SETTINGS_DATA_TOO_OLD, + WRONG_BASECODE_PART_NUMBER, + FirmwareError, + RejectedError, +) from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol, ProtocolEntry from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_aspirate import ManifoldAspirate @@ -44,6 +50,36 @@ make_plate, ) +_VERSION = CommandNumber.GET_BASECODE_VERSION + + +def _part_number(family: InstrumentFamily) -> bytes: + """The basecode part number a model of one family reports. + + Args: + family: Which family the model belongs to. + + Returns: + The seven characters, of which the first three are what the handshake reads. + """ + return BASECODE_PART_NUMBERS[family].encode() + b"0000" + + +def _version(part_number: bytes | None = None, data_version: bytes = b"103 ") -> bytes: + """A firmware version record, with the two fields the handshake reads settable. + + Args: + part_number: The seven-character basecode part number, defaulting to the original model's. + data_version: The five-character settings data version. + + Returns: + The record as the instrument writes it. + """ + if part_number is None: + part_number = _part_number(InstrumentFamily.EL406) + return part_number + b"2.22.6 " + b"ABCD" + b"DCBA" + data_version + b"1.0" + b"2.0" + b" " * 12 + + ANSWERS = { CommandNumber.GET_SYRINGE_MANIFOLD_INSTALLED: bytes([1]), CommandNumber.GET_SYRINGE_BOX_INFO: bytes([1, 2]), @@ -56,9 +92,7 @@ CommandNumber.GET_IS_PERI_HALF_UL_SUPPORTED: bytes([1]), CommandNumber.GET_Y_AXIS_INSTALLED: bytes([1]), CommandNumber.GET_SERIAL_NUMBER: b"SN0001".ljust(24), - CommandNumber.GET_BASECODE_VERSION: ( - b"7100000" + b"2.22.6 " + b"ABCD" + b"DCBA" + b"1.000" + b"1.0" + b"2.0" + b" " * 12 - ), + _VERSION: _version(), CommandNumber.IS_STRIP_WASHER_BOX_CONNECTED: bytes([0]), CommandNumber.GET_STRIP_WASHER_HW_INSTALLED: bytes([0]), CommandNumber.GET_WHICH_BASECODE_IS_INSTALLED: bytes([0]), @@ -103,7 +137,12 @@ async def build(self, cls, wells: int = 96, answers: dict | None = None): Returns: The device and the fake instrument behind it. """ - io = FakeInstrument(answers=ANSWERS if answers is None else answers) + answers = dict(ANSWERS if answers is None else answers) + # A test that named its own version record keeps it; every other one gets the record a model of + # this family reports, since the handshake refuses one built for another family. + if answers.get(_VERSION) == _version(): + answers[_VERSION] = _version(part_number=_part_number(cls.family)) + io = FakeInstrument(answers=answers) device = cls(port="fake", io=io) # The fake instrument answers at once, so none of the pacing a real one needs is wanted here. device.settle = 0 @@ -126,6 +165,37 @@ async def test_setup_proves_something_is_listening_before_reading_anything(self) _, io = await self.build(EL406) self.assertEqual(io.sent[0], int(CommandNumber.PING)) + async def test_setup_reads_the_version_record_next(self): + """Setup reads the version record next, which is what says what is listening.""" + _, io = await self.build(EL406) + self.assertEqual(io.sent[1], int(CommandNumber.GET_BASECODE_VERSION)) + + async def test_setup_refuses_a_basecode_built_for_another_family(self): + """Setup refuses a basecode built for another family.""" + answers = {**ANSWERS, _VERSION: _version(part_number=_part_number(InstrumentFamily.MULTIFLO))} + with self.assertRaises(FirmwareError) as raised: + await self.build(Washer405TS, answers=answers) + self.assertEqual(raised.exception.code, WRONG_BASECODE_PART_NUMBER) + + async def test_setup_accepts_the_basecode_of_the_model_being_driven(self): + """Setup accepts the basecode of the model being driven.""" + answers = {**ANSWERS, _VERSION: _version(part_number=b"1170202")} + device, _ = await self.build(Washer405TS, answers=answers) + self.assertIs(device.settings.family, InstrumentFamily.MODEL_405_TS) + + async def test_setup_refuses_settings_data_older_than_it_reads(self): + """Setup refuses settings data older than this package reads.""" + answers = {**ANSWERS, _VERSION: _version(data_version=b"99 ")} + with self.assertRaises(FirmwareError) as raised: + await self.build(EL406, answers=answers) + self.assertEqual(raised.exception.code, SETTINGS_DATA_TOO_OLD) + + async def test_setup_refuses_firmware_that_keeps_no_version_record(self): + """Setup refuses firmware that keeps no version record.""" + with self.assertRaises(FirmwareError) as raised: + await self.build(EL406, answers={**ANSWERS, _VERSION: b""}) + self.assertEqual(raised.exception.code, WRONG_BASECODE_PART_NUMBER) + async def test_a_device_reports_its_own_name(self): """A device reports its own name.""" device, _ = await self.build(EL406) From 6ea9b66d3ac97a20a9bc5a1e929c262b7ab7b545 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Tue, 8 Sep 2026 16:56:45 +0200 Subject: [PATCH 11/19] fixed status.remaining and abort behavior --- pylabrobot/agilent/biotek/lhc/comm/link.py | 8 +- .../agilent/biotek/lhc/devices/el406.py | 9 +- .../agilent/biotek/lhc/devices/execution.py | 55 ++++++++++- .../agilent/biotek/lhc/devices/multiflo.py | 9 +- .../agilent/biotek/lhc/devices/multiflo_fx.py | 9 +- .../agilent/biotek/lhc/devices/runtime.py | 4 + .../biotek/lhc/devices/washer_405ts.py | 9 +- .../biotek/lhc/serialization/command.py | 4 + .../lhc/serialization/commands/run_control.py | 18 +++- .../biotek/lhc/tests/execution_tests.py | 98 ++++++++++++++++++- .../agilent/biotek/lhc/tests/helpers.py | 21 +++- 11 files changed, 217 insertions(+), 27 deletions(-) diff --git a/pylabrobot/agilent/biotek/lhc/comm/link.py b/pylabrobot/agilent/biotek/lhc/comm/link.py index 58fe76260b6..c5f9fe76caa 100644 --- a/pylabrobot/agilent/biotek/lhc/comm/link.py +++ b/pylabrobot/agilent/biotek/lhc/comm/link.py @@ -132,12 +132,14 @@ async def request(self, command: Command, operation: str = "") -> bytes: operation: What is being attempted, for the message of any exception raised. Returns: - The reply's answer, with the instrument's status split off. + The reply's answer, with the instrument's status split off, and nothing at all for a command + the instrument does not answer. Raises: LinkError: If the link is closed, the write fails, nothing acknowledges the command, or the reply does not arrive intact. - BiotekError: A subclass matching what failed, if the instrument reports an error status. + BiotekError: A subclass matching what failed, if the instrument reports an error status. A + command that is not answered reports no status, so nothing is raised for one. """ if not self._open: raise fail(ErrorKind.LINK, f"{self._name} is not open", operation=operation or "request") @@ -146,6 +148,8 @@ async def request(self, command: Command, operation: str = "") -> bytes: await self.purge() await self._write(command.to_bytes(), operation) await self._read_ack(operation) + if not command.expects_reply: + return b"" header, payload = await self._read_reply(command, timeout, operation) if not command.reply_is_intact(header, payload): raise fail( diff --git a/pylabrobot/agilent/biotek/lhc/devices/el406.py b/pylabrobot/agilent/biotek/lhc/devices/el406.py index cdcadee8a6d..5a80c06ba89 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/el406.py +++ b/pylabrobot/agilent/biotek/lhc/devices/el406.py @@ -431,10 +431,15 @@ async def home(self, motor: Motor | None = None) -> None: ) async def abort(self) -> None: - """Stop the running step. + """Stop the running step and wait for the instrument to come back. + + The instrument stops where it is and homes itself, and is off the wire for as long as that + motion lasts: it acknowledges the request, answers nothing while it homes, and reports itself + ready once it is home. The step does not finish -- whatever was waiting for it is told it was + stopped. Raises: - BiotekError: If the instrument will not stop. + BiotekError: If the instrument does not come back, or reports a fault when it does. """ await execution.abort(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/devices/execution.py b/pylabrobot/agilent/biotek/lhc/devices/execution.py index 78ffaebebaf..7212b461b06 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/execution.py +++ b/pylabrobot/agilent/biotek/lhc/devices/execution.py @@ -22,7 +22,7 @@ from pylabrobot.agilent.biotek.lhc.enums.motion.carrier_type import CarrierType from pylabrobot.agilent.biotek.lhc.enums.plates.plate_restriction import PlateRestriction from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState -from pylabrobot.agilent.biotek.lhc.error_handling import ErrorKind, fail +from pylabrobot.agilent.biotek.lhc.error_handling import ErrorKind, LinkError, fail from pylabrobot.agilent.biotek.lhc.protocols.protocol import Protocol from pylabrobot.agilent.biotek.lhc.protocols.steps.step_interface import Step from pylabrobot.agilent.biotek.lhc.protocols.validation.protocol_pass import validate @@ -49,6 +49,13 @@ READY_TIMEOUT = 15.0 """How long to wait for the instrument to go idle before sending a step, in seconds.""" +ABORT_TIMEOUT = 30.0 +"""How long to wait for a stopped instrument to come back, in seconds. + +Stopping is a motion: the instrument leaves the wire while it homes, which takes it well past the +patience a single exchange has. What is waited for here is the whole of it. +""" + STEP_TIMEOUT = 3600.0 """How long to wait for a step to finish, in seconds. A wash with a long soak is minutes of it.""" @@ -138,6 +145,7 @@ async def run_step( KeyError: If no command runs that kind of step. """ plate_type = runtime.plate_type + runtime.aborting = False await wait_until_idle(runtime) command = RunStep(command_for_step(step), plate_type, step.to_bytes(runtime.settings)) await runtime.link.request(command, operation=step.step_type.name) @@ -164,6 +172,13 @@ async def _wait_for_step(runtime: Runtime, step: Step, timeout: float, interval: while True: reported = await status(runtime) if reported.state not in _RUNNING: + if runtime.aborting or reported.state is RunState.STOPPED: + runtime.aborting = False + raise fail( + ErrorKind.ABORTED, + f"{step.step_type.name} was stopped on {runtime.link.name}", + operation=step.step_type.name, + ) logger.info("%s finished on %s", step.step_type.name, runtime.link.name) return if reported.state is RunState.PAUSED and not paused: @@ -256,16 +271,46 @@ async def can_run(runtime: Runtime, steps: list[Step]) -> ValidationReport: return report -async def abort(runtime: Runtime) -> None: - """Stop the running step. +async def abort( + runtime: Runtime, timeout: float = ABORT_TIMEOUT, interval: float = POLL_INTERVAL +) -> None: + """Stop the running step and wait for the instrument to come back. + + The instrument acknowledges the request, stops where it is, homes itself, and answers nothing + until it is home, so a poll that finds out whether it stopped can go unanswered for as long as + that motion lasts. One that does is not a failure here -- it is the instrument being busy -- so + it is retried until the instrument answers or the time is up. Args: runtime: The device's state. + timeout: How long to wait for it to come back, in seconds. + interval: How long to wait between polls, in seconds. Raises: - BiotekError: If the instrument will not stop. + BiotekError: If the instrument does not come back, or reports a fault when it does. """ - await runtime.link.request(AbortStep(), operation="abort") + runtime.aborting = True + try: + await runtime.link.request(AbortStep(), operation="abort") + except BaseException: + # A request that never went out has stopped nothing, and must not have the step it was aimed + # at reported as stopped. + runtime.aborting = False + raise + deadline = asyncio.get_running_loop().time() + timeout + while True: + try: + if (await status(runtime)).state not in _RUNNING: + return + except LinkError: + logger.debug("%s is not answering while it stops", runtime.link.name) + if asyncio.get_running_loop().time() >= deadline: + raise fail( + ErrorKind.LINK, + f"{runtime.link.name} was asked to stop and has not come back after {timeout:g}s", + operation="abort", + ) + await asyncio.sleep(interval) async def pause(runtime: Runtime) -> None: diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py index dcb439d0527..fcce047f355 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo.py @@ -419,10 +419,15 @@ async def home(self, motor: Motor | None = None) -> None: ) async def abort(self) -> None: - """Stop the running step. + """Stop the running step and wait for the instrument to come back. + + The instrument stops where it is and homes itself, and is off the wire for as long as that + motion lasts: it acknowledges the request, answers nothing while it homes, and reports itself + ready once it is home. The step does not finish -- whatever was waiting for it is told it was + stopped. Raises: - BiotekError: If the instrument will not stop. + BiotekError: If the instrument does not come back, or reports a fault when it does. """ await execution.abort(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py index c3691ce83fb..828b005849a 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py @@ -442,10 +442,15 @@ async def home(self, motor: Motor | None = None) -> None: ) async def abort(self) -> None: - """Stop the running step. + """Stop the running step and wait for the instrument to come back. + + The instrument stops where it is and homes itself, and is off the wire for as long as that + motion lasts: it acknowledges the request, answers nothing while it homes, and reports itself + ready once it is home. The step does not finish -- whatever was waiting for it is told it was + stopped. Raises: - BiotekError: If the instrument will not stop. + BiotekError: If the instrument does not come back, or reports a fault when it does. """ await execution.abort(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/devices/runtime.py b/pylabrobot/agilent/biotek/lhc/devices/runtime.py index ccc8cea4a86..df1cbc27e28 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/runtime.py +++ b/pylabrobot/agilent/biotek/lhc/devices/runtime.py @@ -48,6 +48,9 @@ class Runtime: finished, in seconds. The first poll of a step that has only just started can still report the instrument idle, so this is what stops a step being called finished before it began. in_batch: Whether a batch is open, which is what makes the batch context re-entrant. + aborting: Whether a stop has been asked for and not yet reported. The instrument reports + itself ready once it has stopped and homed, which is indistinguishable from a step that + finished, so this is what lets the caller waiting on the step be told it was stopped. port: Held for as long as a batch is open, so two callers cannot interleave runs on one instrument. """ @@ -62,6 +65,7 @@ class Runtime: carrier_type: CarrierType | None = None settle: float = 0.5 in_batch: bool = False + aborting: bool = False port: asyncio.Lock = field(default_factory=asyncio.Lock) @property diff --git a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py index 0b091dd7f38..3158515b105 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py +++ b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py @@ -409,10 +409,15 @@ async def home(self, motor: Motor | None = None) -> None: ) async def abort(self) -> None: - """Stop the running step. + """Stop the running step and wait for the instrument to come back. + + The instrument stops where it is and homes itself, and is off the wire for as long as that + motion lasts: it acknowledges the request, answers nothing while it homes, and reports itself + ready once it is home. The step does not finish -- whatever was waiting for it is told it was + stopped. Raises: - BiotekError: If the instrument will not stop. + BiotekError: If the instrument does not come back, or reports a fault when it does. """ await execution.abort(self._runtime) diff --git a/pylabrobot/agilent/biotek/lhc/serialization/command.py b/pylabrobot/agilent/biotek/lhc/serialization/command.py index 4c55424586a..e9771cf22cd 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/command.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/command.py @@ -18,6 +18,9 @@ class Command: answer_length: How many bytes of the reply are the answer, or 0 to take all of them. timeout: How long to wait for the reply, in seconds, or None for the transport's default. Commands the instrument answers only once it has finished moving set their own. + expects_reply: Whether a reply frame follows the acknowledgement. A command that stops the + instrument is acknowledged and then answered by nothing at all, so waiting for a frame would + only ever time out. """ number: int @@ -25,6 +28,7 @@ class Command: reserved: int = 0 answer_length: int = 0 timeout: float | None = None + expects_reply: bool = True def to_bytes(self) -> bytes: """Frame the command for sending. diff --git a/pylabrobot/agilent/biotek/lhc/serialization/commands/run_control.py b/pylabrobot/agilent/biotek/lhc/serialization/commands/run_control.py index 2550b462e12..5a32275a2f3 100644 --- a/pylabrobot/agilent/biotek/lhc/serialization/commands/run_control.py +++ b/pylabrobot/agilent/biotek/lhc/serialization/commands/run_control.py @@ -44,11 +44,15 @@ def __init__(self) -> None: class AbortStep(Command): - """Stop the running step.""" + """Stop the running step. + + Acknowledged and then not answered: the instrument stops what it is doing and homes itself, and + says nothing at all until it is home. Whether it stopped is learned by asking afterwards. + """ def __init__(self) -> None: """Build the command.""" - super().__init__(number=CommandNumber.ABORT_STEP) + super().__init__(number=CommandNumber.ABORT_STEP, expects_reply=False) class PauseStep(Command): @@ -90,10 +94,16 @@ def __init__(self, number: CommandNumber, plate_type: PlateType, payload: bytes) class RunStatus: """What a status poll reports. + The countdown and the phase go together, and both are empty most of the time: a dispense, an + aspirate, a prime, or a wash between its stages counts nothing down and reports no phase. A + countdown is not how far through the step the instrument is -- it is the time left in the one + phase it is in, so a step that ends in a soak reports 0 until it reaches the soak. + Attributes: state: What the instrument is doing. - remaining: Seconds left in the current phase, or 0 when nothing is counting down. - activity: Which timed phase the step is in. + remaining: Seconds left in the timed phase the instrument is in, and 0 whenever it is in none + -- which is most of every ordinary step. + activity: Which timed phase the step is in, and ``NONE`` whenever it is in none. """ state: RunState = RunState.READY diff --git a/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py b/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py index 9582227d77f..bf9cff56a1a 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/execution_tests.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import unittest from pylabrobot.agilent.biotek.lhc.devices import execution @@ -18,11 +19,16 @@ from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState -from pylabrobot.agilent.biotek.lhc.error_handling import BiotekError, RejectedError +from pylabrobot.agilent.biotek.lhc.error_handling import ( + AbortedError, + BiotekError, + RejectedError, +) from pylabrobot.agilent.biotek.lhc.plate_geometry.resolution import resolve from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_prime import SyringePrime from pylabrobot.agilent.biotek.lhc.serialization.command_numbers import CommandNumber +from pylabrobot.agilent.biotek.lhc.serialization.frame import HEADER_LENGTH, Header from pylabrobot.agilent.biotek.lhc.tests.helpers import ( ACCEPTS_EVERY_PLATE, FakeInstrument, @@ -32,6 +38,37 @@ FAMILY = InstrumentFamily.EL406 +_STARTUP_POLLS = 100 +"""How many turns of the loop to give a step to reach the wire.""" + + +class Homing(FakeInstrument): + """A fake that leaves one status poll unanswered, the way an instrument moving its motors does. + + Args: + **kwargs: Passed to the plain fake. + + Attributes: + ignored: How many more polls to leave unanswered. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.ignored = 1 + + async def write(self, data: bytes) -> None: + """Take a frame, and answer it unless it is the poll being ignored. + + Args: + data: The bytes written. + """ + header = Header.from_bytes(data[:HEADER_LENGTH]) + if header.number == int(CommandNumber.GET_PROTOCOL_STATUS) and self.ignored: + self.ignored -= 1 + self.sent.append(header.number) + return + await super().write(data) + class ExecutionTestCase(unittest.IsolatedAsyncioTestCase): """A device's state wired to a fake instrument, opened and ready to run steps.""" @@ -43,6 +80,7 @@ async def opened( with_plate: bool = True, busy_after_step: bool = False, answers: dict | None = None, + io: FakeInstrument | None = None, ) -> tuple[Runtime, FakeInstrument]: """Build the state and open the link. @@ -52,6 +90,7 @@ async def opened( with_plate: Whether a plate is on the carrier. busy_after_step: Whether a step, once sent, never finishes. answers: What the instrument answers, defaulting to one that accepts every plate. + io: A transport to use instead of a plain fake. Returns: The state and the fake instrument behind it. @@ -62,6 +101,7 @@ async def opened( status=status, family=FAMILY, busy_after_step=busy_after_step, + io=io, ) state = Runtime( link=link, @@ -263,11 +303,61 @@ async def test_a_new_plate_makes_the_next_check_ask_again(self): class TestRunControl(ExecutionTestCase): """Stopping a running step, and letting it go on.""" - async def test_abort_sends_its_own_command(self): - """Abort sends its own command.""" + async def test_abort_sends_its_own_command_and_then_asks_whether_it_worked(self): + """Abort sends its own command and then asks whether it worked.""" state, io = await self.opened() await execution.abort(state) - self.assertEqual(io.sent, [int(CommandNumber.ABORT_STEP)]) + self.assertEqual( + io.sent, [int(CommandNumber.ABORT_STEP), int(CommandNumber.GET_PROTOCOL_STATUS)] + ) + + async def test_abort_waits_for_an_instrument_that_is_still_stopping(self): + """Abort waits for an instrument that is still stopping.""" + state, io = await self.opened(busy_polls=3) + await execution.abort(state, interval=0) + self.assertGreaterEqual(io.sent.count(int(CommandNumber.GET_PROTOCOL_STATUS)), 4) + + async def test_abort_retries_a_poll_that_goes_unanswered(self): + """Abort retries a poll that goes unanswered, which is what homing looks like.""" + io = Homing(answers=ACCEPTS_EVERY_PLATE) + state, _ = await self.opened(io=io) + await execution.abort(state, interval=0) + self.assertEqual(io.ignored, 0) + self.assertEqual(io.sent[-1], int(CommandNumber.GET_PROTOCOL_STATUS)) + + async def test_abort_gives_up_on_an_instrument_that_never_comes_back(self): + """Abort gives up on an instrument that never comes back.""" + state, _ = await self.opened(busy_polls=1000) + with self.assertRaisesRegex(BiotekError, "has not come back"): + await execution.abort(state, timeout=0, interval=0) + + async def test_the_step_that_was_stopped_reports_that_it_was(self): + """The step that was stopped reports that it was, rather than reporting it finished.""" + state, io = await self.opened(busy_after_step=True) + async with batch(state): + running = asyncio.create_task(execution.run_step(state, ManifoldPrime(), interval=0)) + for _ in range(_STARTUP_POLLS): + if int(CommandNumber.MANIFOLD_PRIME) in io.sent: + break + await asyncio.sleep(0) + await execution.abort(state, interval=0) + with self.assertRaises(AbortedError): + await running + + async def test_an_instrument_stopped_from_its_keypad_reports_that_too(self): + """An instrument stopped from its keypad reports that too, with nothing having asked it to.""" + state, _ = await self.opened(io=FakeInstrument(answers=ACCEPTS_EVERY_PLATE, stops=True)) + async with batch(state): + with self.assertRaises(AbortedError): + await execution.run_step(state, ManifoldPrime(), interval=0) + + async def test_a_new_step_is_not_a_stopped_one(self): + """A new step is not a stopped one, whatever was asked of the last.""" + state, _ = await self.opened() + state.aborting = True + async with batch(state): + await execution.run_step(state, ManifoldPrime(), interval=0) + self.assertFalse(state.aborting) async def test_pause_sends_its_own_command(self): """Pause sends its own command.""" diff --git a/pylabrobot/agilent/biotek/lhc/tests/helpers.py b/pylabrobot/agilent/biotek/lhc/tests/helpers.py index 6a1c5f2cf31..0c2371b0bac 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/helpers.py +++ b/pylabrobot/agilent/biotek/lhc/tests/helpers.py @@ -110,6 +110,8 @@ class FakeInstrument(Transport): busy_after_step: Whether to report a running step forever once one has been sent, which is what a step that never finishes looks like. The polls before it still report the instrument idle, so a caller gets as far as sending the step. + stops: Whether to report itself stopped rather than ready once it is no longer running, which + is what a step stopped from the instrument's own keypad looks like. Attributes: sent: Every command number written, in order, so a test can assert what was sent and when. @@ -122,12 +124,14 @@ def __init__( busy_polls: int = 0, status: int = 0, busy_after_step: bool = False, + stops: bool = False, ) -> None: super().__init__(port="fake", timeout=1.0) self.answers = dict(answers) if answers else {} self.busy_polls = busy_polls self.status = status self.busy_after_step = busy_after_step + self.stops = stops self._step_sent = False self.sent: list[int] = [] self.payloads: list[bytes] = [] @@ -186,6 +190,9 @@ def _answer(self, header: Header, payload: bytes) -> None: """ self.sent.append(header.number) self.payloads.append(payload) + if header.number == int(CommandNumber.ABORT_STEP): + # Stopping ends the step, so an instrument that was reporting one no longer does. + self._step_sent = False self._out += bytes([ACK]) + self._reply(header.number) def _reply(self, number: int) -> bytes: @@ -217,7 +224,10 @@ def _answer_for(self, number: int) -> bytes: if number == CommandNumber.GET_PROTOCOL_STATUS: self._polls += 1 running = self._polls <= self.busy_polls or (self.busy_after_step and self._step_sent) - state = RunState.BUSY if running else RunState.READY + if running: + state = RunState.BUSY + else: + state = RunState.STOPPED if self.stops else RunState.READY return int(state).to_bytes(2, "little") + (0).to_bytes(4, "little") + bytes([0]) for command, answer in self.answers.items(): if int(command) == number: @@ -259,6 +269,7 @@ def fake_link( status: int = 0, family: InstrumentFamily = InstrumentFamily.EL406, busy_after_step: bool = False, + io: FakeInstrument | None = None, ) -> tuple[Link, FakeInstrument]: """A link onto a fake instrument, and the instrument itself. @@ -268,11 +279,13 @@ def fake_link( status: The status word to answer with. family: Which family the link decodes error codes for. busy_after_step: Whether a step, once sent, never finishes. + io: A transport to use instead of a plain fake, for a test that needs one that misbehaves. Returns: The link, unopened, and the transport behind it. """ - io = FakeInstrument( - answers=answers, busy_polls=busy_polls, status=status, busy_after_step=busy_after_step - ) + if io is None: + io = FakeInstrument( + answers=answers, busy_polls=busy_polls, status=status, busy_after_step=busy_after_step + ) return Link(port="fake", family=family, name="fake instrument", timeout=1.0, io=io), io From c3bef865eeb0c80aed9313a81d380358889ffe46 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Tue, 8 Sep 2026 17:58:54 +0200 Subject: [PATCH 12/19] improved notebook --- .../agilent/405ts/hello-world.ipynb | 210 ++---------------- .../biotek/lhc/devices/washer_405ts.py | 7 - 2 files changed, 18 insertions(+), 199 deletions(-) diff --git a/docs/user_guide/agilent/405ts/hello-world.ipynb b/docs/user_guide/agilent/405ts/hello-world.ipynb index a6452b46c7a..ee633ea0054 100644 --- a/docs/user_guide/agilent/405ts/hello-world.ipynb +++ b/docs/user_guide/agilent/405ts/hello-world.ipynb @@ -18,22 +18,15 @@ "\n", "| Property | Value |\n", "|---|---|\n", - "| Communication | USB through an FTDI interface, or a serial port |\n", + "| Communication | A serial port, or USB through an FTDI interface |\n", "| Serial parameters | 38400 baud, 8 data bits, 2 stop bits, no parity, no flow control |\n", "| Operations | Priming, dispensing, aspirating, washing, auto-clean, shake and soak |\n", - "| Plate formats | 6 to 1536 wells, resolved from the PyLabRobot plate resource |\n", + "| Plate formats | 96-well, 384-well and 384-well PCR, resolved from the PyLabRobot plate resource |\n", "| Buffer inlets | A, B, C, D |\n", "| Manifold reach | Depth 1-255 motor steps, across the well -60 to 60, along the well -60 to 60 |\n", "| Protocol files | `.LHC` protocol files are read, checked and run |\n", "\n", "```{warning}\n", - "This driver has not yet been checked against a real 405 TS. `setup()` says so in the log every time\n", - "it runs. Verify every protocol on labware and fluid you can afford to lose, keep a hand on the\n", - "power switch the first time the carrier moves, and please report what you find on the\n", - "[PyLabRobot forum](https://discuss.pylabrobot.org) so the warning can be removed.\n", - "```\n", - "\n", - "```{warning}\n", "Follow the manufacturer's installation, fluid-handling and safety instructions. A prime and a\n", "dispense both move fluid: the buffer bottle must be full and the waste bottle empty enough before\n", "anything in this notebook runs.\n", @@ -53,33 +46,13 @@ "cell_type": "markdown", "id": "ma-02", "metadata": {}, - "source": [ - "## How it communicates\n", - "\n", - "PyLabRobot frames each command as an 11-byte header and a payload, writes it to the instrument,\n", - "reads back an acknowledgement and the reply, and turns a non-zero status into a typed exception.\n", - "Which of the two transports carries those bytes is decided by the port string alone, and nothing\n", - "above that point knows which one it got.\n", - "\n", - "Operations that move fluid do not answer when they are done. The driver sends them, then polls the\n", - "instrument's run state until it stops reporting a step in progress, which is why every method that\n", - "touches the instrument is awaited and can take as long as the physical operation does.\n", - "\n", - "Install PyLabRobot with the FTDI dependencies if the washer is on USB.\n", - "\n", - "Reading `.LHC` protocol files additionally needs `pycryptodome`, which is not a PyLabRobot\n", - "dependency: the file format is encrypted, and the cipher is not in the standard library. Leave it\n", - "out if you only build protocols in Python — `read()` raises a `RuntimeError` telling you to install\n", - "it if you later try to read a file without it." - ] + "source": "## How it communicates\n\nPyLabRobot frames each command as an 11-byte header and a payload, writes it to the instrument,\nreads back an acknowledgement and the reply, and turns a non-zero status into a typed exception.\nWhich of the two transports carries those bytes is decided by the port string alone, and nothing\nabove that point knows which one it got.\n\nOperations that move fluid do not answer when they are done. The driver sends them, then polls the\ninstrument's run state until it stops reporting a step in progress, which is why every method that\ntouches the instrument is awaited and can take as long as the physical operation does.\n\nInstall PyLabRobot with its serial dependencies, or with the FTDI ones if the washer is on USB.\n\nReading `.LHC` protocol files additionally needs `pycryptodome`, which is not a PyLabRobot\ndependency: the file format is encrypted, and the cipher is not in the standard library. Leave it\nout if you only build protocols in Python — `read()` raises a `RuntimeError` telling you to install\nit if you later try to read a file without it." }, { "cell_type": "code", "id": "co-03", "metadata": {}, - "source": [ - "%pip install \"pylabrobot[ftdi]\" pycryptodome" - ], + "source": "%pip install \"pylabrobot[serial]\" pycryptodome\n\n# On USB, install the FTDI dependencies instead: \"pylabrobot[ftdi]\".", "execution_count": null, "outputs": [] }, @@ -87,43 +60,13 @@ "cell_type": "markdown", "id": "ma-04", "metadata": {}, - "source": [ - "## Physical setup and finding the port\n", - "\n", - "Install, plumb and power the washer according to the manufacturer's instructions. Connect the\n", - "buffer bottles to the inlets the protocol names, connect the waste bottle, and connect the\n", - "instrument to the computer.\n", - "\n", - "Then find the port string:\n", - "\n", - "- **USB.** List the attached FTDI devices and use the reported serial number:\n", - "\n", - " ```bash\n", - " python -m pylibftdi.examples.list_devices\n", - " ```\n", - "\n", - " The port is then `ftdi:`, for example `ftdi:183193P`. The form the instrument's own\n", - " protocol files record, `USB 405 TS sn:183193P`, is accepted as well.\n", - "\n", - "- **Serial.** Pass the operating system's own name for the port: `/dev/ttyUSB0`, `/dev/ttyS4` or\n", - " `COM3`. Anything that is not a USB serial number is taken to be a serial port and passed through\n", - " unexamined, so no particular naming pattern is required.\n", - "\n", - "Keep the carrier and the area around it clear from here on. Nothing in this notebook moves the\n", - "carrier before the batch section, but a fault can home the motors at any time." - ] + "source": "## Physical setup and finding the port\n\nInstall, plumb and power the washer according to the manufacturer's instructions. Connect the\nbuffer bottles to the inlets the protocol names, connect the waste bottle, and connect the\ninstrument to the computer.\n\nThen find the port string:\n\n- **Serial.** Pass the operating system's own name for the port: `COM3` on Windows,\n `/dev/ttyUSB0` or `/dev/ttyS4` on Linux and macOS. Anything that is not a USB serial number is\n taken to be a serial port and passed through unexamined, so no particular naming pattern is\n required.\n\n- **USB.** List the attached FTDI devices and use the reported serial number:\n\n ```bash\n python -m pylibftdi.examples.list_devices\n ```\n\n The port is then `ftdi:`, for example `ftdi:183193P`. The form the instrument's own\n protocol files record, `USB 405 TS sn:183193P`, is accepted as well.\n\nKeep the carrier and the area around it clear from here on. Nothing in this notebook moves the\ncarrier before the batch section, but a fault can home the motors at any time." }, { "cell_type": "markdown", "id": "ma-05", "metadata": {}, - "source": [ - "## Turn on logging\n", - "\n", - "The driver reports what it is doing through the standard library's `logging`, and says nothing\n", - "otherwise. Without this cell the untested-driver warning in the next section, and every step the\n", - "instrument runs, pass silently." - ] + "source": "## Turn on logging\n\nThe driver reports what it is doing through the standard library's `logging`, and says nothing\notherwise. Without this cell every step the instrument runs passes silently." }, { "cell_type": "code", @@ -154,12 +97,7 @@ "cell_type": "code", "id": "co-08", "metadata": {}, - "source": [ - "from pylabrobot.agilent.biotek.lhc import Washer405TS\n", - "\n", - "device = Washer405TS(port=\"ftdi:YOUR_SERIAL\", name=\"405 TS\")\n", - "device" - ], + "source": "from pylabrobot.agilent.biotek.lhc import Washer405TS\n\n# The port is a serial one unless it carries a device serial number: on USB, pass\n# \"ftdi:YOUR_SERIAL\" instead.\ndevice = Washer405TS(port=\"COM3\", name=\"405 TS\")\ndevice", "execution_count": null, "outputs": [] }, @@ -192,20 +130,7 @@ "cell_type": "markdown", "id": "ma-11", "metadata": {}, - "source": [ - "## Ask what answered, and check it is a 405 TS\n", - "\n", - "**The model is declared, not discovered.** The instrument does not report which model it is, so it\n", - "is the class you constructed that decides how every step is encoded and which options are read.\n", - "Pointing `Washer405TS` at a different model does not raise: it connects, and then encodes steps for\n", - "the wrong machine.\n", - "\n", - "So checking is worth doing by hand, and there is a fact to check it against. The firmware version\n", - "record carries a part number, which says which instrument the installed firmware image is built\n", - "for. Read it once for an instrument you have identified physically, and assert it from then on.\n", - "\n", - "The serial number identifies the individual instrument; the part number identifies the model." - ] + "source": "## Ask what answered\n\n**The model is declared, not discovered.** The instrument does not report which model it is, so it\nis the class you constructed that decides how every step is encoded and which options are read.\nPointing `Washer405TS` at a different model does not raise: it connects, and then encodes steps for\nthe wrong machine. Note that `device.settings.family` is not a check on this — it echoes what was\ndeclared, not what is attached.\n\nWhat the instrument does report is its serial number, which identifies the individual instrument,\nand its firmware version record, whose part number says which instrument the installed firmware\nimage is built for." }, { "cell_type": "code", @@ -222,33 +147,6 @@ "execution_count": null, "outputs": [] }, - { - "cell_type": "markdown", - "id": "ma-13", - "metadata": {}, - "source": [ - "If the part number is not the one this instrument reported when you identified it physically,\n", - "stop here: close the link and construct the model that is actually attached. Note that\n", - "`device.settings.family` is not the check — it echoes what was declared, not what is attached." - ] - }, - { - "cell_type": "code", - "id": "co-14", - "metadata": {}, - "source": [ - "EXPECTED_PART_NUMBER = version.part_number # replace with the value you recorded for this washer\n", - "\n", - "if version.part_number != EXPECTED_PART_NUMBER:\n", - " await device.stop()\n", - " raise RuntimeError(\n", - " f\"{device.name} reports firmware {version.part_number}, \"\n", - " f\"expected {EXPECTED_PART_NUMBER}; this may not be a 405 TS\"\n", - " )" - ], - "execution_count": null, - "outputs": [] - }, { "cell_type": "markdown", "id": "ma-15", @@ -311,30 +209,13 @@ "cell_type": "markdown", "id": "ma-19", "metadata": {}, - "source": [ - "## Tell it which plate is on the carrier\n", - "\n", - "Nothing runs until a plate has been set. Every step carries the height it works at, measured from\n", - "the nominal heights of the format on the carrier, so without one there is nothing to measure from\n", - "and the driver raises `RejectedError` rather than guessing.\n", - "\n", - "The format is resolved from the PyLabRobot plate resource itself — its columns, its rows, and how\n", - "deep its wells are. Labware that does not land on exactly one of the formats this model works is an\n", - "error naming the candidates, never a nearest fit." - ] + "source": "## Tell it which plate is on the carrier\n\nNothing runs until a plate has been set. Every step carries the height it works at, measured from\nthe nominal heights of the format on the carrier, so without one there is nothing to measure from\nand the driver raises `RejectedError` rather than guessing.\n\nThe format is resolved from the PyLabRobot plate resource itself — its columns, its rows, and how\ndeep its wells are. Labware that does not land on exactly one of the formats this model works is an\nerror naming the candidates, never a nearest fit. A 405 TS works three: 96-well, 384-well and\n384-well PCR.\n\nA 384-well plate needs the manifold that reaches it. The 192-tube manifold is the one fitted for\n384-well work; a 128-tube or a 96-tube single-action manifold is refused outright on this format,\nand a 96-tube dual-action one is allowed but dispenses no less than 50 µL per well, which is more\nthan the wells of the plate below hold." }, { "cell_type": "code", "id": "co-20", "metadata": {}, - "source": [ - "from pylabrobot.resources import cor_96_wellplate_360uL_Fb\n", - "\n", - "plate = cor_96_wellplate_360uL_Fb(name=\"plate\")\n", - "device.set_plate(plate)\n", - "\n", - "print(device.plate)" - ], + "source": "from pylabrobot.resources import Greiner_384_wellplate_28ul_Fb\n\nplate = Greiner_384_wellplate_28ul_Fb(name=\"plate\")\ndevice.set_plate(plate)\n\nprint(device.plate)", "execution_count": null, "outputs": [] }, @@ -342,25 +223,13 @@ "cell_type": "markdown", "id": "ma-21", "metadata": {}, - "source": [ - "Some formats are never resolved from a resource, because they share their column and row count\n", - "with an ordinary plate and differ in something the resource does not carry — a half-area well, a\n", - "flange, a tube, or that it is calibration labware. Name one of those outright, and use the same\n", - "argument when a plate is to be worked as something other than what it resolves to." - ] + "source": "Some formats are never resolved from a resource, because they share their column and row count\nwith an ordinary plate and differ in something the resource does not carry — a well shape, a\nflange, a tube, or that it is calibration labware. On a 405 TS that is the 384-well PCR format,\nwhich is worked from a different height than a flat 384-well plate. Name one of those outright,\nand use the same argument when a plate is to be worked as something other than what it resolves\nto." }, { "cell_type": "code", "id": "co-22", "metadata": {}, - "source": [ - "from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType\n", - "\n", - "device.set_plate(plate, plate_type=PlateType.PLATE_96_HALF_WELL)\n", - "print(device.plate)\n", - "\n", - "device.set_plate(plate) # back to what it resolves to on its own" - ], + "source": "from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType\n\ndevice.set_plate(plate, plate_type=PlateType.PLATE_384_WELL_PCR)\nprint(device.plate)\n\ndevice.set_plate(plate) # back to what it resolves to on its own", "execution_count": null, "outputs": [] }, @@ -445,32 +314,13 @@ "cell_type": "markdown", "id": "ma-29", "metadata": {}, - "source": [ - "## Run several operations in one batch\n", - "\n", - "Opening a batch homes the motors, takes the instrument so that nothing else can interleave a run on\n", - "it, and holds it until the block ends. Every operation opens one; doing it once around several\n", - "operations is what stops the motors being homed between each of them.\n", - "\n", - "The block below primes the lines and then dispenses 100 µL of buffer A into every well. **This one\n", - "dispenses into the plate**, so put a plate on the carrier that you are willing to fill.\n", - "\n", - "`home_on_close=True` drives the transport home before the batch closes. The instrument does not do\n", - "this by itself; ask for it when the next thing to touch the plate is a person.\n", - "\n", - "Nesting is allowed and does nothing: an operation called inside an open batch joins it rather than\n", - "opening a second one." - ] + "source": "## Run several operations in one batch\n\nOpening a batch homes the motors, takes the instrument so that nothing else can interleave a run on\nit, and holds it until the block ends. Every operation opens one; doing it once around several\noperations is what stops the motors being homed between each of them.\n\nThe block below primes the lines and then dispenses 25 µL of buffer A into every well. **This one\ndispenses into the plate**, so put a plate on the carrier that you are willing to fill.\n\n25 µL is the smallest volume a 192-tube manifold dispenses, and about what a well of this plate\nholds. Nothing in the driver measures a volume against the well: the checks accept anything from\nthe manifold's own floor up to 3000 µL, so how much fits is yours to know.\n\n`home_on_close=True` drives the transport home before the batch closes. The instrument does not do\nthis by itself; ask for it when the next thing to touch the plate is a person.\n\nNesting is allowed and does nothing: an operation called inside an open batch joins it rather than\nopening a second one." }, { "cell_type": "code", "id": "co-30", "metadata": {}, - "source": [ - "async with device.batch(home_on_close=True):\n", - " await device.washer.prime(volume=10_000, buffer=\"A\")\n", - " await device.washer.dispense(volume=100, buffer=\"A\", flow_rate=7)" - ], + "source": "async with device.batch(home_on_close=True):\n await device.washer.prime(volume=10_000, buffer=\"A\")\n await device.washer.dispense(volume=25, buffer=\"A\", flow_rate=7)", "execution_count": null, "outputs": [] }, @@ -478,18 +328,13 @@ "cell_type": "markdown", "id": "ma-31", "metadata": {}, - "source": [ - "A wash is the two of them repeated, and is one step rather than a loop: the instrument runs the\n", - "cycles itself. This aspirates each well empty and refills it, three times." - ] + "source": "A wash is the two of them repeated, and is one step rather than a loop: the instrument runs the\ncycles itself. This aspirates each well empty and refills it, three times.\n\nThe refill needs a volume of its own. The dispense a wash owns is checked like any other, and\nthe step type defaults to no volume at all, so a wash that names none is refused before\nanything moves." }, { "cell_type": "code", "id": "co-32", "metadata": {}, - "source": [ - "await device.washer.wash(cycles=3, dispense=None, aspirate=None)" - ], + "source": "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\nfrom pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense\n\nawait device.washer.wash(\n cycles=3,\n dispense=ManifoldDispense(\n volume=25,\n buffer=\"A\",\n positioning=Positioning(z_steps=device.plate.manifold_dispense_height),\n ),\n)", "execution_count": null, "outputs": [] }, @@ -538,19 +383,7 @@ "cell_type": "code", "id": "co-36", "metadata": {}, - "source": [ - "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n", - "\n", - "await device.washer.dispense(\n", - " volume=100,\n", - " buffer=\"A\",\n", - " positioning=Positioning(\n", - " z_steps=device.plate.manifold_dispense_height - 10, # 10 steps higher in the well\n", - " x_steps=20, # toward one side\n", - " y_steps=0,\n", - " ),\n", - ")" - ], + "source": "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n\nawait device.washer.dispense(\n volume=25,\n buffer=\"A\",\n positioning=Positioning(\n z_steps=device.plate.manifold_dispense_height - 10, # 10 steps higher in the well\n x_steps=20, # toward one side\n y_steps=0,\n ),\n)", "execution_count": null, "outputs": [] }, @@ -621,14 +454,7 @@ "cell_type": "code", "id": "co-41", "metadata": {}, - "source": [ - "await device.pause()\n", - "await asyncio.sleep(2)\n", - "print((await device.get_status()).state.name)\n", - "\n", - "await device.resume()\n", - "await running" - ], + "source": "from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState\n\nstatus = await device.get_status()\nif status.state is not RunState.BUSY:\n print(\n f\"the device is {status.state.name}, not running a step -- start the cell above again \"\n \"and run this one while its step is still going\"\n )\nelse:\n await device.pause()\n await asyncio.sleep(2)\n print((await device.get_status()).state.name)\n\n await device.resume()\n await running", "execution_count": null, "outputs": [] }, diff --git a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py index 3158515b105..2f210fd8f75 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py +++ b/pylabrobot/agilent/biotek/lhc/devices/washer_405ts.py @@ -93,7 +93,6 @@ class Washer405TS: """ family: ClassVar[InstrumentFamily] = InstrumentFamily.MODEL_405_TS - checked_on_hardware: ClassVar[bool] = False def __init__( self, @@ -153,12 +152,6 @@ async def setup(self) -> None: BiotekError: If the port will not open, nothing answers on it, or the fitted options cannot be read. """ - if not self.checked_on_hardware: - logger.warning( - "the %s driver has not been checked against a real instrument; verify every protocol on " - "labware you can afford to lose before trusting it", - type(self).__name__, - ) link = self._runtime.link await link.setup() await handshake.check_communications(link) From 53f5a634a3fe77debc3cf5b159b737f288b7a488 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Tue, 8 Sep 2026 18:41:45 +0200 Subject: [PATCH 13/19] imporved notebook --- .../agilent/405ts/hello-world.ipynb | 385 ++++++++++++++---- 1 file changed, 305 insertions(+), 80 deletions(-) diff --git a/docs/user_guide/agilent/405ts/hello-world.ipynb b/docs/user_guide/agilent/405ts/hello-world.ipynb index ee633ea0054..43e5e5fdb62 100644 --- a/docs/user_guide/agilent/405ts/hello-world.ipynb +++ b/docs/user_guide/agilent/405ts/hello-world.ipynb @@ -46,39 +46,128 @@ "cell_type": "markdown", "id": "ma-02", "metadata": {}, - "source": "## How it communicates\n\nPyLabRobot frames each command as an 11-byte header and a payload, writes it to the instrument,\nreads back an acknowledgement and the reply, and turns a non-zero status into a typed exception.\nWhich of the two transports carries those bytes is decided by the port string alone, and nothing\nabove that point knows which one it got.\n\nOperations that move fluid do not answer when they are done. The driver sends them, then polls the\ninstrument's run state until it stops reporting a step in progress, which is why every method that\ntouches the instrument is awaited and can take as long as the physical operation does.\n\nInstall PyLabRobot with its serial dependencies, or with the FTDI ones if the washer is on USB.\n\nReading `.LHC` protocol files additionally needs `pycryptodome`, which is not a PyLabRobot\ndependency: the file format is encrypted, and the cipher is not in the standard library. Leave it\nout if you only build protocols in Python — `read()` raises a `RuntimeError` telling you to install\nit if you later try to read a file without it." + "source": [ + "## How it communicates\n", + "\n", + "PyLabRobot frames each command as an 11-byte header and a payload, writes it to the instrument,\n", + "reads back an acknowledgement and the reply, and turns a non-zero status into a typed exception.\n", + "Which of the two transports carries those bytes is decided by the port string alone, and nothing\n", + "above that point knows which one it got.\n", + "\n", + "Operations that move fluid do not answer when they are done. The driver sends them, then polls the\n", + "instrument's run state until it stops reporting a step in progress, which is why every method that\n", + "touches the instrument is awaited and can take as long as the physical operation does.\n", + "\n", + "Install PyLabRobot with its serial dependencies, or with the FTDI ones if the washer is on USB.\n", + "\n", + "Reading `.LHC` protocol files additionally needs `pycryptodome`, which is not a PyLabRobot\n", + "dependency: the file format is encrypted, and the cipher is not in the standard library. Leave it\n", + "out if you only build protocols in Python — `read()` raises a `RuntimeError` telling you to install\n", + "it if you later try to read a file without it." + ] }, { "cell_type": "code", + "execution_count": null, "id": "co-03", "metadata": {}, - "source": "%pip install \"pylabrobot[serial]\" pycryptodome\n\n# On USB, install the FTDI dependencies instead: \"pylabrobot[ftdi]\".", - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "%pip install \"pylabrobot[serial]\" pycryptodome\n", + "\n", + "# On USB, install the FTDI dependencies instead: \"pylabrobot[ftdi]\"." + ] }, { "cell_type": "markdown", "id": "ma-04", "metadata": {}, - "source": "## Physical setup and finding the port\n\nInstall, plumb and power the washer according to the manufacturer's instructions. Connect the\nbuffer bottles to the inlets the protocol names, connect the waste bottle, and connect the\ninstrument to the computer.\n\nThen find the port string:\n\n- **Serial.** Pass the operating system's own name for the port: `COM3` on Windows,\n `/dev/ttyUSB0` or `/dev/ttyS4` on Linux and macOS. Anything that is not a USB serial number is\n taken to be a serial port and passed through unexamined, so no particular naming pattern is\n required.\n\n- **USB.** List the attached FTDI devices and use the reported serial number:\n\n ```bash\n python -m pylibftdi.examples.list_devices\n ```\n\n The port is then `ftdi:`, for example `ftdi:183193P`. The form the instrument's own\n protocol files record, `USB 405 TS sn:183193P`, is accepted as well.\n\nKeep the carrier and the area around it clear from here on. Nothing in this notebook moves the\ncarrier before the batch section, but a fault can home the motors at any time." + "source": [ + "## Physical setup and finding the port\n", + "\n", + "Install, plumb and power the washer according to the manufacturer's instructions. Connect the\n", + "buffer bottles to the inlets the protocol names, connect the waste bottle, and connect the\n", + "instrument to the computer.\n", + "\n", + "Then find the port string:\n", + "\n", + "- **Serial.** Pass the operating system's own name for the port: `COM3` on Windows,\n", + " `/dev/ttyUSB0` or `/dev/ttyS4` on Linux and macOS. Anything that is not a USB serial number is\n", + " taken to be a serial port and passed through unexamined, so no particular naming pattern is\n", + " required.\n", + "\n", + "- **USB.** List the attached FTDI devices and use the reported serial number:\n", + "\n", + " ```bash\n", + " python -m pylibftdi.examples.list_devices\n", + " ```\n", + "\n", + " The port is then `ftdi:`, for example `ftdi:183193P`. The form the instrument's own\n", + " protocol files record, `USB 405 TS sn:183193P`, is accepted as well.\n", + "\n", + "Keep the carrier and the area around it clear from here on. Nothing in this notebook moves the\n", + "carrier before the batch section, but a fault can home the motors at any time." + ] }, { "cell_type": "markdown", "id": "ma-05", "metadata": {}, - "source": "## Turn on logging\n\nThe driver reports what it is doing through the standard library's `logging`, and says nothing\notherwise. Without this cell every step the instrument runs passes silently." + "source": [ + "## Turn on logging\n", + "\n", + "The driver reports what it is doing through the standard library's `logging`, and says nothing\n", + "otherwise. Without this cell every step the instrument runs passes silently." + ] }, { "cell_type": "code", + "execution_count": null, "id": "co-06", "metadata": {}, + "outputs": [], "source": [ "import logging\n", "\n", "logging.basicConfig(level=logging.INFO, format=\"%(levelname)s %(name)s: %(message)s\")" - ], + ] + }, + { + "cell_type": "markdown", + "id": "b4f0b622", + "metadata": {}, + "source": [ + "## See which ports have something behind them\n", + "\n", + "`pyserial` lists every serial port the operating system offers, most of which are kernel\n", + "placeholders with no hardware behind them — on Linux the 32 `/dev/ttyS*` entries, which report\n", + "their description and hardware id as `n/a`. Skipping those leaves the ports worth trying, and a\n", + "USB-serial adapter names the instrument it is wired to, so the washer is usually recognisable at a\n", + "glance.\n", + "\n", + "This lists serial ports only. A washer driven through the FTDI transport is found with\n", + "`python -m pylibftdi.examples.list_devices` instead — though a device the kernel has bound to its\n", + "own FTDI serial driver shows up here too, and can be used either way." + ] + }, + { + "cell_type": "code", "execution_count": null, - "outputs": [] + "id": "816bd530", + "metadata": {}, + "outputs": [], + "source": [ + "from serial.tools.list_ports import comports\n", + "\n", + "candidates = [port for port in comports() if port.description != \"n/a\" or port.vid is not None]\n", + "\n", + "for port in candidates:\n", + " serial_number = f\" sn:{port.serial_number}\" if port.serial_number else \"\"\n", + " print(f\"{port.device:16} {port.description}{serial_number}\")\n", + "\n", + "if not candidates:\n", + " print(\"no port has a device behind it; is the washer powered and connected?\")" + ] }, { "cell_type": "markdown", @@ -95,11 +184,18 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-08", "metadata": {}, - "source": "from pylabrobot.agilent.biotek.lhc import Washer405TS\n\n# The port is a serial one unless it carries a device serial number: on USB, pass\n# \"ftdi:YOUR_SERIAL\" instead.\ndevice = Washer405TS(port=\"COM3\", name=\"405 TS\")\ndevice", - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc import Washer405TS\n", + "\n", + "# The port is a serial one unless it carries a device serial number: on USB, pass\n", + "# \"ftdi:YOUR_SERIAL\" instead.\n", + "device = Washer405TS(port=\"/dev/ttyUSB1\", name=\"405 TS\")\n", + "device" + ] }, { "cell_type": "markdown", @@ -118,24 +214,38 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-10", "metadata": {}, + "outputs": [], "source": [ "await device.setup()" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", "id": "ma-11", "metadata": {}, - "source": "## Ask what answered\n\n**The model is declared, not discovered.** The instrument does not report which model it is, so it\nis the class you constructed that decides how every step is encoded and which options are read.\nPointing `Washer405TS` at a different model does not raise: it connects, and then encodes steps for\nthe wrong machine. Note that `device.settings.family` is not a check on this — it echoes what was\ndeclared, not what is attached.\n\nWhat the instrument does report is its serial number, which identifies the individual instrument,\nand its firmware version record, whose part number says which instrument the installed firmware\nimage is built for." + "source": [ + "## Ask what answered\n", + "\n", + "**The model is declared, not discovered.** The instrument does not report which model it is, so it\n", + "is the class you constructed that decides how every step is encoded and which options are read.\n", + "Pointing `Washer405TS` at a different model does not raise: it connects, and then encodes steps for\n", + "the wrong machine. Note that `device.settings.family` is not a check on this — it echoes what was\n", + "declared, not what is attached.\n", + "\n", + "What the instrument does report is its serial number, which identifies the individual instrument,\n", + "and its firmware version record, whose part number says which instrument the installed firmware\n", + "image is built for." + ] }, { "cell_type": "code", + "execution_count": null, "id": "co-12", "metadata": {}, + "outputs": [], "source": [ "print(\"serial number: \", await device.get_serial_number())\n", "\n", @@ -143,9 +253,7 @@ "print(\"part number: \", version.part_number)\n", "print(\"firmware: \", version.software_version)\n", "print(\"data version: \", version.data_version)" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -165,8 +273,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-16", "metadata": {}, + "outputs": [], "source": [ "settings = device.settings\n", "\n", @@ -178,9 +288,7 @@ "print(\"cell washing: \", settings.cell_washing)\n", "print(\"ultrasonic: \", settings.ultrasonic)\n", "print(\"Y axis: \", settings.y_axis_installed)" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -196,42 +304,80 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-18", "metadata": {}, + "outputs": [], "source": [ "for step_type in device.get_available_steps():\n", " print(step_type.name)" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", "id": "ma-19", "metadata": {}, - "source": "## Tell it which plate is on the carrier\n\nNothing runs until a plate has been set. Every step carries the height it works at, measured from\nthe nominal heights of the format on the carrier, so without one there is nothing to measure from\nand the driver raises `RejectedError` rather than guessing.\n\nThe format is resolved from the PyLabRobot plate resource itself — its columns, its rows, and how\ndeep its wells are. Labware that does not land on exactly one of the formats this model works is an\nerror naming the candidates, never a nearest fit. A 405 TS works three: 96-well, 384-well and\n384-well PCR.\n\nA 384-well plate needs the manifold that reaches it. The 192-tube manifold is the one fitted for\n384-well work; a 128-tube or a 96-tube single-action manifold is refused outright on this format,\nand a 96-tube dual-action one is allowed but dispenses no less than 50 µL per well, which is more\nthan the wells of the plate below hold." + "source": [ + "## Tell it which plate is on the carrier\n", + "\n", + "Nothing runs until a plate has been set. Every step carries the height it works at, measured from\n", + "the nominal heights of the format on the carrier, so without one there is nothing to measure from\n", + "and the driver raises `RejectedError` rather than guessing.\n", + "\n", + "The format is resolved from the PyLabRobot plate resource itself — its columns, its rows, and how\n", + "deep its wells are. Labware that does not land on exactly one of the formats this model works is an\n", + "error naming the candidates, never a nearest fit. A 405 TS works three: 96-well, 384-well and\n", + "384-well PCR.\n", + "\n", + "Which 384-well labware suits the instrument depends on the manifold fitted to it, because the\n", + "smallest volume the manifold dispenses does. A 128-tube or a 96-tube single-action manifold is\n", + "refused on a 384-well plate outright; a 192-tube manifold dispenses from 25 µL per well, and a\n", + "96-tube dual-action one from 50 µL. Read `settings.washer_manifold` above, and pick a plate whose\n", + "wells hold what your manifold will dispense — the plate below takes about 190 µL." + ] }, { "cell_type": "code", + "execution_count": null, "id": "co-20", "metadata": {}, - "source": "from pylabrobot.resources import Greiner_384_wellplate_28ul_Fb\n\nplate = Greiner_384_wellplate_28ul_Fb(name=\"plate\")\ndevice.set_plate(plate)\n\nprint(device.plate)", - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "from pylabrobot.resources import Plate_384_Well\n", + "\n", + "plate = Plate_384_Well(name=\"plate\")\n", + "device.set_plate(plate)\n", + "\n", + "print(device.plate)" + ] }, { "cell_type": "markdown", "id": "ma-21", "metadata": {}, - "source": "Some formats are never resolved from a resource, because they share their column and row count\nwith an ordinary plate and differ in something the resource does not carry — a well shape, a\nflange, a tube, or that it is calibration labware. On a 405 TS that is the 384-well PCR format,\nwhich is worked from a different height than a flat 384-well plate. Name one of those outright,\nand use the same argument when a plate is to be worked as something other than what it resolves\nto." + "source": [ + "Some formats are never resolved from a resource, because they share their column and row count\n", + "with an ordinary plate and differ in something the resource does not carry — a well shape, a\n", + "flange, a tube, or that it is calibration labware. On a 405 TS that is the 384-well PCR format,\n", + "which is worked from a different height than a flat 384-well plate. Name one of those outright,\n", + "and use the same argument when a plate is to be worked as something other than what it resolves\n", + "to." + ] }, { "cell_type": "code", + "execution_count": null, "id": "co-22", "metadata": {}, - "source": "from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType\n\ndevice.set_plate(plate, plate_type=PlateType.PLATE_384_WELL_PCR)\nprint(device.plate)\n\ndevice.set_plate(plate) # back to what it resolves to on its own", - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType\n", + "\n", + "device.set_plate(plate, plate_type=PlateType.PLATE_384_WELL_PCR)\n", + "print(device.plate)\n", + "\n", + "device.set_plate(plate) # back to what it resolves to on its own" + ] }, { "cell_type": "markdown", @@ -249,17 +395,17 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-24", "metadata": {}, + "outputs": [], "source": [ "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime\n", "\n", "report = await device.can_run([ManifoldPrime(volume=10_000, buffer=\"A\", flow_rate=9)])\n", "print(report)\n", "print(\"can run:\", bool(report))" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -272,15 +418,15 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-26", "metadata": {}, + "outputs": [], "source": [ "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense\n", "\n", "print(await device.can_run([SyringeDispense(volume=50)]))" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -302,41 +448,84 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-28", "metadata": {}, + "outputs": [], "source": [ "await device.washer.prime(volume=10_000, buffer=\"A\", flow_rate=9)" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", "id": "ma-29", "metadata": {}, - "source": "## Run several operations in one batch\n\nOpening a batch homes the motors, takes the instrument so that nothing else can interleave a run on\nit, and holds it until the block ends. Every operation opens one; doing it once around several\noperations is what stops the motors being homed between each of them.\n\nThe block below primes the lines and then dispenses 25 µL of buffer A into every well. **This one\ndispenses into the plate**, so put a plate on the carrier that you are willing to fill.\n\n25 µL is the smallest volume a 192-tube manifold dispenses, and about what a well of this plate\nholds. Nothing in the driver measures a volume against the well: the checks accept anything from\nthe manifold's own floor up to 3000 µL, so how much fits is yours to know.\n\n`home_on_close=True` drives the transport home before the batch closes. The instrument does not do\nthis by itself; ask for it when the next thing to touch the plate is a person.\n\nNesting is allowed and does nothing: an operation called inside an open batch joins it rather than\nopening a second one." + "source": [ + "## Run several operations in one batch\n", + "\n", + "Opening a batch homes the motors, takes the instrument so that nothing else can interleave a run on\n", + "it, and holds it until the block ends. Every operation opens one; doing it once around several\n", + "operations is what stops the motors being homed between each of them.\n", + "\n", + "The block below primes the lines and then dispenses 50 µL of buffer A into every well. **This one\n", + "dispenses into the plate**, so put a plate on the carrier that you are willing to fill.\n", + "\n", + "50 µL is the smallest volume a 96-tube dual-action manifold dispenses, and a 192-tube one goes down\n", + "to 25 µL. Above that the volume is yours: nothing in the driver measures it against the well, since\n", + "the check accepts anything from the manifold's floor up to 3000 µL. A refusal here reads\n", + "`Washer Dispense Volume must be 50..3000`.\n", + "\n", + "`home_on_close=True` drives the transport home before the batch closes. The instrument does not do\n", + "this by itself; ask for it when the next thing to touch the plate is a person.\n", + "\n", + "Nesting is allowed and does nothing: an operation called inside an open batch joins it rather than\n", + "opening a second one." + ] }, { "cell_type": "code", + "execution_count": null, "id": "co-30", "metadata": {}, - "source": "async with device.batch(home_on_close=True):\n await device.washer.prime(volume=10_000, buffer=\"A\")\n await device.washer.dispense(volume=25, buffer=\"A\", flow_rate=7)", - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.washer.prime(volume=10_000, buffer=\"A\")\n", + " await device.washer.dispense(volume=50, buffer=\"A\", flow_rate=7)" + ] }, { "cell_type": "markdown", "id": "ma-31", "metadata": {}, - "source": "A wash is the two of them repeated, and is one step rather than a loop: the instrument runs the\ncycles itself. This aspirates each well empty and refills it, three times.\n\nThe refill needs a volume of its own. The dispense a wash owns is checked like any other, and\nthe step type defaults to no volume at all, so a wash that names none is refused before\nanything moves." + "source": [ + "A wash is the two of them repeated, and is one step rather than a loop: the instrument runs the\n", + "cycles itself. This aspirates each well empty and refills it, three times.\n", + "\n", + "The refill needs a volume of its own. The dispense a wash owns is checked like any other, and\n", + "the step type defaults to no volume at all, so a wash that names none is refused before\n", + "anything moves." + ] }, { "cell_type": "code", + "execution_count": null, "id": "co-32", "metadata": {}, - "source": "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\nfrom pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense\n\nawait device.washer.wash(\n cycles=3,\n dispense=ManifoldDispense(\n volume=25,\n buffer=\"A\",\n positioning=Positioning(z_steps=device.plate.manifold_dispense_height),\n ),\n)", - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n", + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense\n", + "\n", + "await device.washer.wash(\n", + " cycles=2,\n", + " dispense=ManifoldDispense(\n", + " volume=50,\n", + " buffer=\"A\",\n", + " positioning=Positioning(z_steps=device.plate.manifold_dispense_height),\n", + " ),\n", + ")" + ] }, { "cell_type": "markdown", @@ -360,14 +549,14 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-34", "metadata": {}, + "outputs": [], "source": [ "print(\"nominal manifold dispense height:\", device.plate.manifold_dispense_height)\n", "print(\"nominal manifold aspirate height:\", device.plate.manifold_aspirate_height)" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -381,11 +570,23 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-36", "metadata": {}, - "source": "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n\nawait device.washer.dispense(\n volume=25,\n buffer=\"A\",\n positioning=Positioning(\n z_steps=device.plate.manifold_dispense_height - 10, # 10 steps higher in the well\n x_steps=20, # toward one side\n y_steps=0,\n ),\n)", - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n", + "\n", + "await device.washer.dispense(\n", + " volume=50,\n", + " buffer=\"A\",\n", + " positioning=Positioning(\n", + " z_steps=device.plate.manifold_dispense_height - 10, # 10 steps higher in the well\n", + " x_steps=20, # toward one side\n", + " y_steps=0,\n", + " ),\n", + ")" + ] }, { "cell_type": "markdown", @@ -426,8 +627,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-39", "metadata": {}, + "outputs": [], "source": [ "import asyncio\n", "\n", @@ -438,9 +641,7 @@ "print(\"state: \", status.state.name)\n", "print(\"activity: \", status.activity.name)\n", "print(\"remaining:\", status.remaining, \"s\")" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -452,11 +653,27 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-41", "metadata": {}, - "source": "from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState\n\nstatus = await device.get_status()\nif status.state is not RunState.BUSY:\n print(\n f\"the device is {status.state.name}, not running a step -- start the cell above again \"\n \"and run this one while its step is still going\"\n )\nelse:\n await device.pause()\n await asyncio.sleep(2)\n print((await device.get_status()).state.name)\n\n await device.resume()\n await running", - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState\n", + "\n", + "status = await device.get_status()\n", + "if status.state is not RunState.BUSY:\n", + " print(\n", + " f\"the device is {status.state.name}, not running a step -- start the cell above again \"\n", + " \"and run this one while its step is still going\"\n", + " )\n", + "else:\n", + " await device.pause()\n", + " await asyncio.sleep(2)\n", + " print((await device.get_status()).state.name)\n", + "\n", + " await device.resume()\n", + " await running" + ] }, { "cell_type": "markdown", @@ -470,8 +687,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-43", "metadata": {}, + "outputs": [], "source": [ "from pylabrobot.agilent.biotek.lhc.error_handling import AbortedError, BiotekError\n", "\n", @@ -483,9 +702,7 @@ " await running\n", "except AbortedError as error:\n", " print(\"stopped:\", error)" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -504,8 +721,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-45", "metadata": {}, + "outputs": [], "source": [ "from pylabrobot.agilent.biotek.lhc import read\n", "\n", @@ -519,9 +738,7 @@ "\n", "for index, step in enumerate(protocol.build_steps()):\n", " print(f\" step {index}: {type(step).__name__}\")" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -541,15 +758,15 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-47", "metadata": {}, + "outputs": [], "source": [ "comparison = device.compare_settings(protocol)\n", "print(comparison)\n", "print(\"same configuration:\", bool(comparison))" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -572,13 +789,13 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-49", "metadata": {}, + "outputs": [], "source": [ "await device.run_protocol(protocol, home_on_close=True)" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -591,8 +808,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-51", "metadata": {}, + "outputs": [], "source": [ "await device.run_protocol(\n", " [\n", @@ -601,9 +820,7 @@ " ],\n", " home_on_close=True,\n", ")" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -622,14 +839,14 @@ }, { "cell_type": "code", + "execution_count": null, "id": "co-53", "metadata": {}, + "outputs": [], "source": [ "await device.home()\n", "await device.stop()" - ], - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", @@ -652,8 +869,16 @@ "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.11.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" } }, "nbformat": 4, From 560b063788b24474a1d080a18c7023495268ec67 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Thu, 10 Sep 2026 14:54:24 +0200 Subject: [PATCH 14/19] added columns masks --- .../lhc/protocols/steps/step_parts/masks.py | 22 +++++++ .../agilent/biotek/lhc/tests/masks_tests.py | 58 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 pylabrobot/agilent/biotek/lhc/tests/masks_tests.py diff --git a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/masks.py b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/masks.py index deec0072ba3..0a13f185544 100644 --- a/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/masks.py +++ b/pylabrobot/agilent/biotek/lhc/protocols/steps/step_parts/masks.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Iterable COLUMNS = 48 """How many entries a column selection always has, whatever the plate holds.""" @@ -49,6 +50,27 @@ def from_definition(cls, field_text: str) -> WellMask: """ return cls([int(digit) for digit in field_text]) + @classmethod + def from_columns(cls, columns: Iterable[int]) -> WellMask: + """A selection of the columns to work, counted from one. + + Args: + columns: The columns to select. Order does not matter, and a column named twice is selected + once. Naming none selects nothing, which is a selection a step accepts. + + Returns: + The selection, with every column not named unselected. + + Raises: + ValueError: If a column is not between 1 and 48. A column beyond what the plate holds is not + an error: the instrument reads as many entries as the format on the carrier has. + """ + chosen = set(columns) + beyond = sorted(column for column in chosen if not 1 <= column <= COLUMNS) + if beyond: + raise ValueError(f"columns must be 1..{COLUMNS}; got {beyond}") + return cls([1 if column in chosen else 0 for column in range(1, COLUMNS + 1)]) + @classmethod def all_columns(cls) -> WellMask: """Every column selected. diff --git a/pylabrobot/agilent/biotek/lhc/tests/masks_tests.py b/pylabrobot/agilent/biotek/lhc/tests/masks_tests.py new file mode 100644 index 00000000000..760c4698589 --- /dev/null +++ b/pylabrobot/agilent/biotek/lhc/tests/masks_tests.py @@ -0,0 +1,58 @@ +"""Building the column and row selections a step carries. + +A selection is one entry per position rather than a list of numbers, so the constructors that turn +numbers into entries are what a caller reaches for. What is tested is that they place the entries +where the packers below them expect, and that a column outside the range is an error rather than a +silently dropped entry. +""" + +from __future__ import annotations + +import pytest + +from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import COLUMNS, WellMask + + +def test_named_columns_are_the_ones_selected(): + mask = WellMask.from_columns([1, 2]) + + assert mask.values == [1, 1] + [0] * 46 + assert mask.selected_count == 2 + + +def test_a_selection_has_an_entry_per_position_whatever_is_named(): + assert len(WellMask.from_columns([1]).values) == COLUMNS + assert len(WellMask.from_columns([]).values) == COLUMNS + + +def test_order_and_repeats_do_not_matter(): + assert WellMask.from_columns([3, 1, 3]) == WellMask.from_columns([1, 3]) + + +def test_naming_no_column_selects_nothing(): + mask = WellMask.from_columns([]) + + assert mask.selected_count == 0 + assert mask.is_valid + + +def test_a_column_beyond_the_plate_is_still_a_column(): + assert WellMask.from_columns([48]).values[-1] == 1 + + +@pytest.mark.parametrize("columns", [[0], [49], [1, 0], [-1]]) +def test_a_column_outside_the_range_is_refused(columns: list[int]): + with pytest.raises(ValueError, match="columns must be 1..48"): + WellMask.from_columns(columns) + + +def test_a_built_selection_packs_as_the_same_bytes_as_a_written_one(): + built = WellMask.from_columns([1, 2]) + written = WellMask([1, 1] + [0] * 46) + + assert built.to_bytes() == written.to_bytes() + assert built.to_definition() == written.to_definition() + + +def test_every_column_is_what_all_columns_gives(): + assert WellMask.from_columns(range(1, COLUMNS + 1)) == WellMask.all_columns() From 791accec2f88423079936d19ef69cea33d9dbd8b Mon Sep 17 00:00:00 2001 From: StefanMa Date: Fri, 11 Sep 2026 13:26:25 +0200 Subject: [PATCH 15/19] added step-parts to doc strings --- docs/api/pylabrobot.agilent.rst | 85 +++++++++++++++++++ .../agilent/405ts/hello-world.ipynb | 84 ++++++++++++++++-- docs/user_guide/agilent/index.md | 3 + 3 files changed, 163 insertions(+), 9 deletions(-) diff --git a/docs/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst index 8097d521fb8..63c8c348fd3 100644 --- a/docs/api/pylabrobot.agilent.rst +++ b/docs/api/pylabrobot.agilent.rst @@ -59,6 +59,91 @@ One class per model. Each exposes the capability objects its fitted hardware sup InstrumentSettings SettingsComparison +Steps +~~~~~ + +One class per operation the instruments perform, holding everything that operation can be given. +A capability method takes these where it takes a step, and a protocol file stores them. + +.. currentmodule:: pylabrobot.agilent.biotek.lhc.protocols.steps.steps + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ManifoldWash + ManifoldDispense + ManifoldAspirate + ManifoldPrime + ManifoldAutoClean + Wash1536 + StripWash + StripDispense + StripAspirate + StripPrime + SyringeDispense + SyringePrime + PeriDispense + PeriRandomAccessDispense + PeriPrime + PeriPurge + PeriWashDispense + PeriWashAspirate + ShakeSoak + +.. currentmodule:: pylabrobot.agilent.biotek.lhc.protocols.steps + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Step + step_from_definition + +Step parts +~~~~~~~~~~ + +The groups of parameters the steps above are built from: where in the well a step works, which +stages of a wash run, and the optional behaviours a step can switch on. + +.. currentmodule:: pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Positioning + +.. currentmodule:: pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + WashStages + Sectors + PreDispense + SecondaryAspirate + VacuumDelay + Shake + Soak + Submerge + RandomAccess + WellVolumeMap + +.. currentmodule:: pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + WellMask + .. currentmodule:: pylabrobot.agilent.biotek.lhc.protocols.validation .. autosummary:: diff --git a/docs/user_guide/agilent/405ts/hello-world.ipynb b/docs/user_guide/agilent/405ts/hello-world.ipynb index 43e5e5fdb62..fe863165812 100644 --- a/docs/user_guide/agilent/405ts/hello-world.ipynb +++ b/docs/user_guide/agilent/405ts/hello-world.ipynb @@ -69,7 +69,7 @@ { "cell_type": "code", "execution_count": null, - "id": "co-03", + "id": "93baa9f0", "metadata": {}, "outputs": [], "source": [ @@ -123,7 +123,7 @@ { "cell_type": "code", "execution_count": null, - "id": "co-06", + "id": "16a6724d", "metadata": {}, "outputs": [], "source": [ @@ -185,7 +185,7 @@ { "cell_type": "code", "execution_count": null, - "id": "co-08", + "id": "fba4e138", "metadata": {}, "outputs": [], "source": [ @@ -193,7 +193,7 @@ "\n", "# The port is a serial one unless it carries a device serial number: on USB, pass\n", "# \"ftdi:YOUR_SERIAL\" instead.\n", - "device = Washer405TS(port=\"/dev/ttyUSB1\", name=\"405 TS\")\n", + "device = Washer405TS(port=\"COM3\", name=\"405 TS\")\n", "device" ] }, @@ -518,7 +518,64 @@ "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense\n", "\n", "await device.washer.wash(\n", - " cycles=2,\n", + " cycles=1,\n", + " dispense=ManifoldDispense(\n", + " volume=50,\n", + " buffer=\"A\",\n", + " positioning=Positioning(z_steps=device.plate.manifold_dispense_height),\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{note}\n", + "That is a wash with almost everything left at its default, and a wash is the most configurable\n", + "step there is. It also takes the aspirate that empties the well, which stages run\n", + "(`WashStages`: pre-dispense before the wash and between cycles, a bottom wash, a final aspirate,\n", + "a pause after each dispense), the `ShakeSoak` that pause runs, and separate dispense and aspirate\n", + "steps for the bottom wash and the final aspirate. Each of those carries its own settings in turn:\n", + "flow rate, pre-dispense volume and count, aspirate travel rate and dwell, a secondary aspirate\n", + "pattern, vacuum filtration and vacuum delay, and its own positioning.\n", + "\n", + "Nothing the original instrument software configures is missing here. The full list, per step and\n", + "per group of settings, is in the API reference under\n", + "{doc}`Steps and Step parts `.\n", + "```\n" + ], + "id": "ma-32-opts" + }, + { + "cell_type": "markdown", + "id": "ma-35-sel", + "metadata": {}, + "source": [ + "## Washing part of a plate\n", + "\n", + "A wash covers a region per pass: `wash_format` is `\"Plate\"`, `\"Sector\"` or `\"Strip\"`, and `sectors`\n", + "says which quarters are in play, one flag per sector. The sector selection is checked whatever the\n", + "format says — a wash naming no sector at all is refused before anything moves — so it defaults to\n", + "all four.\n", + "\n", + "This is the only way to work part of a plate on this model. Its wash manifold reaches every well of\n", + "a 96-well plate in one pass, so there is nothing left for a finer selection to do." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-36-sel", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import Sectors\n", + "\n", + "await device.washer.wash(\n", + " cycles=1,\n", + " wash_format=\"Sector\",\n", + " sectors=Sectors([True, False, True, False]),\n", " dispense=ManifoldDispense(\n", " volume=50,\n", " buffer=\"A\",\n", @@ -692,16 +749,25 @@ "metadata": {}, "outputs": [], "source": [ + "import asyncio\n", + "\n", + "from pylabrobot.agilent.biotek.lhc.comm.observer import Operation\n", + "from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState\n", "from pylabrobot.agilent.biotek.lhc.error_handling import AbortedError, BiotekError\n", + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n", + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense\n", + "from pylabrobot.agilent.biotek.lhc.serialization.commands.run_control import AbortStep\n", + "\n", + "# from pylabrobot.agilent.biotek.lhc.error_handling import AbortedError, BiotekError\n", "\n", "running = asyncio.create_task(device.washer.prime(volume=40_000, buffer=\"A\"))\n", "await asyncio.sleep(2)\n", "await device.abort()\n", - "\n", "try:\n", " await running\n", "except AbortedError as error:\n", - " print(\"stopped:\", error)" + " print(\"stopped:\", error)\n", + "\n" ] }, { @@ -728,7 +794,7 @@ "source": [ "from pylabrobot.agilent.biotek.lhc import read\n", "\n", - "protocol = read(\"path/to/your/protocol.LHC\")\n", + "protocol = read(\"/home/stefan/workspace/biotek_plr/pylabrobot_modified/pylabrobot/agilent/biotek/lhc/tests/test_data/protocols/405_TS_and_LS/001_W-CORNING_FLAT_96.LHC\")\n", "\n", "print(\"name: \", protocol.protocol_name)\n", "print(\"written by:\", protocol.lhc_version)\n", @@ -840,7 +906,7 @@ { "cell_type": "code", "execution_count": null, - "id": "co-53", + "id": "671c9c0c", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/user_guide/agilent/index.md b/docs/user_guide/agilent/index.md index 5460ada353f..947d03ea457 100644 --- a/docs/user_guide/agilent/index.md +++ b/docs/user_guide/agilent/index.md @@ -5,5 +5,8 @@ 405ts/hello-world benchcel/hello-world +el406/hello-world +multiflo/hello-world +multiflofx/hello-world vspin/index ``` From b556e1023a10364e437ca28bfc0cb2cf5379204f Mon Sep 17 00:00:00 2001 From: StefanMa Date: Fri, 11 Sep 2026 13:38:53 +0200 Subject: [PATCH 16/19] added missing noteooks --- .../agilent/el406/hello-world.ipynb | 935 +++++++++++ .../agilent/multiflo/hello-world.ipynb | 853 ++++++++++ .../agilent/multiflofx/hello-world.ipynb | 1386 +++++++++++++++++ 3 files changed, 3174 insertions(+) create mode 100644 docs/user_guide/agilent/el406/hello-world.ipynb create mode 100644 docs/user_guide/agilent/multiflo/hello-world.ipynb create mode 100644 docs/user_guide/agilent/multiflofx/hello-world.ipynb diff --git a/docs/user_guide/agilent/el406/hello-world.ipynb b/docs/user_guide/agilent/el406/hello-world.ipynb new file mode 100644 index 00000000000..4535491d0a7 --- /dev/null +++ b/docs/user_guide/agilent/el406/hello-world.ipynb @@ -0,0 +1,935 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ma-00", + "metadata": {}, + "source": [ + "# Agilent BioTek EL406 washer-dispenser quickstart\n", + "\n", + "The EL406 is the whole family in one instrument: a wash manifold that primes, dispenses, aspirates\n", + "and washes; a syringe box that meters volumes precisely; and a peristaltic pump that pushes fluid\n", + "through a tubing cassette. Which of those it has, and which options go with them — a buffer\n", + "switching valve, an ultrasonic cleaner, vacuum filtration — is a matter of what somebody fitted,\n", + "and it reports all of it.\n", + "\n", + "This quickstart connects to the instrument, reads what it is and what it has fitted, tells it which\n", + "plate is on its carrier, primes and washes through the manifold, dispenses from the syringes and the\n", + "pump, explains where in the well a step works, runs a protocol file, and disconnects.\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Communication | A serial port, or USB through an FTDI interface |\n", + "| Serial parameters | 38400 baud, 8 data bits, 2 stop bits, no parity, no flow control |\n", + "| Operations | Washing, priming, dispensing, aspirating, auto-clean, syringe and peristaltic dispensing, shake and soak |\n", + "| Plate formats | 96-well, 384-well, 384-well PCR, 1536-well and 1536-flanged |\n", + "| Buffer inlets | A, B, C, D — anything but A needs the buffer switching valve |\n", + "| Syringes | One box; which syringes a step may drive is decided by the fitted manifold |\n", + "| Peristaltic pumps | One, with a tubing cassette |\n", + "| Manifold reach | Depth 1-210 motor steps, across the well -60 to 60, along the well -40 to 40 |\n", + "| Protocol files | `.LHC` protocol files are read, checked and run |\n", + "\n", + "```{warning}\n", + "This driver has not yet been checked against a real EL406. `setup()` says so in the log every time\n", + "it runs. Every step it sends has been checked against the vendor's own interface library — frame\n", + "for frame, and against the same validation the instrument applies — but that is not the same as\n", + "having moved fluid. Verify every protocol on labware and fluid you can afford to lose, keep a hand\n", + "on the power switch the first time the carrier moves, and please report what you find on the\n", + "[PyLabRobot forum](https://discuss.pylabrobot.org) so the warning can be removed.\n", + "```\n", + "\n", + "```{warning}\n", + "Follow the manufacturer's installation, fluid-handling and safety instructions. Priming, washing and\n", + "dispensing all move fluid: the buffer bottles must be full and the waste bottle empty enough before\n", + "anything in this notebook runs.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-01", + "metadata": {}, + "source": [ + "```{device-card} biotek-el406\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-02", + "metadata": {}, + "source": [ + "## How it communicates\n", + "\n", + "PyLabRobot frames each command as an 11-byte header and a payload, writes it to the instrument,\n", + "reads back an acknowledgement and the reply, and turns a non-zero status into a typed exception.\n", + "Which of the two transports carries those bytes is decided by the port string alone, and nothing\n", + "above that point knows which one it got.\n", + "\n", + "Operations that move fluid do not answer when they are done. The driver sends them, then polls the\n", + "instrument's run state until it stops reporting a step in progress, which is why every method that\n", + "touches the instrument is awaited and can take as long as the physical operation does.\n", + "\n", + "Reading `.LHC` protocol files additionally needs `pycryptodome`, which is not a PyLabRobot\n", + "dependency: the file format is encrypted, and the cipher is not in the standard library." + ] + }, + { + "cell_type": "code", + "id": "co-03", + "metadata": {}, + "source": [ + "%pip install \"pylabrobot[serial]\" pycryptodome\n", + "\n", + "# On USB, install the FTDI dependencies instead: \"pylabrobot[ftdi]\"." + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-04", + "metadata": {}, + "source": [ + "## Physical setup and finding the port\n", + "\n", + "Install, plumb and power the instrument according to the manufacturer's instructions. Connect the\n", + "buffer bottle to the inlet each step will name, fit a cassette into the peristaltic pump if you\n", + "intend to use it, connect the waste bottle, and connect the instrument to the computer.\n", + "\n", + "Then find the port string:\n", + "\n", + "- **Serial.** Pass the operating system's own name for the port: `COM3` on Windows,\n", + " `/dev/ttyUSB0` or `/dev/ttyS4` on Linux and macOS. Anything that is not a USB serial number is\n", + " taken to be a serial port and passed through unexamined.\n", + "\n", + "- **USB.** List the attached FTDI devices and use the reported serial number:\n", + "\n", + " ```bash\n", + " python -m pylibftdi.examples.list_devices\n", + " ```\n", + "\n", + " The port is then `ftdi:`. The form the instrument's own protocol files record,\n", + " `USB EL406 sn:`, is accepted as well.\n", + "\n", + "Keep the carrier and the area around it clear from here on." + ] + }, + { + "cell_type": "markdown", + "id": "ma-05", + "metadata": {}, + "source": [ + "## Turn on logging\n", + "\n", + "The driver reports what it is doing through the standard library's `logging`, and says nothing\n", + "otherwise. Without this cell the untested-driver warning below, and every step the instrument runs,\n", + "pass silently." + ] + }, + { + "cell_type": "code", + "id": "co-06", + "metadata": {}, + "source": [ + "import logging\n", + "\n", + "logging.basicConfig(level=logging.INFO, format=\"%(levelname)s %(name)s: %(message)s\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-07", + "metadata": {}, + "source": [ + "## See which ports have something behind them\n", + "\n", + "`pyserial` lists every serial port the operating system offers, most of which are kernel\n", + "placeholders with no hardware behind them. Skipping those leaves the ports worth trying, and a\n", + "USB-serial adapter names the instrument it is wired to." + ] + }, + { + "cell_type": "code", + "id": "co-08", + "metadata": {}, + "source": [ + "from serial.tools.list_ports import comports\n", + "\n", + "candidates = [port for port in comports() if port.description != \"n/a\" or port.vid is not None]\n", + "\n", + "for port in candidates:\n", + " serial_number = f\" sn:{port.serial_number}\" if port.serial_number else \"\"\n", + " print(f\"{port.device:16} {port.description}{serial_number}\")\n", + "\n", + "if not candidates:\n", + " print(\"no port has a device behind it; is the instrument powered and connected?\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-09", + "metadata": {}, + "source": [ + "## Build the instrument\n", + "\n", + "Constructing the object opens nothing and touches no hardware. It records which port to use, which\n", + "model this is, and what to call the instrument in logs and error messages." + ] + }, + { + "cell_type": "code", + "id": "co-10", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc import EL406\n", + "\n", + "# The port is a serial one unless it carries a device serial number: on USB, pass\n", + "# \"ftdi:YOUR_SERIAL\" instead.\n", + "device = EL406(port=\"COM3\", name=\"EL406\")\n", + "device" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-11", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` opens the link, asks whether anything is listening, and reads the options the instrument\n", + "has fitted. That read is not optional: every step is encoded against it and every check measures\n", + "against it, so a failure here stops the notebook rather than being carried past.\n", + "\n", + "It raises a `BiotekError` if the port will not open, if nothing answers on it, or if the fitted\n", + "options cannot be read." + ] + }, + { + "cell_type": "code", + "id": "co-12", + "metadata": {}, + "source": [ + "await device.setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-13", + "metadata": {}, + "source": [ + "## Ask what answered\n", + "\n", + "**The model is declared, not discovered.** The instrument does not report which model it is, so it\n", + "is the class you constructed that decides how every step is encoded and which options are read.\n", + "`device.settings.family` echoes what was declared, not what is attached.\n", + "\n", + "What the instrument does report is its serial number, and a firmware version record whose part\n", + "number says which instrument the installed firmware is built for. An EL406 reports a part number\n", + "beginning `718`, and `setup()` has already refused a link whose firmware says otherwise." + ] + }, + { + "cell_type": "code", + "id": "co-14", + "metadata": {}, + "source": [ + "print(\"serial number: \", await device.get_serial_number())\n", + "\n", + "version = await device.get_firmware_version()\n", + "print(\"part number: \", version.part_number)\n", + "print(\"firmware: \", version.software_version)\n", + "print(\"data version: \", version.data_version)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-15", + "metadata": {}, + "source": [ + "## Read the configuration\n", + "\n", + "What `setup()` read is kept as a read-only record of what somebody fitted. It is read-only because\n", + "the instrument is: of its whole command vocabulary almost nothing about the configuration can be\n", + "written, so a record that could be edited would only mislead.\n", + "\n", + "On this model the record decides more than the plate does: which manifold is fitted and therefore\n", + "which plates can be washed, whether buffers other than A can be selected, whether there is an\n", + "ultrasonic cleaner to auto-clean with, and whether wells can be filtered under vacuum." + ] + }, + { + "cell_type": "code", + "id": "co-16", + "metadata": {}, + "source": [ + "settings = device.settings\n", + "\n", + "print(\"family: \", settings.family.name)\n", + "print(\"wash manifold: \", settings.washer_manifold.name)\n", + "print(\"valve box: \", settings.valve_box.name)\n", + "print(\"buffer switching: \", settings.buffer_switching)\n", + "print(\"vacuum filtration: \", settings.vacuum_filtration)\n", + "print(\"ultrasonic: \", settings.ultrasonic)\n", + "print(\"cell washing: \", settings.cell_washing)\n", + "print(\"syringe box: \", settings.syringe_box.name)\n", + "print(\"syringe manifold: \", settings.syringe_manifold.name)\n", + "print(\"peri pump: \", settings.peri_pump)\n", + "print(\"y axis: \", settings.y_axis_installed)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-17", + "metadata": {}, + "source": [ + "## Ask what it can run\n", + "\n", + "A model can be built to run a fixed set of operations; a particular instrument runs the subset its\n", + "fitted hardware supports. `get_available_steps()` has already applied that, which makes it the\n", + "answer to \"why was my step refused\" before you have written the step.\n", + "\n", + "An EL406 with everything fitted runs twelve step types — the four manifold steps plus auto-clean,\n", + "the 1536-well wash, both syringe steps, all three peristaltic ones, and shake/soak." + ] + }, + { + "cell_type": "code", + "id": "co-18", + "metadata": {}, + "source": [ + "for step_type in device.get_available_steps():\n", + " print(step_type.name)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-19", + "metadata": {}, + "source": [ + "## Tell it which plate is on the carrier\n", + "\n", + "Nothing runs until a plate has been set. Every step carries the height it works at, measured from\n", + "the nominal heights of the format on the carrier, so without one there is nothing to measure from\n", + "and the driver raises `RejectedError` rather than guessing.\n", + "\n", + "The format is resolved from the PyLabRobot plate resource itself — its columns, its rows, and how\n", + "deep its wells are. An EL406 works five: 96-well, 384-well, 384-well PCR, 1536-well and\n", + "1536-flanged. The PCR and flanged formats share their shape with an ordinary plate and differ in\n", + "something a resource does not carry, so they are only ever named outright." + ] + }, + { + "cell_type": "code", + "id": "co-20", + "metadata": {}, + "source": [ + "from pylabrobot.resources import cor_96_wellplate_360uL_Fb\n", + "\n", + "plate = cor_96_wellplate_360uL_Fb(name=\"plate\")\n", + "device.set_plate(plate)\n", + "\n", + "print(device.plate)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-21", + "metadata": {}, + "source": [ + "## Check before running\n", + "\n", + "`can_run()` measures steps against the instrument as it is now — what is fitted, which plate is on\n", + "the carrier, and what the plate will accept — and touches nothing. It is what `run_protocol()` does\n", + "first, so calling it yourself is how you see a refusal without moving anything.\n", + "\n", + "The report is truthy when everything can run, and prints as the list of what cannot." + ] + }, + { + "cell_type": "code", + "id": "co-22", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_prime import ManifoldPrime\n", + "\n", + "report = await device.can_run([ManifoldPrime(volume=10_000, buffer=\"A\")])\n", + "print(report)\n", + "print(\"can run:\", bool(report))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-23", + "metadata": {}, + "source": [ + "## Prime the wash manifold\n", + "\n", + "Priming pumps buffer through the manifold until its lines are full and sends it to waste. Nothing is\n", + "dispensed into the plate, which makes it the safest operation to try first — but it does move fluid,\n", + "so check the bottles before running this cell.\n", + "\n", + "The volume is in microlitres and the instrument meters it at millilitre resolution. The default is\n", + "40 mL, a full prime of dry lines; 10 mL is enough to see the pump run." + ] + }, + { + "cell_type": "code", + "id": "co-24", + "metadata": {}, + "source": [ + "await device.washer.prime(volume=10_000, buffer=\"A\", flow_rate=9)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-25", + "metadata": {}, + "source": [ + "## Dispense, aspirate, and wash\n", + "\n", + "**These dispense into the plate**, so put one on the carrier you are willing to fill. A dispense\n", + "fills every selected well; an aspirate empties them; a wash is the two of them repeated, and is one\n", + "step rather than a loop — the instrument runs the cycles itself.\n", + "\n", + "The refill a wash performs needs a volume of its own: the step type defaults to no volume at all, so\n", + "a wash that names none is refused before anything moves." + ] + }, + { + "cell_type": "code", + "id": "co-26", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n", + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.manifold_dispense import ManifoldDispense\n", + "\n", + "async with device.batch(home_on_close=True):\n", + " await device.washer.dispense(volume=100, buffer=\"A\", flow_rate=7)\n", + " await device.washer.aspirate()\n", + " await device.washer.wash(\n", + " cycles=2,\n", + " dispense=ManifoldDispense(\n", + " volume=100,\n", + " buffer=\"A\",\n", + " positioning=Positioning(z_steps=device.plate.manifold_dispense_height),\n", + " ),\n", + " )" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-27", + "metadata": {}, + "source": [ + "### One buffer inlet per run, unless a valve can switch it\n", + "\n", + "A step names the inlet it draws from — `\"A\"` through `\"D\"`. Without the buffer switching valve the\n", + "whole protocol is committed to the first inlet a step names, and a later step naming a different one\n", + "is refused on the second of the pair, because nothing switches the line mid-run. With the valve\n", + "fitted, a protocol may draw from several.\n", + "\n", + "The cell below asks the record first, so it is safe to run on any instrument." + ] + }, + { + "cell_type": "code", + "id": "co-28", + "metadata": {}, + "source": [ + "if device.settings.buffer_switching:\n", + " async with device.batch(home_on_close=True):\n", + " await device.washer.prime(volume=5_000, buffer=\"A\")\n", + " await device.washer.prime(volume=5_000, buffer=\"B\")\n", + "else:\n", + " print(\"no buffer switching valve fitted; one inlet for the whole protocol\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-29", + "metadata": {}, + "source": [ + "## Auto-clean, and filtering under vacuum\n", + "\n", + "Two options that add operations of their own rather than changing existing ones.\n", + "\n", + "**Auto-clean** soaks the manifold in cleaning fluid and needs the ultrasonic cleaner; its duration\n", + "is in seconds. **Vacuum filtration** pulls the wells through a filter plate instead of aspirating\n", + "from above, and needs both the module and the filtration carrier — the instrument is asked which\n", + "carrier is fitted, and a filtering aspirate is refused if it is the wrong one. Under vacuum the\n", + "aspirate's `delay` is a filtration time in seconds rather than a delay in milliseconds." + ] + }, + { + "cell_type": "code", + "id": "co-30", + "metadata": {}, + "source": [ + "if device.settings.ultrasonic:\n", + " await device.washer.auto_clean(duration=600, buffer=\"A\")\n", + "else:\n", + " print(\"no ultrasonic cleaner fitted; nothing to do\")\n", + "\n", + "if device.settings.vacuum_filtration:\n", + " await device.washer.aspirate(vacuum_filtration=True, delay=30)\n", + "else:\n", + " print(\"no vacuum filtration fitted; nothing to do\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-31", + "metadata": {}, + "source": [ + "## The syringes\n", + "\n", + "The syringe box meters a volume precisely, where the manifold delivers what its pump pushes. Volumes\n", + "are per well in µL, and each syringe draws from one of two supply bottles named per step.\n", + "\n", + "Which syringes a step may drive is decided by the fitted *manifold*, not by the box: on the plain\n", + "16-tube manifold each step drives one syringe. A prime drives one regardless. A syringe is also\n", + "committed to one bottle for the whole run unless the syringe-side switching valve is fitted." + ] + }, + { + "cell_type": "code", + "id": "co-32", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType\n", + "\n", + "if device.settings.syringe_box is not SyringeBoxType.NOT_INSTALLED:\n", + " async with device.batch(home_on_close=True):\n", + " await device.syringe_dispenser.prime(volume=5_000, syringe=\"A\", syringe_bottle=\"A1\")\n", + " await device.syringe_dispenser.dispense(volume=50, syringe=\"A\", syringe_bottle=\"A1\")\n", + "else:\n", + " print(\"no syringe box fitted; nothing to do\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-33", + "metadata": {}, + "source": [ + "## The peristaltic pump\n", + "\n", + "One pump, one tubing cassette. Priming fills the tubing; purging empties it, running fluid to waste\n", + "rather than into the plate — which is what you want at the end of a run and before a cassette comes\n", + "out. A dispense meters a volume per tube; the flow rate is one of three named speeds.\n", + "\n", + "This model has a single pump, so a step naming the secondary one is refused with a reason rather\n", + "than quietly running on the primary." + ] + }, + { + "cell_type": "code", + "id": "co-34", + "metadata": {}, + "source": [ + "if device.settings.peri_pump:\n", + " async with device.batch(home_on_close=True):\n", + " await device.peristaltic_dispenser.prime(volume=300, peri_pump=\"Primary\")\n", + " await device.peristaltic_dispenser.dispense(volume=100, peri_pump=\"Primary\")\n", + " await device.peristaltic_dispenser.purge(volume=300, peri_pump=\"Primary\")\n", + "else:\n", + " print(\"no peristaltic pump fitted; nothing to do\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-35", + "metadata": {}, + "source": [ + "## Washing a 1536-well plate\n", + "\n", + "A 1536-well plate has a wash of its own, and it is a different step: the ordinary wash is refused on\n", + "one. The refill comes from the **syringes** rather than the manifold, so its dispense is a\n", + "`SyringeDispense` — which means it needs two things fitted, not one: the 128-tube wash manifold, and\n", + "one of the 32-tube syringe manifolds, the only ones a 1536-well plate accepts.\n", + "\n", + "Selections work in sectors there rather than columns, which is what lets a manifold with fewer tubes\n", + "than the plate has wells cover it in several passes." + ] + }, + { + "cell_type": "code", + "id": "co-36", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold\n", + "from pylabrobot.agilent.biotek.lhc.enums.instrument.washer_manifold import WasherManifold\n", + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense\n", + "\n", + "THIRTY_TWO_TUBE = {SyringeManifold.TUBE_32_LARGE_BORE, SyringeManifold.TUBE_32_SMALL_BORE}\n", + "\n", + "if (\n", + " device.plate.wells == 1536\n", + " and device.settings.washer_manifold is WasherManifold.TUBE_128\n", + " and device.settings.syringe_manifold in THIRTY_TWO_TUBE\n", + "):\n", + " await device.washer.wash_1536(cycles=2, dispense=SyringeDispense(volume=5, syringe=\"A\"))\n", + "else:\n", + " print(\"this needs a 1536-well plate, a 128-tube wash manifold and a 32-tube syringe manifold\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-37", + "metadata": {}, + "source": [ + "## Shake and soak\n", + "\n", + "Shaking and soaking are one step, and either half can be left out by giving it no time: `duration`\n", + "shakes, `soak_duration` leaves the plate still afterwards. Both are in seconds. This is the one\n", + "operation every instrument in the family offers, whatever is fitted." + ] + }, + { + "cell_type": "code", + "id": "co-38", + "metadata": {}, + "source": [ + "await device.shake(duration=30, intensity=\"Medium\", axis=\"X\", soak_duration=30)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-39", + "metadata": {}, + "source": [ + "## Where in the well a step works\n", + "\n", + "Every operation that reaches into the plate takes a `positioning`, and defaults it to the nominal\n", + "position for that head over the format on the carrier. Three numbers:\n", + "\n", + "- `z_steps` — how deep the head goes. **This is a height, not an offset**: it defaults to the plate\n", + " record's own nominal height for the head, and a larger number reaches further down into the well.\n", + " The manifold works at 1-210 here; the syringes and the pump reach 1-1500.\n", + "- `x_steps` — across the well: -60 to 60 for the manifold, ±125 for the syringes and the pump.\n", + "- `y_steps` — along the well, ±40 for every head, and it needs the Y axis to be fitted.\n", + "\n", + "The nominal heights come from the plate record, so they change with the plate and differ per head:\n", + "the manifold dispenses and aspirates at two different heights, and the syringes at a third." + ] + }, + { + "cell_type": "code", + "id": "co-40", + "metadata": {}, + "source": [ + "print(\"nominal dispensing height: \", device.plate.manifold_dispense_height)\n", + "print(\"nominal aspirating height: \", device.plate.manifold_aspirate_height)\n", + "print(\"nominal syringe height: \", device.plate.dispenser_height)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-41", + "metadata": {}, + "source": [ + "```{note}\n", + "The offsets are in motor steps rather than millimetres, which is the one place this package deviates\n", + "from PyLabRobot's convention — the field names say so. The conversion differs per axis, per model\n", + "and per head, and only part of it is established, so the package does not convert. A step is also\n", + "what a protocol file stores, which is what lets a protocol be read and written with no instrument to\n", + "ask.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-42", + "metadata": {}, + "source": [ + "## Run several operations in one batch\n", + "\n", + "Opening a batch homes the motors, takes the instrument so that nothing else can interleave a run on\n", + "it, and holds it until the block ends. Every operation opens one; doing it once around several\n", + "operations — as the cells above have been doing — is what stops the motors being homed between each\n", + "of them.\n", + "\n", + "`home_on_close=True` drives the transport home before the batch closes. The instrument does not do\n", + "this by itself; ask for it when the next thing to touch the plate is a person. Nesting is allowed\n", + "and does nothing: an operation called inside an open batch joins it." + ] + }, + { + "cell_type": "code", + "id": "co-43", + "metadata": {}, + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.washer.prime(volume=5_000, buffer=\"A\")\n", + " await device.washer.dispense(volume=100, buffer=\"A\")\n", + " await device.shake(duration=15, soak_duration=0)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-44", + "metadata": {}, + "source": [ + "## Watch a step, and stop it\n", + "\n", + "`get_status()` reports what the instrument is doing, which timed phase a running step is in, and how\n", + "many seconds are left in it. It can be called at any time, including while a step is running.\n", + "\n", + "An operation does not return until its step has finished, so pausing or aborting means asking from\n", + "somewhere else while it runs. In a notebook that is a task. Abort makes the waiting operation raise\n", + "`AbortedError`, so a protocol run ends where it was stopped." + ] + }, + { + "cell_type": "code", + "id": "co-45", + "metadata": {}, + "source": [ + "import asyncio\n", + "\n", + "from pylabrobot.agilent.biotek.lhc.error_handling import AbortedError\n", + "\n", + "running = asyncio.create_task(device.washer.prime(volume=40_000, buffer=\"A\"))\n", + "await asyncio.sleep(2)\n", + "\n", + "status = await device.get_status()\n", + "print(\"state: \", status.state.name)\n", + "print(\"activity: \", status.activity.name)\n", + "print(\"remaining:\", status.remaining, \"s\")\n", + "\n", + "await device.abort()\n", + "try:\n", + " await running\n", + "except AbortedError as error:\n", + " print(\"stopped:\", error)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-46", + "metadata": {}, + "source": [ + "## Run a protocol file\n", + "\n", + "A `.LHC` protocol file is read into a `Protocol`: what it will run, and everything the file records\n", + "alongside it. Reading needs `pycryptodome`, installed at the top of this notebook.\n", + "\n", + "Reading a file never fails on a step it cannot understand — the file's own records are kept as they\n", + "are, so a protocol from another model still reads, prints and writes. `build_steps()` is what turns\n", + "those records into steps, and it names the one that will not read." + ] + }, + { + "cell_type": "code", + "id": "co-47", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc import read\n", + "\n", + "protocol = read(\"path/to/your/protocol.LHC\")\n", + "\n", + "print(\"name: \", protocol.protocol_name)\n", + "print(\"written by:\", protocol.lhc_version)\n", + "print(\"written for:\", protocol.instrument_name)\n", + "print(\"plate: \", protocol.plate_type or protocol.plate_type_number)\n", + "print(\n", + " \"entries: \",\n", + " len(protocol.entries),\n", + " \"of which\",\n", + " len(protocol.device_entries),\n", + " \"operate the instrument\",\n", + ")\n", + "\n", + "for index, step in enumerate(protocol.build_steps()):\n", + " print(f\" step {index}: {type(step).__name__}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-48", + "metadata": {}, + "source": [ + "### What the file says about its instrument\n", + "\n", + "A protocol file records the options the instrument had fitted when it was written. Nothing runs\n", + "against that record — steps are encoded against the instrument in front of you — and nothing writes\n", + "it to the instrument. It is good for exactly one question, worth asking about a file that came from\n", + "another machine: was this written for a differently equipped instrument?\n", + "\n", + "`compare_settings()` is truthy when the two agree, and prints as the options that differ. It raises\n", + "`ValueError` for a file that carries no such record, which is how the oldest releases wrote one." + ] + }, + { + "cell_type": "code", + "id": "co-49", + "metadata": {}, + "source": [ + "comparison = device.compare_settings(protocol)\n", + "print(comparison)\n", + "print(\"same configuration:\", bool(comparison))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-50", + "metadata": {}, + "source": [ + "### Running it\n", + "\n", + "`run_protocol()` checks the protocol, opens one batch around the whole run, sends each step and\n", + "polls it to completion. The check is the same `can_run()` from above and happens automatically, so a\n", + "protocol that cannot run raises before anything moves.\n", + "\n", + "**This runs whatever the protocol does**, which for most washer protocols means washing every well\n", + "of the plate on the carrier. Read the steps printed above first.\n", + "\n", + "The entries that sequence a run rather than operate the instrument — delays, loops, remarks — are\n", + "not run; the device steps go in file order. They are still on `protocol.entries` to inspect." + ] + }, + { + "cell_type": "code", + "id": "co-51", + "metadata": {}, + "source": [ + "await device.run_protocol(protocol, home_on_close=True)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-52", + "metadata": {}, + "source": [ + "Steps built in Python run the same way. `run_protocol()` takes a list of steps as readily\n", + "as a protocol, and `run_step()` runs a single one." + ] + }, + { + "cell_type": "code", + "id": "co-53", + "metadata": {}, + "source": [ + "await device.run_protocol(\n", + " [\n", + " ManifoldPrime(volume=10_000, buffer=\"A\"),\n", + " ManifoldDispense(\n", + " volume=100,\n", + " buffer=\"A\",\n", + " positioning=Positioning(z_steps=device.plate.manifold_dispense_height),\n", + " ),\n", + " ],\n", + " home_on_close=True,\n", + ")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-54", + "metadata": {}, + "source": [ + "## Home the transport and disconnect\n", + "\n", + "Homing drives the transport to its home position and confirms it arrived. Do it before a person\n", + "reaches for the plate, unless the last batch already closed with `home_on_close=True`.\n", + "\n", + "`stop()` closes the link. It does nothing on an instrument that is already closed, so it is safe to\n", + "run this cell twice, and it is worth running from a `finally` in a script so that a failed run does\n", + "not leave the port open." + ] + }, + { + "cell_type": "code", + "id": "co-55", + "metadata": {}, + "source": [ + "await device.home()\n", + "await device.stop()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-56", + "metadata": {}, + "source": [ + "```{note}\n", + "The fluid left in the manifold and the cassette after a run is the instrument's problem, not the\n", + "driver's. Follow the manufacturer's shutdown and maintenance procedure — `auto_clean(...)` soaks the\n", + "manifold, `peristaltic_dispenser.purge(...)` empties a cassette before it comes out, and most\n", + "maintenance routines ship as protocol files you can run with `run_protocol()`.\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/agilent/multiflo/hello-world.ipynb b/docs/user_guide/agilent/multiflo/hello-world.ipynb new file mode 100644 index 00000000000..31fbdf9cfc1 --- /dev/null +++ b/docs/user_guide/agilent/multiflo/hello-world.ipynb @@ -0,0 +1,853 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ma-00", + "metadata": {}, + "source": [ + "# Agilent BioTek MultiFlo dispenser quickstart\n", + "\n", + "The MultiFlo is a bulk dispenser and nothing else: it fills wells from syringes, which meter a\n", + "volume precisely, and from peristaltic pumps, which push fluid through a tubing cassette. There is\n", + "no wash manifold, so washing a plate is not something this model does.\n", + "\n", + "It is also the oldest firmware in the family, and that shows: it predates dispensing into\n", + "individually chosen wells and cannot store the fields for it, so such a step is refused rather than\n", + "run across the whole plate.\n", + "\n", + "This quickstart connects to the dispenser, reads what it is and what it has fitted, tells it which\n", + "plate is on its carrier, primes and dispenses from both kinds of pump, explains where in the well a\n", + "step works, runs a protocol file, and disconnects.\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Communication | A serial port, or USB through an FTDI interface |\n", + "| Serial parameters | 38400 baud, 8 data bits, 2 stop bits, no parity, no flow control |\n", + "| Operations | Syringe priming and dispensing, peristaltic priming, purging and dispensing, shake and soak |\n", + "| Plate formats | 96- and 384-well including deep-well, 96 half-well, mini tubes, 1536-well |\n", + "| Syringes | A and B, each with two selectable bottles; both at once on the manifolds that allow it |\n", + "| Peristaltic pumps | Primary and secondary, one tubing cassette each |\n", + "| Dispenser reach | Depth 1-1500 motor steps; across the well ±125, along the well ±40 |\n", + "| Protocol files | `.LHC` protocol files are read, checked and run |\n", + "\n", + "```{warning}\n", + "This driver has not yet been checked against a real MultiFlo. `setup()` says so in the log every\n", + "time it runs. Every step it sends has been checked against the vendor's own interface library —\n", + "frame for frame, and against the same validation the instrument applies — but that is not the same\n", + "as having moved fluid. Verify every protocol on labware and fluid you can afford to lose, keep a\n", + "hand on the power switch the first time the carrier moves, and please report what you find on the\n", + "[PyLabRobot forum](https://discuss.pylabrobot.org) so the warning can be removed.\n", + "```\n", + "\n", + "```{warning}\n", + "Follow the manufacturer's installation, fluid-handling and safety instructions. Priming, purging and\n", + "dispensing all move fluid: the bottles must be full and the waste bottle empty enough before\n", + "anything in this notebook runs.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-01", + "metadata": {}, + "source": [ + "```{device-card} biotek-multiflo\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-02", + "metadata": {}, + "source": [ + "## How it communicates\n", + "\n", + "PyLabRobot frames each command as an 11-byte header and a payload, writes it to the instrument,\n", + "reads back an acknowledgement and the reply, and turns a non-zero status into a typed exception.\n", + "Which of the two transports carries those bytes is decided by the port string alone, and nothing\n", + "above that point knows which one it got.\n", + "\n", + "Operations that move fluid do not answer when they are done. The driver sends them, then polls the\n", + "instrument's run state until it stops reporting a step in progress, which is why every method that\n", + "touches the instrument is awaited and can take as long as the physical operation does.\n", + "\n", + "Reading `.LHC` protocol files additionally needs `pycryptodome`, which is not a PyLabRobot\n", + "dependency: the file format is encrypted, and the cipher is not in the standard library." + ] + }, + { + "cell_type": "code", + "id": "co-03", + "metadata": {}, + "source": [ + "%pip install \"pylabrobot[serial]\" pycryptodome\n", + "\n", + "# On USB, install the FTDI dependencies instead: \"pylabrobot[ftdi]\"." + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-04", + "metadata": {}, + "source": [ + "## Physical setup and finding the port\n", + "\n", + "Install, plumb and power the dispenser according to the manufacturer's instructions. Fit the tubing\n", + "cassettes into the pumps you intend to use, connect each syringe to the bottle it draws from,\n", + "connect the waste bottle, and connect the instrument to the computer.\n", + "\n", + "Then find the port string:\n", + "\n", + "- **Serial.** Pass the operating system's own name for the port: `COM3` on Windows,\n", + " `/dev/ttyUSB0` or `/dev/ttyS4` on Linux and macOS. Anything that is not a USB serial number is\n", + " taken to be a serial port and passed through unexamined.\n", + "\n", + "- **USB.** List the attached FTDI devices and use the reported serial number:\n", + "\n", + " ```bash\n", + " python -m pylibftdi.examples.list_devices\n", + " ```\n", + "\n", + " The port is then `ftdi:`. The form the instrument's own protocol files record,\n", + " `USB MultiFlo sn:`, is accepted as well.\n", + "\n", + "Keep the carrier and the area around it clear from here on." + ] + }, + { + "cell_type": "markdown", + "id": "ma-05", + "metadata": {}, + "source": [ + "## Turn on logging\n", + "\n", + "The driver reports what it is doing through the standard library's `logging`, and says nothing\n", + "otherwise. Without this cell the untested-driver warning below, and every step the instrument runs,\n", + "pass silently." + ] + }, + { + "cell_type": "code", + "id": "co-06", + "metadata": {}, + "source": [ + "import logging\n", + "\n", + "logging.basicConfig(level=logging.INFO, format=\"%(levelname)s %(name)s: %(message)s\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-07", + "metadata": {}, + "source": [ + "## See which ports have something behind them\n", + "\n", + "`pyserial` lists every serial port the operating system offers, most of which are kernel\n", + "placeholders with no hardware behind them. Skipping those leaves the ports worth trying, and a\n", + "USB-serial adapter names the instrument it is wired to." + ] + }, + { + "cell_type": "code", + "id": "co-08", + "metadata": {}, + "source": [ + "from serial.tools.list_ports import comports\n", + "\n", + "candidates = [port for port in comports() if port.description != \"n/a\" or port.vid is not None]\n", + "\n", + "for port in candidates:\n", + " serial_number = f\" sn:{port.serial_number}\" if port.serial_number else \"\"\n", + " print(f\"{port.device:16} {port.description}{serial_number}\")\n", + "\n", + "if not candidates:\n", + " print(\"no port has a device behind it; is the dispenser powered and connected?\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-09", + "metadata": {}, + "source": [ + "## Build the dispenser\n", + "\n", + "Constructing the object opens nothing and touches no hardware. It records which port to use, which\n", + "model this is, and what to call the instrument in logs and error messages." + ] + }, + { + "cell_type": "code", + "id": "co-10", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc import MultiFlo\n", + "\n", + "# The port is a serial one unless it carries a device serial number: on USB, pass\n", + "# \"ftdi:YOUR_SERIAL\" instead.\n", + "device = MultiFlo(port=\"COM3\", name=\"MultiFlo\")\n", + "device" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-11", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` opens the link, asks whether anything is listening, and reads the options the instrument\n", + "has fitted. That read is not optional: every step is encoded against it and every check measures\n", + "against it, so a failure here stops the notebook rather than being carried past.\n", + "\n", + "It raises a `BiotekError` if the port will not open, if nothing answers on it, or if the fitted\n", + "options cannot be read." + ] + }, + { + "cell_type": "code", + "id": "co-12", + "metadata": {}, + "source": [ + "await device.setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-13", + "metadata": {}, + "source": [ + "## Ask what answered\n", + "\n", + "**The model is declared, not discovered.** The instrument does not report which model it is, so it\n", + "is the class you constructed that decides how every step is encoded and which options are read.\n", + "`device.settings.family` echoes what was declared, not what is attached.\n", + "\n", + "What the instrument does report is its serial number, and a firmware version record whose part\n", + "number says which instrument the installed firmware is built for. A MultiFlo reports a part number\n", + "beginning `721`, and `setup()` has already refused a link whose firmware says otherwise." + ] + }, + { + "cell_type": "code", + "id": "co-14", + "metadata": {}, + "source": [ + "print(\"serial number: \", await device.get_serial_number())\n", + "\n", + "version = await device.get_firmware_version()\n", + "print(\"part number: \", version.part_number)\n", + "print(\"firmware: \", version.software_version)\n", + "print(\"data version: \", version.data_version)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-15", + "metadata": {}, + "source": [ + "## Read the configuration\n", + "\n", + "What `setup()` read is kept as a read-only record of what somebody fitted. It is read-only because\n", + "the instrument is: of its whole command vocabulary almost nothing about the configuration can be\n", + "written, so a record that could be edited would only mislead.\n", + "\n", + "On this model the record is short, because there is less to fit: the syringe box and its manifold,\n", + "and whether each peristaltic pump is there. The wash manifold, valve box and the modules that go\n", + "with them are only asked about on the models that can carry them, so they read as not fitted here\n", + "without a query being sent." + ] + }, + { + "cell_type": "code", + "id": "co-16", + "metadata": {}, + "source": [ + "settings = device.settings\n", + "\n", + "print(\"family: \", settings.family.name)\n", + "print(\"syringe box: \", settings.syringe_box.name)\n", + "print(\"syringe box size: \", settings.syringe_box_size.name)\n", + "print(\"syringe manifold: \", settings.syringe_manifold.name)\n", + "print(\"primary peri pump: \", settings.peri_pump)\n", + "print(\"secondary peri pump: \", settings.peri_pump_2)\n", + "print(\"half-µL volumes: \", settings.half_ul_enabled)\n", + "print(\"wash manifold: \", settings.washer_manifold.name)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-17", + "metadata": {}, + "source": [ + "## Ask what it can run\n", + "\n", + "A model can be built to run a fixed set of operations; a particular instrument runs the subset its\n", + "fitted hardware supports. `get_available_steps()` has already applied that, which makes it the\n", + "answer to \"why was my step refused\" before you have written the step.\n", + "\n", + "This model's whole palette is six step types: both syringe steps, all three peristaltic ones, and\n", + "shake/soak. There are no manifold steps and no strip washer steps to be had, whatever is fitted." + ] + }, + { + "cell_type": "code", + "id": "co-18", + "metadata": {}, + "source": [ + "for step_type in device.get_available_steps():\n", + " print(step_type.name)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-19", + "metadata": {}, + "source": [ + "### What the oldest firmware does not check\n", + "\n", + "A step type this firmware never knew is refused outright. What is worth knowing is the other\n", + "direction: eleven of the rules the newer models apply are **absent** here, because the features they\n", + "guard did not exist yet — single-well dispensing, the strip washer, the small-plate syringe\n", + "manifolds and the mini-tube carrier.\n", + "\n", + "Most of those are unreachable anyway on a model that offers neither strip steps nor random access.\n", + "One is not: an ordinary peristaltic dispense reaches the single-well rule on newer firmware and is\n", + "measured against it, and here it is not. So a step this instrument accepts is not proof the same\n", + "step would be accepted by a MultiFlo FX." + ] + }, + { + "cell_type": "markdown", + "id": "ma-20", + "metadata": {}, + "source": [ + "## Tell it which plate is on the carrier\n", + "\n", + "Nothing runs until a plate has been set. Every step carries the height it works at, measured from\n", + "the nominal heights of the format on the carrier, so without one there is nothing to measure from\n", + "and the driver raises `RejectedError` rather than guessing.\n", + "\n", + "The format is resolved from the PyLabRobot plate resource itself — its columns, its rows, and how\n", + "deep its wells are. This model works nine formats, from 96- and 384-well plates through their\n", + "deep-well variants to 1536-well ones. The half-well, mini-tube, PCR and flanged formats share their\n", + "shape with an ordinary plate and differ in something a resource does not carry, so they are only\n", + "ever named outright." + ] + }, + { + "cell_type": "code", + "id": "co-21", + "metadata": {}, + "source": [ + "from pylabrobot.resources import cor_96_wellplate_360uL_Fb\n", + "\n", + "plate = cor_96_wellplate_360uL_Fb(name=\"plate\")\n", + "device.set_plate(plate)\n", + "\n", + "print(device.plate)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "co-22", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType\n", + "\n", + "device.set_plate(plate, plate_type=PlateType.PLATE_96_HALF_WELL)\n", + "print(device.plate)\n", + "\n", + "device.set_plate(plate) # back to what it resolves to on its own" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-23", + "metadata": {}, + "source": [ + "## Check before running\n", + "\n", + "`can_run()` measures steps against the instrument as it is now — what is fitted, which plate is on\n", + "the carrier, and what the plate will accept — and touches nothing. It is what `run_protocol()` does\n", + "first, so calling it yourself is how you see a refusal without moving anything.\n", + "\n", + "The report is truthy when everything can run, and prints as the list of what cannot. A volume the\n", + "syringe will not meter is refused with the range it would accept; that floor moves with the plate on\n", + "the carrier and the flow rate, so read it off the message rather than memorising it." + ] + }, + { + "cell_type": "code", + "id": "co-24", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime\n", + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense\n", + "\n", + "print(await device.can_run([PeriPrime(volume=300, peri_pump=\"Primary\")]))\n", + "print(await device.can_run([SyringeDispense(volume=1, syringe=\"A\")]))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-25", + "metadata": {}, + "source": [ + "## Prime and dispense from the syringes\n", + "\n", + "Priming draws fluid through a syringe until its lines are full. It is the safest operation to try\n", + "first — nothing is dispensed into the plate — but it does move fluid, so check the bottles first.\n", + "\n", + "A prime drives **one** syringe: unlike a dispense it cannot run both at once. Each syringe draws\n", + "from one of its two bottles, named per step, and stays committed to that bottle for the whole run\n", + "unless the syringe-side switching valve is fitted. Whether a dispense may drive both at once is\n", + "decided by the fitted manifold; on the plain 16-tube one each step drives a single syringe.\n", + "\n", + "**The dispense fills the plate**, so put one on the carrier you are willing to fill." + ] + }, + { + "cell_type": "code", + "id": "co-26", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_box_type import SyringeBoxType\n", + "\n", + "if device.settings.syringe_box is not SyringeBoxType.NOT_INSTALLED:\n", + " async with device.batch(home_on_close=True):\n", + " await device.syringe_dispenser.prime(volume=5_000, syringe=\"A\", syringe_bottle=\"A1\")\n", + " await device.syringe_dispenser.dispense(volume=50, syringe=\"A\", syringe_bottle=\"A1\")\n", + "else:\n", + " print(\"no syringe box fitted; nothing to do\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-27", + "metadata": {}, + "source": [ + "## Prime, purge and dispense from the peristaltic pumps\n", + "\n", + "A peristaltic pump pushes fluid through a tubing cassette. Priming fills the tubing; purging empties\n", + "it, running fluid to waste rather than into the plate — which is what you want at the end of a run\n", + "and before a cassette comes out. Both take either a volume per tube or a duration: give `duration`\n", + "and the step runs for that many seconds instead of metering a volume.\n", + "\n", + "A step names the pump it wants and may name the cassette it needs. `\"Any\"` accepts whatever is\n", + "fitted; naming `\"1uL\"`, `\"5uL\"` or `\"10uL\"` is a requirement checked before anything moves, and two\n", + "steps wanting different cassettes in the same pump are refused on the second of the pair." + ] + }, + { + "cell_type": "code", + "id": "co-28", + "metadata": {}, + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.peristaltic_dispenser.prime(volume=300, peri_pump=\"Primary\")\n", + " await device.peristaltic_dispenser.dispense(volume=100, peri_pump=\"Primary\")\n", + " if device.settings.peri_pump_2:\n", + " await device.peristaltic_dispenser.dispense(\n", + " volume=100, flow_rate=\"Low\", cassette_type=\"5uL\", peri_pump=\"Secondary\"\n", + " )\n", + "\n", + "await device.peristaltic_dispenser.purge(volume=300, peri_pump=\"Primary\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-29", + "metadata": {}, + "source": [ + "## Shake and soak\n", + "\n", + "Shaking and soaking are one step, and either half can be left out by giving it no time: `duration`\n", + "shakes, `soak_duration` leaves the plate still afterwards. Both are in seconds. This is the one\n", + "operation every instrument in the family offers, whatever is fitted." + ] + }, + { + "cell_type": "code", + "id": "co-30", + "metadata": {}, + "source": [ + "await device.shake(duration=30, intensity=\"Medium\", axis=\"X\", soak_duration=30)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-31", + "metadata": {}, + "source": [ + "## Working part of a plate\n", + "\n", + "Every step that reaches into the plate takes a `columns` selection, and most take a `rows` one as\n", + "well. Both default to everything.\n", + "\n", + "A selection is a `WellMask`: one entry per position, `1` to work it and `0` to skip it, and\n", + "`from_columns` builds one from column numbers counted from one. Rows go in sections of eight rather\n", + "than one at a time — a plate has `rows // 8` of them — and they bite only where the head covers less\n", + "than the plate, which for this model means the peristaltic cassettes on a plate of 384 wells or\n", + "more. Selecting no column is allowed and dispenses nothing; an empty row selection is refused where\n", + "the step checks rows." + ] + }, + { + "cell_type": "code", + "id": "co-32", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask\n", + "\n", + "first_two = WellMask.from_columns([1, 2])\n", + "print(\"selected columns:\", first_two.selected_count)\n", + "\n", + "await device.peristaltic_dispenser.dispense(volume=100, peri_pump=\"Primary\", columns=first_two)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-33", + "metadata": {}, + "source": [ + "## Where in the well a step works\n", + "\n", + "Every operation that reaches into the plate takes a `positioning`, and defaults it to the nominal\n", + "position for that head over the format on the carrier. Three numbers:\n", + "\n", + "- `z_steps` — how deep the dispense tubes go. **This is a height, not an offset**: it defaults to\n", + " the plate record's own nominal height for the head, and a larger number reaches further down into\n", + " the well. On this model it must be 1-1500.\n", + "- `x_steps` — across the well, ±125.\n", + "- `y_steps` — along the well, ±40.\n", + "\n", + "The nominal heights come from the plate record, so they change with the plate." + ] + }, + { + "cell_type": "code", + "id": "co-34", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n", + "\n", + "print(\"nominal dispensing height:\", device.plate.dispenser_height)\n", + "\n", + "await device.peristaltic_dispenser.dispense(\n", + " volume=100,\n", + " peri_pump=\"Primary\",\n", + " positioning=Positioning(\n", + " z_steps=device.plate.dispenser_height - 20, # 20 steps higher in the well\n", + " x_steps=15, # toward one side\n", + " y_steps=0,\n", + " ),\n", + ")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-35", + "metadata": {}, + "source": [ + "```{note}\n", + "The offsets are in motor steps rather than millimetres, which is the one place this package deviates\n", + "from PyLabRobot's convention — the field names say so. The conversion differs per axis, per model\n", + "and per head, and only part of it is established, so the package does not convert. A step is also\n", + "what a protocol file stores, which is what lets a protocol be read and written with no instrument to\n", + "ask.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-36", + "metadata": {}, + "source": [ + "## Run several operations in one batch\n", + "\n", + "Opening a batch homes the motors, takes the instrument so that nothing else can interleave a run on\n", + "it, and holds it until the block ends. Every operation opens one; doing it once around several\n", + "operations — as the cells above have been doing — is what stops the motors being homed between each\n", + "of them.\n", + "\n", + "`home_on_close=True` drives the transport home before the batch closes. The instrument does not do\n", + "this by itself; ask for it when the next thing to touch the plate is a person. Nesting is allowed\n", + "and does nothing: an operation called inside an open batch joins it." + ] + }, + { + "cell_type": "code", + "id": "co-37", + "metadata": {}, + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.peristaltic_dispenser.prime(volume=300, peri_pump=\"Primary\")\n", + " await device.peristaltic_dispenser.dispense(volume=100, peri_pump=\"Primary\")\n", + " await device.shake(duration=15, soak_duration=0)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-38", + "metadata": {}, + "source": [ + "## Watch a step, and stop it\n", + "\n", + "`get_status()` reports what the instrument is doing, which timed phase a running step is in, and how\n", + "many seconds are left in it. It can be called at any time, including while a step is running.\n", + "\n", + "An operation does not return until its step has finished, so pausing or aborting means asking from\n", + "somewhere else while it runs. In a notebook that is a task. Abort makes the waiting operation raise\n", + "`AbortedError`, so a protocol run ends where it was stopped rather than carrying on." + ] + }, + { + "cell_type": "code", + "id": "co-39", + "metadata": {}, + "source": [ + "import asyncio\n", + "\n", + "from pylabrobot.agilent.biotek.lhc.error_handling import AbortedError\n", + "\n", + "running = asyncio.create_task(\n", + " device.peristaltic_dispenser.prime(duration=30, peri_pump=\"Primary\")\n", + ")\n", + "await asyncio.sleep(2)\n", + "\n", + "status = await device.get_status()\n", + "print(\"state: \", status.state.name)\n", + "print(\"activity: \", status.activity.name)\n", + "print(\"remaining:\", status.remaining, \"s\")\n", + "\n", + "await device.abort()\n", + "try:\n", + " await running\n", + "except AbortedError as error:\n", + " print(\"stopped:\", error)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-40", + "metadata": {}, + "source": [ + "## Run a protocol file\n", + "\n", + "A `.LHC` protocol file is read into a `Protocol`: what it will run, and everything the file records\n", + "alongside it. Reading needs `pycryptodome`, installed at the top of this notebook.\n", + "\n", + "Reading a file never fails on a step it cannot understand — the file's own records are kept as they\n", + "are, so a protocol from another model still reads, prints and writes. `build_steps()` is what turns\n", + "those records into steps, and it names the one that will not read. A file written for a newer model\n", + "may well contain a step this instrument cannot run; `can_run()` is what says so." + ] + }, + { + "cell_type": "code", + "id": "co-41", + "metadata": {}, + "source": [ + "from pylabrobot.agilent.biotek.lhc import read\n", + "\n", + "protocol = read(\"path/to/your/protocol.LHC\")\n", + "\n", + "print(\"name: \", protocol.protocol_name)\n", + "print(\"written by:\", protocol.lhc_version)\n", + "print(\"written for:\", protocol.instrument_name)\n", + "print(\"plate: \", protocol.plate_type or protocol.plate_type_number)\n", + "print(\n", + " \"entries: \",\n", + " len(protocol.entries),\n", + " \"of which\",\n", + " len(protocol.device_entries),\n", + " \"operate the instrument\",\n", + ")\n", + "\n", + "for index, step in enumerate(protocol.build_steps()):\n", + " print(f\" step {index}: {type(step).__name__}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-42", + "metadata": {}, + "source": [ + "### What the file says about its instrument\n", + "\n", + "A protocol file records the options the instrument had fitted when it was written. Nothing runs\n", + "against that record — steps are encoded against the instrument in front of you — and nothing writes\n", + "it to the instrument. It is good for exactly one question, worth asking about a file that came from\n", + "another machine: was this written for a differently equipped dispenser? A file written for a\n", + "two-pump instrument will not run on a one-pump one.\n", + "\n", + "`compare_settings()` is truthy when the two agree, and prints as the options that differ. It raises\n", + "`ValueError` for a file that carries no such record, which is how the oldest releases wrote one." + ] + }, + { + "cell_type": "code", + "id": "co-43", + "metadata": {}, + "source": [ + "comparison = device.compare_settings(protocol)\n", + "print(comparison)\n", + "print(\"same configuration:\", bool(comparison))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-44", + "metadata": {}, + "source": [ + "### Running it\n", + "\n", + "`run_protocol()` checks the protocol, opens one batch around the whole run, sends each step and\n", + "polls it to completion. The check is the same `can_run()` from above and happens automatically, so a\n", + "protocol that cannot run raises before anything moves.\n", + "\n", + "**This runs whatever the protocol does**, which for most dispenser protocols means filling every\n", + "well of the plate on the carrier. Read the steps printed above first.\n", + "\n", + "The entries that sequence a run rather than operate the instrument — delays, loops, remarks — are\n", + "not run; the device steps go in file order. They are still on `protocol.entries` to inspect." + ] + }, + { + "cell_type": "code", + "id": "co-45", + "metadata": {}, + "source": [ + "await device.run_protocol(protocol, home_on_close=True)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-46", + "metadata": {}, + "source": [ + "Steps built in Python run the same way. `run_protocol()` takes a list of steps as readily\n", + "as a protocol, and `run_step()` runs a single one." + ] + }, + { + "cell_type": "code", + "id": "co-47", + "metadata": {}, + "source": [ + "await device.run_protocol(\n", + " [\n", + " PeriPrime(volume=300, peri_pump=\"Primary\"),\n", + " SyringeDispense(volume=50, syringe=\"A\", syringe_bottle=\"A1\"),\n", + " ],\n", + " home_on_close=True,\n", + ")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-48", + "metadata": {}, + "source": [ + "## Home the transport and disconnect\n", + "\n", + "Homing drives the transport to its home position and confirms it arrived. Do it before a person\n", + "reaches for the plate, unless the last batch already closed with `home_on_close=True`.\n", + "\n", + "`stop()` closes the link. It does nothing on an instrument that is already closed, so it is safe to\n", + "run this cell twice, and it is worth running from a `finally` in a script so that a failed run does\n", + "not leave the port open." + ] + }, + { + "cell_type": "code", + "id": "co-49", + "metadata": {}, + "source": [ + "await device.home()\n", + "await device.stop()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ma-50", + "metadata": {}, + "source": [ + "```{note}\n", + "The fluid left in the syringes and the cassettes after a run is the instrument's problem, not the\n", + "driver's. Follow the manufacturer's shutdown and maintenance procedure — purging each pump\n", + "(`device.peristaltic_dispenser.purge(...)`) is what empties a cassette before it comes out, and most\n", + "maintenance routines ship as protocol files you can run with `run_protocol()`.\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/agilent/multiflofx/hello-world.ipynb b/docs/user_guide/agilent/multiflofx/hello-world.ipynb new file mode 100644 index 00000000000..e818170c861 --- /dev/null +++ b/docs/user_guide/agilent/multiflofx/hello-world.ipynb @@ -0,0 +1,1386 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ma-00", + "metadata": {}, + "source": [ + "# Agilent BioTek MultiFlo FX dispenser quickstart\n", + "\n", + "The MultiFlo FX is a bulk dispenser. It fills wells from two kinds of pump — syringes, which meter\n", + "a volume precisely, and peristaltic pumps, which push fluid through a tubing cassette — and can\n", + "wash a plate through a strip washer manifold rather than the plate wash manifold its washer\n", + "siblings carry. Which of those it actually has is a matter of what somebody fitted and which\n", + "firmware image is installed, and it reports both.\n", + "\n", + "This quickstart connects to the dispenser, reads what it is and what it has fitted, works out which\n", + "firmware variant that leaves it running, tells it which plate is on its carrier, primes and\n", + "dispenses from both syringes and both pumps, explains cassettes and where in the well a step works,\n", + "runs a protocol file, and disconnects.\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Communication | A serial port, or USB through an FTDI interface |\n", + "| Serial parameters | 38400 baud, 8 data bits, 2 stop bits, no parity, no flow control |\n", + "| Operations | Syringe priming and dispensing, peristaltic priming, purging and dispensing, peristaltic and strip washing, shake and soak |\n", + "| Plate formats | 6 to 1536 wells, resolved from the PyLabRobot plate resource |\n", + "| Syringes | A and B, each with two selectable bottles; both at once on the manifolds that allow it |\n", + "| Peristaltic pumps | Primary and secondary, one tubing cassette each |\n", + "| Dispenser reach | Depth 1-1500 motor steps; across the well ±125 for the syringes, ±400 for the pumps; along the well ±40 |\n", + "| Protocol files | `.LHC` protocol files are read, checked and run |\n", + "```{warning}\n", + "Follow the manufacturer's installation, fluid-handling and safety instructions. Priming, purging\n", + "and dispensing all move fluid: the bottles must be full and the waste bottle empty enough before\n", + "anything in this notebook runs.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-01", + "metadata": {}, + "source": [ + "```{device-card} biotek-multiflo-fx\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ma-02", + "metadata": {}, + "source": [ + "## How it communicates\n", + "\n", + "PyLabRobot frames each command as an 11-byte header and a payload, writes it to the instrument,\n", + "reads back an acknowledgement and the reply, and turns a non-zero status into a typed exception.\n", + "Which of the two transports carries those bytes is decided by the port string alone, and nothing\n", + "above that point knows which one it got.\n", + "\n", + "Operations that move fluid do not answer when they are done. The driver sends them, then polls the\n", + "instrument's run state until it stops reporting a step in progress, which is why every method that\n", + "touches the instrument is awaited and can take as long as the physical operation does.\n", + "\n", + "Install PyLabRobot with its serial dependencies, or with the FTDI ones if the dispenser is on USB.\n", + "\n", + "Reading `.LHC` protocol files additionally needs `pycryptodome`, which is not a PyLabRobot\n", + "dependency: the file format is encrypted, and the cipher is not in the standard library. Leave it\n", + "out if you only build protocols in Python — `read()` raises a `RuntimeError` telling you to install\n", + "it if you later try to read a file without it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-03", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install \"pylabrobot[serial]\" pycryptodome\n", + "\n", + "# On USB, install the FTDI dependencies instead: \"pylabrobot[ftdi]\"." + ] + }, + { + "cell_type": "markdown", + "id": "ma-04", + "metadata": {}, + "source": [ + "## Physical setup and finding the port\n", + "\n", + "Install, plumb and power the dispenser according to the manufacturer's instructions. Fit the tubing\n", + "cassettes into the pumps you intend to use, connect each syringe to the bottle it draws from,\n", + "connect the waste bottle, and connect the instrument to the computer.\n", + "\n", + "Then find the port string:\n", + "\n", + "- **Serial.** Pass the operating system's own name for the port: `COM3` on Windows,\n", + " `/dev/ttyUSB0` or `/dev/ttyS4` on Linux and macOS. Anything that is not a USB serial number is\n", + " taken to be a serial port and passed through unexamined, so no particular naming pattern is\n", + " required.\n", + "\n", + "- **USB.** List the attached FTDI devices and use the reported serial number:\n", + "\n", + " ```bash\n", + " python -m pylibftdi.examples.list_devices\n", + " ```\n", + "\n", + " The port is then `ftdi:`, for example `ftdi:183193P`. The form the instrument's own\n", + " protocol files record, `USB MultiFlo FX sn:183193P`, is accepted as well.\n", + "\n", + "Keep the carrier and the area around it clear from here on. Nothing in this notebook moves the\n", + "carrier before the priming section, but a fault can home the motors at any time." + ] + }, + { + "cell_type": "markdown", + "id": "ma-05", + "metadata": {}, + "source": [ + "## Turn on logging\n", + "\n", + "The driver reports what it is doing through the standard library's `logging`, and says nothing\n", + "otherwise. Without this cell every step the instrument runs passes silently." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-06", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "\n", + "logging.basicConfig(level=logging.INFO, format=\"%(levelname)s %(name)s: %(message)s\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-07", + "metadata": {}, + "source": [ + "## See which ports have something behind them\n", + "\n", + "`pyserial` lists every serial port the operating system offers, most of which are kernel\n", + "placeholders with no hardware behind them — on Linux the 32 `/dev/ttyS*` entries, which report\n", + "their description and hardware id as `n/a`. Skipping those leaves the ports worth trying, and a\n", + "USB-serial adapter names the instrument it is wired to, so the dispenser is usually recognisable at\n", + "a glance.\n", + "\n", + "This lists serial ports only. An instrument driven through the FTDI transport is found with\n", + "`python -m pylibftdi.examples.list_devices` instead — though a device the kernel has bound to its\n", + "own FTDI serial driver shows up here too, and can be used either way." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-08", + "metadata": {}, + "outputs": [], + "source": [ + "from serial.tools.list_ports import comports\n", + "\n", + "candidates = [port for port in comports() if port.description != \"n/a\" or port.vid is not None]\n", + "\n", + "for port in candidates:\n", + " serial_number = f\" sn:{port.serial_number}\" if port.serial_number else \"\"\n", + " print(f\"{port.device:16} {port.description}{serial_number}\")\n", + "\n", + "if not candidates:\n", + " print(\"no port has a device behind it; is the dispenser powered and connected?\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-09", + "metadata": {}, + "source": [ + "## Build the dispenser\n", + "\n", + "Constructing the object opens nothing and touches no hardware. It records which port to use, which\n", + "model this is, and what to call the instrument in logs and error messages.\n", + "\n", + "Replace the port with the one found above." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-10", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc import MultiFloFX\n", + "\n", + "# The port is a serial one unless it carries a device serial number: on USB, pass\n", + "# \"ftdi:YOUR_SERIAL\" instead.\n", + "device = MultiFloFX(port=\"/dev/ttyUSB2\", name=\"MultiFlo FX\")\n", + "device" + ] + }, + { + "cell_type": "markdown", + "id": "ma-11", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` opens the link, asks whether anything is listening, and reads the options the instrument\n", + "has fitted. That read is not optional: every step is encoded against it and every check measures\n", + "against it, so a failure here stops the notebook rather than being carried past.\n", + "\n", + "It raises a `BiotekError` if the port will not open, if nothing answers on it, or if the fitted\n", + "options cannot be read." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-12", + "metadata": {}, + "outputs": [], + "source": [ + "await device.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "ma-13", + "metadata": {}, + "source": [ + "## Ask what answered\n", + "\n", + "**The model is declared, not discovered.** The instrument does not report which model it is, so it\n", + "is the class you constructed that decides how every step is encoded and which options are read.\n", + "Note that `device.settings.family` is not a check on this — it echoes what was declared, not what is\n", + "attached.\n", + "\n", + "What the instrument does report is its serial number, which identifies the individual instrument,\n", + "and its firmware version record, whose part number says which instrument the installed firmware\n", + "image is built for. A MultiFlo FX reports a part number beginning `126`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-14", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"serial number: \", await device.get_serial_number())\n", + "\n", + "version = await device.get_firmware_version()\n", + "print(\"part number: \", version.part_number)\n", + "print(\"firmware: \", version.software_version)\n", + "print(\"data version: \", version.data_version)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-15", + "metadata": {}, + "source": [ + "## Read the configuration\n", + "\n", + "What `setup()` read is kept as a read-only record of what somebody fitted to this instrument. It is\n", + "read-only because the instrument is: of its whole command vocabulary, almost nothing about the\n", + "configuration can be written, so a record that could be edited would only mislead. The one\n", + "exception is which cassette sits in which pump, and that is reconciled when a batch opens rather\n", + "than held here.\n", + "\n", + "This is the record every check below measures against, and on this model it decides more than on\n", + "any other: which pumps exist, whether there are one or two syringes, whether a strip washer is\n", + "fitted, and — through the last two flags — which firmware variant the instrument is running." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-16", + "metadata": {}, + "outputs": [], + "source": [ + "settings = device.settings\n", + "\n", + "print(\"family: \", settings.family.name)\n", + "print(\"syringe box: \", settings.syringe_box.name)\n", + "print(\"syringe box size: \", settings.syringe_box_size.name)\n", + "print(\"syringe manifold: \", settings.syringe_manifold.name)\n", + "print(\"primary peri pump: \", settings.peri_pump)\n", + "print(\"secondary peri pump: \", settings.peri_pump_2)\n", + "print(\"strip washer: \", settings.strip_washer_manifold.name)\n", + "print(\"wash manifold: \", settings.washer_manifold.name)\n", + "print(\"single well enabled: \", settings.single_well_enabled)\n", + "print(\"peri wash enabled: \", settings.peri_wash_enabled)\n", + "print(\"wider dispense offsets:\", settings.advanced_dispense_offsets)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-17", + "metadata": {}, + "source": [ + "### Two syringes and two pumps\n", + "\n", + "The two syringes are one box: `syringe_box_size` is `DOUBLE` for a box driving A and B, and\n", + "`SINGLE` for one driving A alone. Which bottle each syringe draws from is chosen per step, so a\n", + "double box gives four combinations and a step that runs both syringes names the pairing.\n", + "\n", + "Running **both at once** takes more than the box, though: it is the fitted *manifold* that decides,\n", + "and only four of them can — the 8-tube, the 16/7-tube and both 32-tube manifolds. On the plain\n", + "16-tube manifold each step drives one syringe, and a step asking for both is refused saying so. A\n", + "prime is never both regardless: it is checked against a 16-tube manifold whatever is fitted, so\n", + "that a prime is never rejected for the manifold it will actually run on.\n", + "\n", + "The two peristaltic pumps are separate options, `peri_pump` and `peri_pump_2`, and each holds one\n", + "tubing cassette. A step names the pump it wants and the cassette it needs; asking for a pump that\n", + "is not fitted is refused with a reason rather than quietly running on the other one." + ] + }, + { + "cell_type": "markdown", + "id": "ma-19", + "metadata": {}, + "source": [ + "## Ask what it can run, and why that is not just the hardware\n", + "\n", + "A model can be built to run a fixed set of operations, a particular instrument runs the subset its\n", + "fitted hardware supports — and on this model the **firmware image narrows it again**. Its firmware\n", + "ships in three variants, and the instrument reports which one it is running through the two flags\n", + "read above:\n", + "\n", + "| Variant | Reported by | Offers |\n", + "|---|---|---|\n", + "| Basic | neither flag set | peristaltic steps, syringe steps, strip washer steps, shake and soak |\n", + "| Random access | `single_well_enabled` | peristaltic steps, syringe steps, dispensing into individually chosen wells, shake and soak |\n", + "| PeriWash | `peri_wash_enabled` | peristaltic steps, the peristaltic wash pair, shake and soak |\n", + "\n", + "Read that table for what it costs: **the PeriWash variant has no syringe steps at all**, and\n", + "neither of the other two has the peristaltic wash pair. A step type the installed firmware does not\n", + "know is refused before anything moves, with a code of its own for exactly that. If both flags are\n", + "set the instrument is taken to be running the random-access variant.\n", + "\n", + "`get_available_steps()` has already applied all of it, which makes it the answer to \"why was my\n", + "step refused\" before you have written the step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-20", + "metadata": {}, + "outputs": [], + "source": [ + "for step_type in device.get_available_steps():\n", + " print(step_type.name)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-21", + "metadata": {}, + "source": [ + "## Tell it which plate is on the carrier\n", + "\n", + "Nothing runs until a plate has been set. Every step carries the height it works at, measured from\n", + "the nominal heights of the format on the carrier, so without one there is nothing to measure from\n", + "and the driver raises `RejectedError` rather than guessing.\n", + "\n", + "The format is resolved from the PyLabRobot plate resource itself — its columns, its rows, and how\n", + "deep its wells are. Labware that does not land on exactly one of the formats this model works is an\n", + "error naming the candidates, never a nearest fit. This model works the widest range in the family,\n", + "from 6-well plates to 1536-well ones." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-22", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Greiner_384_wellplate_28ul_Fb\n", + "\n", + "plate = Greiner_384_wellplate_28ul_Fb(name=\"plate\")\n", + "device.set_plate(plate)\n", + "\n", + "print(device.plate)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-23", + "metadata": {}, + "source": [ + "Some formats are never resolved from a resource, because they share their column and row count with\n", + "an ordinary plate and differ in something the resource does not carry — a well shape, a flange,\n", + "a tube, or that it is calibration labware. Name one of those outright, and use the same argument\n", + "when a plate is to be worked as something other than what it resolves to." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-24", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType\n", + "\n", + "device.set_plate(plate, plate_type=PlateType.PLATE_384_WELL_PCR)\n", + "print(device.plate)\n", + "\n", + "device.set_plate(plate) # back to what it resolves to on its own" + ] + }, + { + "cell_type": "markdown", + "id": "ma-25", + "metadata": {}, + "source": [ + "## Check before running\n", + "\n", + "`can_run()` measures steps against the instrument as it is now — what is fitted, which firmware\n", + "variant is installed, which plate is on the carrier, and what the plate will accept — and touches\n", + "nothing. It is what `run_protocol()` does first, so calling it yourself is how you see a refusal\n", + "without moving anything.\n", + "\n", + "The report is truthy when everything can run, and prints as the list of what cannot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-26", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.peri_prime import PeriPrime\n", + "\n", + "report = await device.can_run([PeriPrime(volume=300, peri_pump=\"Primary\")])\n", + "print(report)\n", + "print(\"can run:\", bool(report))" + ] + }, + { + "cell_type": "markdown", + "id": "ma-27", + "metadata": {}, + "source": [ + "A volume the syringe will not meter is refused with the range it would accept. The floor moves with\n", + "the plate on the carrier and the flow rate, so it is worth reading off the message rather than\n", + "memorising: into a 384-well plate at flow rate 2 it is 10 µL, and at the slowest rate 40 µL." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-28", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.syringe_dispense import SyringeDispense\n", + "\n", + "print(await device.can_run([SyringeDispense(volume=5, syringe=\"B\")]))" + ] + }, + { + "cell_type": "markdown", + "id": "ma-29", + "metadata": {}, + "source": [ + "## Prime the syringes\n", + "\n", + "Priming draws fluid through a syringe until its lines are full. It is the safest operation to try\n", + "first — nothing is dispensed into the plate — but it does move fluid, so check the bottles before\n", + "running this cell.\n", + "\n", + "A prime drives **one** syringe: unlike a dispense, it cannot run both at once, and a prime naming\n", + "`\"Both\"` is refused. Priming each in turn inside one batch is how both get done without homing the\n", + "motors twice." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-30", + "metadata": {}, + "outputs": [], + "source": [ + "async with device.batch():\n", + " await device.syringe_dispenser.prime(volume=5_000, syringe=\"A\", syringe_bottle=\"A1\", cycles=2)\n", + " await device.syringe_dispenser.prime(volume=5_000, syringe=\"B\", syringe_bottle=\"B1\", cycles=2)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-31", + "metadata": {}, + "source": [ + "## Dispense from each syringe\n", + "\n", + "**This dispenses into the plate**, so put a plate on the carrier that you are willing to fill.\n", + "\n", + "Each syringe draws from one of its two bottles, named per step. Volumes are per well in µL." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-32", + "metadata": {}, + "outputs": [], + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.syringe_dispenser.dispense(volume=20, syringe=\"A\", syringe_bottle=\"A1\")\n", + " await device.syringe_dispenser.dispense(volume=20, syringe=\"B\", syringe_bottle=\"B1\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-33", + "metadata": {}, + "source": [ + "Both syringes can also dispense at once, on the manifolds that allow it. The step then names the\n", + "pairing rather than a single bottle: `\"A1B1\"` draws syringe A from its first bottle and syringe B\n", + "from its first, `\"A2B1\"` from A's second and B's first.\n", + "\n", + "`\"Both\"` does not put both fluids in every well. Nothing in the step divides the plate — one\n", + "volume, one flow rate and one column selection cover the pair — but each syringe feeds its own\n", + "tubes in the manifold, so the wells divide between them. Observed on a MultiFlo FX with the\n", + "16-tube 7° manifold, watched with the lines dry: the columns fall in pairs, syringe B taking 3-4,\n", + "7-8 and 11-12 and syringe A the rest. That mapping belongs to the fitted manifold and the plate\n", + "format rather than to the protocol — the vendor's own library carries no per-syringe well\n", + "selection at all — so confirm it on your instrument before a step depends on it.\n", + "\n", + "LHC offers the same choice as the `Syringe:` radio group — `A`, `B`, `Both` — in its Syringe\n", + "Dispense Step dialog, alongside the bottle list that reads `A1 + B1` for the pairing, and says no\n", + "more about it there than the name does here.\n", + "\n", + "The cell below asks the fitted manifold first, so it is safe to run on any instrument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-34", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.instrument.syringe_manifold import SyringeManifold\n", + "\n", + "BOTH_AT_ONCE = {\n", + " SyringeManifold.TUBE_8,\n", + " SyringeManifold.TUBE_16_7,\n", + " SyringeManifold.TUBE_32_SMALL_BORE,\n", + " SyringeManifold.TUBE_32_LARGE_BORE,\n", + "}\n", + "\n", + "if device.settings.syringe_manifold in BOTH_AT_ONCE:\n", + " await device.syringe_dispenser.dispense(volume=20, syringe=\"Both\", syringe_bottle=\"A1B1\")\n", + "else:\n", + " print(\n", + " f\"a {device.settings.syringe_manifold.name} manifold drives one syringe per step; \"\n", + " \"run the two dispenses above instead\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "ma-35", + "metadata": {}, + "source": [ + "One thing to know before writing a protocol: **a syringe is committed to one bottle for the whole\n", + "run**. Two steps drawing syringe A from different bottles are refused — on the second of the pair,\n", + "which is where the conflict becomes visible — because nothing switches the bottle mid-run.\n", + "\n", + "Where a syringe is plumbed to one bottle and nothing else, the choice does not arise and\n", + "`syringe_bottle` can be left out: it defaults to `\"A1\"`. It defaults to that whichever syringe the\n", + "step drives, so a step on B names `\"B1\"` for itself. Switching bottles within a run at all takes\n", + "the syringe buffer-switching valve module, which only the EL406 is asked about, so on this model\n", + "the commitment above always holds." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-36", + "metadata": {}, + "outputs": [], + "source": [ + "print(\n", + " await device.can_run(\n", + " [\n", + " SyringeDispense(volume=20, syringe=\"A\", syringe_bottle=\"A1\"),\n", + " SyringeDispense(volume=20, syringe=\"A\", syringe_bottle=\"A2\"),\n", + " ]\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-37", + "metadata": {}, + "source": [ + "## Prime and purge the peristaltic pumps\n", + "\n", + "A peristaltic pump pushes fluid through a tubing cassette. Priming fills the tubing; purging empties\n", + "it, running fluid to waste rather than into the plate — which is what you want at the end of a run\n", + "and before a cassette comes out.\n", + "\n", + "Both take either a volume per tube or a duration: give `duration` and the step runs for that many\n", + "seconds instead of metering a volume." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-38", + "metadata": {}, + "outputs": [], + "source": [ + "async with device.batch():\n", + " await device.peristaltic_dispenser.prime(volume=300, peri_pump=\"Primary\")\n", + " await device.peristaltic_dispenser.prime(duration=10, peri_pump=\"Secondary\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-39a", + "metadata": {}, + "source": [ + "Purging is worth doing on its own at the end of a run, and before a cassette comes out: what stays\n", + "in the tubing otherwise dries in it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-40", + "metadata": {}, + "outputs": [], + "source": [ + "await device.peristaltic_dispenser.purge(volume=300, peri_pump=\"Secondary\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-41", + "metadata": {}, + "source": [ + "## Dispense from the peristaltic pumps\n", + "\n", + "**This one dispenses into the plate too.** The volume is per tube in µL, and the flow rate is one of\n", + "three named speeds rather than a number.\n", + "\n", + "A step may also name the cassette it needs. `\"Any\"` accepts whatever is fitted; naming `\"1uL\"`,\n", + "`\"5uL\"` or `\"10uL\"` is a requirement, and it is checked before anything moves." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-42", + "metadata": {}, + "outputs": [], + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.peristaltic_dispenser.dispense(volume=20, peri_pump=\"Primary\")\n", + " await device.peristaltic_dispenser.dispense(\n", + " volume=20, flow_rate=\"Low\", cassette_type=\"5uL\", peri_pump=\"Secondary\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "ma-43", + "metadata": {}, + "source": [ + "### One cassette per pump\n", + "\n", + "Each pump holds one cassette, and **opening a batch is what makes the hardware match what the\n", + "checked protocol asked for** — this is the only model whose peristaltic dispense head can be set\n", + "from the host, so the reconciliation happens there rather than by hand.\n", + "\n", + "The rule that follows is that two steps may not want different cassettes in the same pump. Such a\n", + "protocol is refused on the second of the pair; the same two steps, one per pump, are fine." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-44", + "metadata": {}, + "outputs": [], + "source": [ + "print(\n", + " \"same pump, two cassettes:\",\n", + " await device.can_run(\n", + " [\n", + " PeriPrime(volume=300, cassette_type=\"1uL\", peri_pump=\"Primary\"),\n", + " PeriPrime(volume=300, cassette_type=\"5uL\", peri_pump=\"Primary\"),\n", + " ]\n", + " ),\n", + ")\n", + "print(\n", + " \"one per pump:\",\n", + " await device.can_run(\n", + " [\n", + " PeriPrime(volume=300, cassette_type=\"1uL\", peri_pump=\"Primary\"),\n", + " PeriPrime(volume=300, cassette_type=\"5uL\", peri_pump=\"Secondary\"),\n", + " ]\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-45", + "metadata": {}, + "source": [ + "## Shake and soak\n", + "\n", + "Shaking and soaking are one step, and either half can be left out by giving it no time: `duration`\n", + "shakes, `soak_duration` leaves the plate still afterwards. Both are in seconds.\n", + "\n", + "This is the one operation every variant of the firmware offers, whatever is fitted." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-46", + "metadata": {}, + "outputs": [], + "source": [ + "await device.shake(duration=30, intensity=\"Medium\", axis=\"X\", soak_duration=30)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-47", + "metadata": {}, + "source": [ + "## Where in the well a step works\n", + "\n", + "Every operation that reaches into the plate takes a `positioning`, and defaults it to the nominal\n", + "position for that head over the format on the carrier. Three numbers:\n", + "\n", + "- `z_steps` — how deep the dispense tubes go. **This is a height, not an offset**: it defaults to\n", + " the plate record's own nominal height for the head, and giving a larger number reaches further\n", + " down into the well. On this model it must be 1-1500, whichever head is working.\n", + "- `x_steps` — across the well. The range is per head: ±125 from the syringes, ±400 from the\n", + " peristaltic pumps, the strip washer and the peristaltic wash manifolds.\n", + "- `y_steps` — along the well, ±40 from the syringes and the pumps, ±99 from the strip washer, the\n", + " peristaltic wash manifolds and a random-access dispense.\n", + "\n", + "The `advanced_dispense_offsets` flag read above widens the syringes to the ±400 and ±99 the rest\n", + "already have. It is an instrument setting, not something a step asks for, which is why a syringe\n", + "offset that one instrument accepts is refused on another.\n", + "\n", + "The nominal heights come from the plate record, so they change with the plate and differ per head.\n", + "On this model the dispensers work at the dispensing height and the peristaltic wash manifold sits\n", + "markedly higher." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-48", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"nominal dispensing height:\", device.plate.dispenser_height)\n", + "print(\"nominal aspirating height:\", device.plate.manifold_aspirate_height)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-49", + "metadata": {}, + "source": [ + "To dispense a little higher than nominal — down the side of the well rather than into the middle of\n", + "it — build a `Positioning` from the nominal height rather than from a number you have written down,\n", + "so the same code stays right when the plate changes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-50", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning\n", + "\n", + "await device.syringe_dispenser.dispense(\n", + " volume=20,\n", + " syringe=\"A\",\n", + " positioning=Positioning(\n", + " z_steps=device.plate.dispenser_height - 20, # 20 steps higher in the well\n", + " x_steps=15, # toward one side\n", + " y_steps=0,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-51", + "metadata": {}, + "source": [ + "### Why motor steps and not millimetres\n", + "\n", + "PyLabRobot's convention is millimetres, and this is the one place the package deviates from it. The\n", + "field names say so outright — `z_steps`, not `z` — because the conversion is not a single number:\n", + "it differs per axis, per model, and per head, and only part of it is established.\n", + "\n", + "What is known: the across-the-plate axis is **0.04572 mm per motor step**, confirmed twice over\n", + "against a published maximum offset. The depth axis has at least two scales, chosen by a property\n", + "that follows the head, and which head takes which is not settled. The along-the-plate axis is not\n", + "established at all.\n", + "\n", + "Converting on the strength of that would put a head at the wrong depth on some model, so the\n", + "package does not convert. If you need millimetres on your instrument, measure them: drive a known\n", + "offset on each axis and see where the tubes go. A step is also what a protocol file stores, which\n", + "is what lets a protocol be read, checked and written with no instrument to ask." + ] + }, + { + "cell_type": "markdown", + "id": "ma-51-sel", + "metadata": {}, + "source": [ + "## Working part of a plate\n", + "\n", + "Every step that reaches into the plate takes a `columns` selection, and most of them take a `rows`\n", + "one as well. Both default to everything, which is why nothing above has named them.\n", + "\n", + "A selection is a `WellMask`: one entry per position, `1` to work it and `0` to skip it.\n", + "`from_columns` builds one from column numbers, counted from one, and a list of entries is the way\n", + "to write one out by hand. A column selection always has 48 entries whatever the plate holds — the\n", + "instrument reads as many as the format on the carrier has, so on a 384-well plate everything past\n", + "the 24th is ignored, and naming a column the plate does not have is not an error.\n", + "\n", + "Selecting no column at all is allowed and dispenses nothing, which is the vendor's own behaviour\n", + "rather than an oversight. An entry that is neither 0 nor 1 is refused as corrupt data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-52-sel", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask\n", + "\n", + "first_two = WellMask.from_columns([1, 2])\n", + "every_other = WellMask.from_columns(range(1, 49, 2)) # columns 1, 3, 5, ...\n", + "\n", + "print(\"first two selects: \", first_two.selected_count)\n", + "print(\"every other selects:\", every_other.selected_count)\n", + "print(await device.can_run([SyringeDispense(volume=20, syringe=\"A\", columns=every_other)]))" + ] + }, + { + "cell_type": "markdown", + "id": "ma-53-sel", + "metadata": {}, + "source": [ + "**This dispenses into the plate**, into the first two columns and nowhere else." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-54-sel", + "metadata": {}, + "outputs": [], + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.syringe_dispenser.dispense(volume=20, syringe=\"A\", columns=first_two)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-55-sel", + "metadata": {}, + "source": [ + "Rows go in sections of eight rather than one at a time, and a plate has `rows // 8` of them — one\n", + "on a 96-well plate, two on a 384-well, four on a 1536-well. Selecting a section the plate does not\n", + "have counts as selecting nothing.\n", + "\n", + "Whether sections can be addressed at all belongs to the head doing the work rather than to the\n", + "plate. A head that covers every row in one pass leaves nothing to choose between, and a selection\n", + "handed to it does nothing: a 16-tube syringe manifold reaches all sixteen rows of a 384-well plate\n", + "at once, so a row selection on a syringe dispense there is inert. It bites where the head covers\n", + "less than the plate — the peristaltic pumps, whose cassettes carry eight tubes, and the 8-tube\n", + "syringe manifold on a 384-well plate.\n", + "\n", + "Nothing refuses an inert selection, either. Of the steps that carry rows only the peristaltic\n", + "dispense, the strip dispense and the peristaltic wash pair check them, and there an empty selection\n", + "is refused — where an empty column selection is allowed. Naming `rows` at all is what makes a step\n", + "store and send them, and the payload is padded to a fixed length either way, so the selection costs\n", + "nothing when it is there." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-56-sel", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"row sections on this plate:\", device.plate.rows // 8)\n", + "print(\"syringe manifold: \", device.settings.syringe_manifold.name)\n", + "\n", + "# A cassette carries eight tubes, so for the pumps a section is a real choice on this plate.\n", + "await device.peristaltic_dispenser.dispense(\n", + " volume=20, peri_pump=\"Primary\", columns=first_two, rows=WellMask([1, 0, 0, 0])\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-57-sel", + "metadata": {}, + "source": [ + "On a wash, put the selection on the wash itself rather than on the dispense handed\n", + "to it: a sub-step's own masks are dropped, in the protocol file as on the wire." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-58-sel", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import (\n", + " StripWasherManifold,\n", + ")\n", + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_dispense import StripDispense\n", + "\n", + "if device.settings.strip_washer_manifold is not StripWasherManifold.NOT_INSTALLED:\n", + " async with device.batch(home_on_close=True):\n", + " await device.washer.strip_wash(\n", + " cycles=3,\n", + " dispense=StripDispense(volume=25, flow_rate=3),\n", + " columns=first_two,\n", + " )\n", + "else:\n", + " print(\"no strip washer fitted; nothing to do\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-53", + "metadata": {}, + "source": [ + "## Dispensing into individually chosen wells\n", + "\n", + "On the random-access variant — the one reporting `single_well_enabled` — a peristaltic dispense can\n", + "address wells one at a time instead of the whole plate. Giving a per-well volume map, a dispense\n", + "head, or both makes the step that kind of dispense, which is a different payload and a different\n", + "command.\n", + "\n", + "Two rules come with it, and both are checked rather than assumed: the step runs on the **secondary**\n", + "pump when two are fitted, and the head has to suit the plate — `\"1 tube to 1 well\"` on a 384-well\n", + "plate, where `\"8 tubes to 8 wells\"` is refused.\n", + "\n", + "One oddity in how this is reported, worth knowing before concluding the option is missing: the\n", + "instrument is only asked whether the random-access dispenser is fitted once its **strip washer\n", + "hardware** answers. On an instrument that reports no strip washer hardware at all,\n", + "`single_well_enabled` reads false whatever is actually fitted, and every random-access dispense is\n", + "refused. That is how the vendor's own library asks, so it is what this driver does.\n", + "\n", + "The cell below runs only where the firmware offers it, so it is safe to run on any instrument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-54", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType\n", + "\n", + "if device.settings.single_well_enabled:\n", + " await device.peristaltic_dispenser.dispense(\n", + " volume=20, peri_pump=\"Secondary\", cassette_head=\"1 tube to 1 well\"\n", + " )\n", + "else:\n", + " print(\"this instrument does not run the random-access firmware; nothing to do\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-55", + "metadata": {}, + "source": [ + "## Gentle medium exchange\n", + "\n", + "On the PeriWash variant — the one reporting `peri_wash_enabled` — the peristaltic pumps drive a pair\n", + "of wash manifolds that exchange medium without disturbing what is growing in the well: one\n", + "aspirates the spent medium off, the other adds fresh. Both work at the plate's aspirating height,\n", + "which is the instrument's own pairing rather than this package's.\n", + "\n", + "Two things to know. A wash cassette cannot share a pump with an ordinary peristaltic step, and a\n", + "protocol that asks for both on one pump is refused saying so. And this variant has **no syringe\n", + "steps** — if the cells above dispensed from a syringe, this instrument is not running it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-56", + "metadata": {}, + "outputs": [], + "source": [ + "if device.settings.peri_wash_enabled:\n", + " async with device.batch(home_on_close=True):\n", + " await device.peristaltic_dispenser.wash_aspirate(volume=25, peri_pump=\"Primary\")\n", + " await device.peristaltic_dispenser.wash_dispense(volume=25, peri_pump=\"Primary\")\n", + "else:\n", + " print(\"this instrument does not run the PeriWash firmware; nothing to do\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-57", + "metadata": {}, + "source": [ + "## The strip washer\n", + "\n", + "Where the washers in this family carry a plate wash manifold, this model washes through a strip\n", + "washer manifold — so of the methods on `device.washer` only the strip ones run here, and\n", + "`get_available_steps()` above says whether even those do.\n", + "\n", + "The manifold is fitted for a plate format, and it has to match the plate on the carrier: the 96-well\n", + "manifold covers 96- and 384-well plates, where a 24-well manifold over either is refused. A wash is\n", + "one step rather than a loop — the instrument runs the cycles itself — and its refill needs a volume\n", + "of its own, since the step type defaults to none." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-58", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.instrument.strip_washer_manifold import (\n", + " StripWasherManifold,\n", + ")\n", + "from pylabrobot.agilent.biotek.lhc.protocols.steps.steps.strip_dispense import StripDispense\n", + "\n", + "if device.settings.strip_washer_manifold is not StripWasherManifold.NOT_INSTALLED:\n", + " async with device.batch(home_on_close=True):\n", + " await device.washer.strip_prime(volume=320, flow_rate=3)\n", + " await device.washer.strip_wash(\n", + " cycles=1, dispense=StripDispense(volume=25, flow_rate=3)\n", + " )\n", + "else:\n", + " print(\"no strip washer fitted; nothing to do\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-59", + "metadata": {}, + "source": [ + "## Run several operations in one batch\n", + "\n", + "Opening a batch homes the motors, reconciles the cassettes and the dispense head with what the\n", + "checked protocol asked for, takes the instrument so that nothing else can interleave a run on it,\n", + "and holds it until the block ends. Every operation opens one; doing it once around several\n", + "operations — as the cells above have been doing — is what stops all of that happening between each\n", + "of them.\n", + "\n", + "`home_on_close=True` drives the transport home before the batch closes. The instrument does not do\n", + "this by itself; ask for it when the next thing to touch the plate is a person.\n", + "\n", + "Nesting is allowed and does nothing: an operation called inside an open batch joins it rather than\n", + "opening a second one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-60", + "metadata": {}, + "outputs": [], + "source": [ + "async with device.batch(home_on_close=True):\n", + " await device.peristaltic_dispenser.prime(volume=300, peri_pump=\"Primary\")\n", + " await device.peristaltic_dispenser.dispense(volume=20, peri_pump=\"Primary\")\n", + " await device.shake(duration=30, soak_duration=0)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-61", + "metadata": {}, + "source": [ + "## Watch a step, and stop it\n", + "\n", + "`get_status()` reports what the instrument is doing, which timed phase a running step is in, and how\n", + "many seconds are left in it. It can be called at any time, including while a step is running.\n", + "\n", + "An operation does not return until its step has finished, so pausing or aborting means asking from\n", + "somewhere else while it runs. In a notebook that is a task." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-62", + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "running = asyncio.create_task(\n", + " device.peristaltic_dispenser.prime(duration=30, peri_pump=\"Primary\")\n", + ")\n", + "await asyncio.sleep(2)\n", + "\n", + "status = await device.get_status()\n", + "print(\"state: \", status.state.name)\n", + "print(\"activity: \", status.activity.name)\n", + "print(\"remaining:\", status.remaining, \"s\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-63", + "metadata": {}, + "source": [ + "Pause holds the step where it is; resume carries on from there." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-64", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.enums.status.run_state import RunState\n", + "\n", + "status = await device.get_status()\n", + "if status.state is not RunState.BUSY:\n", + " print(\n", + " f\"the device is {status.state.name}, not running a step -- start the cell above again \"\n", + " \"and run this one while its step is still going\"\n", + " )\n", + "else:\n", + " await device.pause()\n", + " await asyncio.sleep(2)\n", + " print((await device.get_status()).state.name)\n", + "\n", + " await device.resume()\n", + " await running" + ] + }, + { + "cell_type": "markdown", + "id": "ma-65", + "metadata": {}, + "source": [ + "Abort stops the running step instead. The operation that was waiting for it raises `AbortedError`,\n", + "which is a `BiotekError`, so a protocol run ends where it was stopped rather than carrying on to the\n", + "next step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-66", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc.error_handling import AbortedError\n", + "\n", + "running = asyncio.create_task(\n", + " device.peristaltic_dispenser.prime(duration=30, peri_pump=\"Primary\")\n", + ")\n", + "await asyncio.sleep(5)\n", + "await device.abort()\n", + "\n", + "try:\n", + " await running\n", + "except AbortedError as error:\n", + " print(\"stopped:\", error)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-67", + "metadata": {}, + "source": [ + "## Run a protocol file\n", + "\n", + "A `.LHC` protocol file is read into a `Protocol`: what it will run, and everything the file records\n", + "alongside it. Reading needs `pycryptodome`, installed at the top of this notebook.\n", + "\n", + "Reading a file never fails on a step it cannot understand — the file's own records are kept as they\n", + "are, so a protocol from another model still reads, prints and writes. `build_steps()` is what turns\n", + "those records into steps, and it names the one that will not read." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-68", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.lhc import read\n", + "\n", + "protocol = read(\"path/to/your/protocol.LHC\")\n", + "\n", + "print(\"name: \", protocol.protocol_name)\n", + "print(\"written by:\", protocol.lhc_version)\n", + "print(\"written for:\", protocol.instrument_name)\n", + "print(\"plate: \", protocol.plate_type or protocol.plate_type_number)\n", + "print(\n", + " \"entries: \",\n", + " len(protocol.entries),\n", + " \"of which\",\n", + " len(protocol.device_entries),\n", + " \"operate the instrument\",\n", + ")\n", + "\n", + "for index, step in enumerate(protocol.build_steps()):\n", + " print(f\" step {index}: {type(step).__name__}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-69", + "metadata": {}, + "source": [ + "### What the file says about its instrument\n", + "\n", + "A protocol file records the options the instrument had fitted when it was written. Nothing runs\n", + "against that record — steps are encoded against the instrument in front of you — and nothing writes\n", + "it to the instrument. It is good for exactly one question, worth asking about a file that came from\n", + "another machine: was this written for a differently equipped dispenser? On this model that question\n", + "has teeth, because a file written for a two-pump instrument will not run on a one-pump one.\n", + "\n", + "`compare_settings()` is truthy when the two agree, and prints as the options that differ. It raises\n", + "`ValueError` for a file that carries no such record, which is how the oldest releases wrote one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-70", + "metadata": {}, + "outputs": [], + "source": [ + "comparison = device.compare_settings(protocol)\n", + "print(comparison)\n", + "print(\"same configuration:\", bool(comparison))" + ] + }, + { + "cell_type": "markdown", + "id": "ma-71", + "metadata": {}, + "source": [ + "### Running it\n", + "\n", + "`run_protocol()` checks the protocol, opens one batch around the whole run, sends each step and\n", + "polls it to completion. The check is the same `can_run()` from above and happens automatically, so a\n", + "protocol that cannot run raises before anything moves.\n", + "\n", + "**This runs whatever the protocol does**, which for most dispenser protocols means filling every\n", + "well of the plate on the carrier. Read the steps printed above first.\n", + "\n", + "The entries that sequence a run rather than operate the instrument — delays, loops, remarks — are\n", + "not run; the device steps go in file order. They are still there on `protocol.entries` to inspect." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-72", + "metadata": {}, + "outputs": [], + "source": [ + "await device.run_protocol(protocol, home_on_close=True)" + ] + }, + { + "cell_type": "markdown", + "id": "ma-73", + "metadata": {}, + "source": [ + "Steps built in Python run the same way. `run_protocol()` takes a list of steps as readily as a\n", + "protocol, and `run_step()` runs a single one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-74", + "metadata": {}, + "outputs": [], + "source": [ + "await device.run_protocol(\n", + " [\n", + " PeriPrime(volume=300, peri_pump=\"Primary\"),\n", + " PeriPrime(volume=300, peri_pump=\"Secondary\"),\n", + " ],\n", + " home_on_close=True,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ma-75", + "metadata": {}, + "source": [ + "## Home the transport and disconnect\n", + "\n", + "Homing drives the transport to its home position and confirms it arrived. Do it before a person\n", + "reaches for the plate, unless the last batch already closed with `home_on_close=True`.\n", + "\n", + "`stop()` closes the link. It does nothing on an instrument that is already closed, so it is safe to\n", + "run this cell twice, and it is worth running from a `finally` in a script so that a failed run does\n", + "not leave the port open." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "co-76", + "metadata": {}, + "outputs": [], + "source": [ + "await device.home()\n", + "await device.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "ma-77", + "metadata": {}, + "source": [ + "```{note}\n", + "The fluid left in the syringes and the cassettes after a run is the instrument's problem, not the\n", + "driver's. Follow the manufacturer's shutdown and maintenance procedure — purging each pump\n", + "(`device.peristaltic_dispenser.purge(...)`) is what empties a cassette before it comes out, and most\n", + "maintenance routines ship as protocol files you can run with `run_protocol()`.\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 910cc83ccf8240708ab33b9118956ebe12692802 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Fri, 11 Sep 2026 14:11:25 +0200 Subject: [PATCH 17/19] ruff fixes --- .../biotek/lhc/devices/components/peristaltic_dispenser.py | 2 +- .../agilent/biotek/lhc/devices/components/syringe_dispenser.py | 2 +- pylabrobot/agilent/biotek/lhc/devices/components/washer.py | 2 +- pylabrobot/agilent/biotek/lhc/error_handling/__init__.py | 2 +- pylabrobot/agilent/biotek/lhc/tests/device_tests.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py b/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py index a2c529f6b45..105ae209e72 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py +++ b/pylabrobot/agilent/biotek/lhc/devices/components/peristaltic_dispenser.py @@ -15,11 +15,11 @@ from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime -from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_head import CassetteHead from pylabrobot.agilent.biotek.lhc.enums.steps.cassette_type import CassetteType from pylabrobot.agilent.biotek.lhc.enums.steps.peri_flow_rate import PeriFlowRate from pylabrobot.agilent.biotek.lhc.enums.steps.peri_pump import PeriPump +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( PreDispense, RandomAccess, diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py b/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py index 4cce9bea8f4..2b27d674a04 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py +++ b/pylabrobot/agilent/biotek/lhc/devices/components/syringe_dispenser.py @@ -9,9 +9,9 @@ from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime -from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.enums.steps.syringe import Syringe from pylabrobot.agilent.biotek.lhc.enums.steps.syringe_bottle import SyringeBottle +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import PreDispense, Submerge from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.masks import WellMask from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.positioning import Positioning diff --git a/pylabrobot/agilent/biotek/lhc/devices/components/washer.py b/pylabrobot/agilent/biotek/lhc/devices/components/washer.py index bce05646933..58dbfd2fa7f 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/components/washer.py +++ b/pylabrobot/agilent/biotek/lhc/devices/components/washer.py @@ -15,10 +15,10 @@ from pylabrobot.agilent.biotek.lhc.devices.execution import run_steps from pylabrobot.agilent.biotek.lhc.devices.runtime import Runtime -from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.enums.steps.buffer import Buffer from pylabrobot.agilent.biotek.lhc.enums.steps.travel_rate import TravelRate from pylabrobot.agilent.biotek.lhc.enums.steps.wash_format import WashFormat +from pylabrobot.agilent.biotek.lhc.plate_geometry.plate_record import Head from pylabrobot.agilent.biotek.lhc.protocols.steps.step_parts.groups import ( PreDispense, SecondaryAspirate, diff --git a/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py b/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py index 0e5f84c847c..c997f9354fc 100644 --- a/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py +++ b/pylabrobot/agilent/biotek/lhc/error_handling/__init__.py @@ -9,8 +9,8 @@ REPLY_TIMED_OUT, SETTINGS_DATA_TOO_OLD, UNKNOWN_CODE, - WRONG_BASECODE_PART_NUMBER, WRITE_FAILED, + WRONG_BASECODE_PART_NUMBER, AbortedError, BiotekError, ErrorInfo, diff --git a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py index 1f80d006a83..13d1682cd6e 100644 --- a/pylabrobot/agilent/biotek/lhc/tests/device_tests.py +++ b/pylabrobot/agilent/biotek/lhc/tests/device_tests.py @@ -15,6 +15,7 @@ ) from pylabrobot.agilent.biotek.lhc.devices.components.syringe_dispenser import SyringeDispenser from pylabrobot.agilent.biotek.lhc.devices.components.washer import PlateWasher +from pylabrobot.agilent.biotek.lhc.devices.handshake import BASECODE_PART_NUMBERS from pylabrobot.agilent.biotek.lhc.devices.instrument_settings import InstrumentSettings from pylabrobot.agilent.biotek.lhc.enums.instrument.basecode import Basecode from pylabrobot.agilent.biotek.lhc.enums.instrument.instrument_family import InstrumentFamily @@ -24,7 +25,6 @@ from pylabrobot.agilent.biotek.lhc.enums.plates.plate_type import PlateType from pylabrobot.agilent.biotek.lhc.enums.steps.step_action import StepAction from pylabrobot.agilent.biotek.lhc.enums.steps.step_type import StepType -from pylabrobot.agilent.biotek.lhc.devices.handshake import BASECODE_PART_NUMBERS from pylabrobot.agilent.biotek.lhc.error_handling import ( SETTINGS_DATA_TOO_OLD, WRONG_BASECODE_PART_NUMBER, From 0153c78fa60f6754d7ae00e14a00a2a586cb7461 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Fri, 11 Sep 2026 14:28:26 +0200 Subject: [PATCH 18/19] added changelog and contributions --- .github/CODEOWNERS | 7 +++++++ CHANGELOG.md | 13 +++++++++++++ docs/_static/devices.json | 3 +++ 3 files changed, 23 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 821a6639cb4..bdd7562bf26 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,13 @@ # Default owners * @PyLabRobot/core-team +# Agilent BioTek washers and dispensers +/docs/user_guide/agilent/405ts/ @stefanmaak +/docs/user_guide/agilent/el406/ @stefanmaak +/docs/user_guide/agilent/multiflo/ @stefanmaak +/docs/user_guide/agilent/multiflofx/ @stefanmaak +/pylabrobot/agilent/biotek/lhc/ @stefanmaak + # BMG CLARIOstar /docs/user_guide/bmg_labtech/clariostar/ @BioCam /pylabrobot/legacy/plate_reading/bmg_labtech/ @BioCam diff --git a/CHANGELOG.md b/CHANGELOG.md index 870ab59605e..41e21871db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - User guide notebook for the MicroSpin (`docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb`). - `Plate`: optional `stacking_z_height` parameter -- the per-plate vertical pitch when plates are stacked directly on top of each other (`size_z` minus the nesting overlap), mirroring `NestedTipRack.stacking_z_height`. Because it is a physical dimension, plates that differ in it no longer compare equal; `Plate` also now serializes `stacking_z_height` and the pre-existing `plate_type` so both round-trip through `deserialize`/`copy`. (#1110) - `ResourceStack`: bare plates stacked in the z direction now nest into one another by their `stacking_z_height` (a stack of `N` identical plates is `size_z + (N - 1) * stacking_z_height` tall, for both `get_size_z()` and child placement). Plates without a `stacking_z_height`, and plates wearing a lid, do not nest, so existing behaviour is unchanged. (#1112) +- Agilent BioTek 405 TS washer (`pylabrobot.agilent.biotek.lhc.Washer405TS`), MultiFlo (`MultiFlo`) and MultiFlo FX (`MultiFloFX`) dispensers, alongside the EL406 in one shared package: one device model, wire protocol and protocol-file format, with per-model differences expressed as configuration. Each model exposes the capability objects its fitted hardware supports (`PlateWasher`, `SyringeDispenser`, `PeristalticDispenser`) and reads the options the instrument has fitted on `setup()`. +- `.LHC` protocol files can be read, checked and run (`pylabrobot.agilent.biotek.lhc.Protocol`, `read`, `write`), including comparing the instrument settings a file records against the instrument in front of you (`compare_settings`). +- Serial transport for these instruments alongside FTDI, chosen by the port string. +- Strip washing, 1536-well washing and peristaltic wash dispense/aspirate operations, and a public `get_status()` reporting the instrument's run state and activity. +- User guide notebooks for the 405 TS, EL406, MultiFlo and MultiFlo FX (`docs/user_guide/agilent/`). ### Fixed - Imported `unittest.mock` in `pylabrobot/centrifuge/centrifuge_tests.py` (pre-existing bug that prevented the test class from running). +### Changed + +- Agilent BioTek EL406 moved from `pylabrobot.agilent.biotek.el406.EL406` to `pylabrobot.agilent.biotek.lhc.EL406`. Its operations take the step objects each operation is defined by (`...protocols.steps.steps`) and the parameter groups they are built from (`...steps.step_parts`) rather than long flat keyword lists -- `wash()` went from 39 keyword arguments to nine. Every step is checked against the settings read at `setup()` before anything moves, and plate geometry is resolved from the PyLabRobot `Plate` resource instead of being passed per command. + +### Removed + +- `pylabrobot.agilent.biotek.el406`, replaced by `pylabrobot.agilent.biotek.lhc`. + ## 0.2.1 ### Added diff --git a/docs/_static/devices.json b/docs/_static/devices.json index cea2f838acc..95e3d1a1f02 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -137,6 +137,7 @@ "api": "pylabrobot.agilent.biotek.lhc.EL406", "api_version": "v1", "code_slug": "agilent/biotek/lhc", + "doc_slug": "agilent/el406/hello-world", "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.agilent.com/en/product/cell-analysis/microplate-automation-detection/microplate-washers-dispensers/biotek-el406-washer-dispenser-1623255" }, @@ -152,6 +153,7 @@ "api": "pylabrobot.agilent.biotek.lhc.MultiFlo", "api_version": "v1", "code_slug": "agilent/biotek/lhc", + "doc_slug": "agilent/multiflo/hello-world", "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.agilent.com/en/product/microplate-instrumentation/automated-liquid-dispensing-handling/automated-microplate-dispensers/biotek-multiflo-microplate-dispenser-1623263" }, @@ -168,6 +170,7 @@ "api": "pylabrobot.agilent.biotek.lhc.MultiFloFX", "api_version": "v1", "code_slug": "agilent/biotek/lhc", + "doc_slug": "agilent/multiflofx/hello-world", "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.agilent.com/en/product/microplate-instrumentation/automated-liquid-dispensing-handling/automated-microplate-dispensers/biotek-multiflo-fx-multi-mode-dispenser-1623264" }, From 6421b8e251302a4acb68de3bfc52fe4c5b795a55 Mon Sep 17 00:00:00 2001 From: StefanMa Date: Wed, 16 Sep 2026 17:43:37 +0200 Subject: [PATCH 19/19] corrected flag checked_on_hardware for multiflofx --- pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py index 828b005849a..ceca7513b1c 100644 --- a/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py +++ b/pylabrobot/agilent/biotek/lhc/devices/multiflo_fx.py @@ -119,7 +119,7 @@ class MultiFloFX: """ family: ClassVar[InstrumentFamily] = InstrumentFamily.MULTIFLO_FX - checked_on_hardware: ClassVar[bool] = False + checked_on_hardware: ClassVar[bool] = True def __init__( self,