diff --git a/CHANGELOG.md b/CHANGELOG.md index 500fd9cbfe8..470590eb38e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- v1 `CLARIOstar`: firmware 1.20 is decoded (`DECODED_FIRMWARE` now starts at 1.200) and confirmed, from an original CLARIOstar whose INIT and stored-parameters replies lay out as 1.35's but for a longer pumps block; its recording is `clariostar_1_20_temperature_controller_pumps_2.json`. +- v1 `CLARIOstar`: `CLARIOstar.pumps` (`driver.pumps`), built when the reader reports a pump: `prime` and `backflush` (pumps 1 and 2, 500-4500 uL in steps of 500, 100, 300 or 430 uL/s), each sent once and waited on until the reader is idle; settings outside those verified on firmware 1.20 are sent with a warning. `CLARIOstarStatus.reagent_door_open` reads STATUS byte 3 bit 6; on firmware 1.20 `prime` raises `RuntimeError` without sending when that bit reads the reagent door shut. `PumpAbortedError` is raised when the reader reports a pump command aborted (quit codes 243/12 are described as the reagent door abort) or the door closes during a prime. The driver does not start a pump command during a measurement, or a measurement during a pump command. A simulated reader with pumps checks the settings and returns at once. - BMG Labtech VANTAstar plate reader (`pylabrobot.bmg_labtech.vantastar`): USB driver for absorbance (endpoint, spectra), top fluorescence intensity (endpoint, excitation and emission spectra) with automatic gain and focus and z-profiles, orbital and spiral well scanning, shaking and the incubator, and the reader's estimate of a read's run time, tested on one reader (firmware 1.12), and a luminescence read not tested on a reader with luminescence. - VANTAstar: simulation, `VANTAstar(simulation=True)`: nothing reaches a wire; the configuration comes from a declared configuration (by default the tested reader's recording), the drawer, shaker and temperature controller act at once, and absorbance (endpoint, spectra), fluorescence (endpoint) and luminescence reads return random values, or the values of an earlier result given as `simulated_data`, which a reader's reads take and ignore. - VANTAstar: `declared_configuration_json`: `setup` refuses a reader that disagrees with a configuration saved by `save_configuration` on what is fitted; the configuration decodes the fitted hardware (photomultipliers, temperature controller, pumps, spectrometer, monochromator, atmospheric control unit, stirrer, stacker, barcode readers), and the tested reader's is kept as a recording. diff --git a/pylabrobot/bmg_labtech/clariostar/__init__.py b/pylabrobot/bmg_labtech/clariostar/__init__.py index 38f84198aea..09ab38b0ac8 100644 --- a/pylabrobot/bmg_labtech/clariostar/__init__.py +++ b/pylabrobot/bmg_labtech/clariostar/__init__.py @@ -24,6 +24,8 @@ Luminescence, LuminescenceConfiguration, MeasurementSettings, + PumpAbortedError, + Pumps, ShakeSettings, TemperatureController, ) diff --git a/pylabrobot/bmg_labtech/clariostar/device.py b/pylabrobot/bmg_labtech/clariostar/device.py index f8c90ef8f6c..0e3076ccec0 100644 --- a/pylabrobot/bmg_labtech/clariostar/device.py +++ b/pylabrobot/bmg_labtech/clariostar/device.py @@ -11,6 +11,7 @@ from .driver.features.drawer import Drawer from .driver.features.fluorescence import Fluorescence from .driver.features.luminescence import Luminescence +from .driver.features.pumps import Pumps from .driver.features.temperature_controller import TemperatureController from .driver.master import CLARIOstarDriver from .driver.simulator import CLARIOstarSimulationDriver @@ -195,6 +196,11 @@ def luminescence(self) -> Optional[Luminescence]: """Luminescence measurement, or None if the reader has none; known after `setup`.""" return self.driver.luminescence + @property + def pumps(self) -> Optional[Pumps]: + """The injector pumps, or None if the reader has none; known after `setup`.""" + return self.driver.pumps + async def setup(self) -> None: """Connect, initialize the reader (about 13 s), and check it is the model this device is. diff --git a/pylabrobot/bmg_labtech/clariostar/driver/__init__.py b/pylabrobot/bmg_labtech/clariostar/driver/__init__.py index 25678c9af99..e247e0ef96c 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/__init__.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/__init__.py @@ -2,7 +2,7 @@ from .configuration import DeviceConfiguration from .confirmed_firmware_versions import CONFIRMED_FIRMWARE_VERSIONS -from .errors import CommandRefusedError +from .errors import CommandRefusedError, PumpAbortedError from .features import ( Absorbance, AbsorbanceConfiguration, @@ -18,6 +18,7 @@ Luminescence, LuminescenceConfiguration, MeasurementSettings, + Pumps, ShakeSettings, TemperatureController, ) diff --git a/pylabrobot/bmg_labtech/clariostar/driver/configuration.py b/pylabrobot/bmg_labtech/clariostar/driver/configuration.py index ef8b229161b..e710864481e 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/configuration.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/configuration.py @@ -11,8 +11,10 @@ INIT_REPLY_TYPE = 0x13 # The firmware whose replies are decoded, as (lowest, highest) firmware number x 1000: the 1.x line. -# Older firmware is untested; 2.x (the line with native USB) lays its replies out unseen. -DECODED_FIRMWARE = (1300, 1999) +# 1.20 (an original CLARIOstar) lays out its INIT and stored parameters replies as 1.35 does, but +# for a longer pumps block; older firmware is untested; 2.x (the line with native USB) lays its +# replies out unseen. +DECODED_FIRMWARE = (1200, 1999) _BARCODE_READER_TYPES = {0: "SE 923", 1: "CSE 600", 2: "SE 955", 3: "SE 965"} BARCODE_READER_POSITIONS = ("front", "right", "optional 1", "optional 2") diff --git a/pylabrobot/bmg_labtech/clariostar/driver/configuration_tests.py b/pylabrobot/bmg_labtech/clariostar/driver/configuration_tests.py index 934d619c3b7..43df9c629ad 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/configuration_tests.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/configuration_tests.py @@ -70,9 +70,9 @@ def test_monochromator_slides(self): self.assertEqual(slides["EM_SLIT"][6], "LUM lense") def test_undecoded_firmware_fills_only_the_main_board(self): - # Not a reader's reply: the recorded one with its firmware field set to 1.29, and to 2.11, the + # Not a reader's reply: the recorded one with its firmware field set to 1.19, and to 2.11, the # line with native USB; no reader with either has answered. - for firmware, version in ((1290, "1.29"), (2110, "2.11")): + for firmware, version in ((1190, "1.19"), (2110, "2.11")): payload = bytearray(INIT_PAYLOAD) payload[9:11] = firmware.to_bytes(2, "big") configuration = DeviceConfiguration() @@ -277,3 +277,90 @@ def test_nothing_read_cannot_be_saved(self): if __name__ == "__main__": unittest.main() + + +# An original CLARIOstar (main board revision 1) on firmware 1.20, compiled 2017-04-27: the +# payloads of its INIT, stored parameters and firmware info replies at its first connection. The +# serial number is zeroed at SERIAL_NUMBER_ADDRESSES, and the pumps' part numbers in block 0F's +# text are zeroed too; neither is decoded. +# INIT reply payload. +CLARIOSTAR_1_20_INIT_PAYLOAD = bytes.fromhex( + "13250006300801000604b001020102090009010942500100013d460d000220000e00032700000f001f030550313a" + "303030303030303020430d0a50323a303030303030303020430011000300000012000200001f00090100ec102204" + "3d00212e000901044c0001001b32b12f017845585f53503a313d6f70656e2c323d46696c74657220312c333d4669" + "6c74657220320d0a45585f4c503a313d6f70656e2c323d46696c74657220312c333d46696c74657220320d0a4253" + "503a313d6f70656e2c323d46696c74657220312c333d46696c74657220322c343d46696c74657220330d0a454d5f" + "53503a313d6f70656e2c323d46696c74657220312c333d46696c74657220320d0a454d5f4c503a313d6f70656e2c" + "323d46696c74657220312c333d46696c74657220320d0a454d5f534c49543a313d6f70656e2c323d536c69742030" + "2e382c333d536c697420312e322c343d536c697420322e302c353d536c697420332e302c373d4650207061722c38" + "3d4650207065722c363d4c554d206c656e73650d0a45585f534c49543a313d6f70656e2c323d536c697420302e36" + "2c333d536c697420312e302c343d536c697420312e362c353d536c697420322e332c383d46502053746472642c37" + "3d46502055562c393d4142532c363d462d436865636b0d0a000131000700000000000000" +) +# Stored parameters reply payload. +CLARIOSTAR_1_20_PARAMETERS_PAYLOAD = bytes.fromhex( + "070500263008000100000a0c0c010100000100540b00000f00c50000000000000003040000010000010200000000" + "00000000000032000000000000000000000000000000000000000074006f0000000000000065000000dc05000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000" + "000101000000000000000100000100000000000f02980600000000b004000004005b0c00000000a40058ff8e030d" + "0560ff6211fe0b621100001102990655ff950600000000b0140000000091009001463228460a0046061e00000000" + "00000000001e03bd0628002c01900146001e000010120000000000000000000000" +) +# Firmware info reply payload. +CLARIOSTAR_1_20_FIRMWARE_INFO_PAYLOAD = bytes.fromhex( + "0a050026300804b041707220323720323031370031333a32343a333400737570706f7274206f6620646f75626c65" + "20504d5420636f6e66696775726174696f6e20287769746820657874656e64656420495220504d54293b206e6577" + "207363616e206d6f64652073706972616c207363616e6e696e673b20636f6e74726f6c206f662041435520647269" + "76656e2046414e323b20696d70726f76656420706175736520666561747572653a20706175736520696e2077656c" + "6c206d6f64652c20636f6e74696e7565207768656e20656e7669726f6e6d656e7420636f6e646974696f6e207265" + "61636865643b206e65772072656167656e742073617665207072696d696e67206f7074696f6e3b206e6577207368" + "616b65206d6f64657320726f756e6420616e64206f76616c3b20696d70726f76656d656e74732072656761726469" + "6e67207465737420657865637574696f6e2028666173746572207072657061726174696f6e2074696d652c20696e" + "6a6563742d6f6e6c792070726f746f636f6c732c2e2e293b0001" +) +RECORDING_CLARIOSTAR_1_20 = os.path.join( + os.path.dirname(__file__), "recordings", "clariostar_1_20_temperature_controller_pumps_2.json" +) + + +class OriginalCLARIOstarFirmware120Tests(unittest.TestCase): + """The replies of an original CLARIOstar on firmware 1.20 decode to its recording.""" + + def decoded(self) -> DeviceConfiguration: + configuration = DeviceConfiguration() + configuration.update_from_init_reply(CLARIOSTAR_1_20_INIT_PAYLOAD) + configuration.update_from_parameters( + master_module.CLARIOstarDriver._decode_parameters(CLARIOSTAR_1_20_PARAMETERS_PAYLOAD) + ) + return configuration + + def test_replies_decode_to_the_recording(self): + with open(RECORDING_CLARIOSTAR_1_20) as f: + recorded = json.load(f)["device"] + self.assertEqual(configuration_module.configuration_to_json(self.decoded()), recorded) + + def test_recording_name(self): + self.assertEqual( + configuration_module.compute_recording_name(self.decoded()), + os.path.basename(RECORDING_CLARIOSTAR_1_20), + ) + + def test_init_and_parameters_agree_on_the_firmware(self): + parameters = master_module.CLARIOstarDriver._decode_parameters( + CLARIOSTAR_1_20_PARAMETERS_PAYLOAD + ) + self.assertEqual(parameters["firmware_version"], 1200) + self.assertEqual(self.decoded().firmware_version, "1.20") + + def test_firmware_build_date(self): + self.assertEqual( + master_module.CLARIOstarDriver._decode_firmware_build_date( + CLARIOSTAR_1_20_FIRMWARE_INFO_PAYLOAD + ), + "2017-04-27", + ) + + def test_two_xcalibur_pumps_from_a_longer_pumps_block(self): + blocks = dict(configuration_module._blocks(CLARIOSTAR_1_20_INIT_PAYLOAD)) + self.assertEqual(len(blocks[0x0F]), 31) # 3 on the recorded Plus, which has no pumps + self.assertEqual(self.decoded().pumps, ("XCalibur", "XCalibur", None)) diff --git a/pylabrobot/bmg_labtech/clariostar/driver/confirmed_firmware_versions.py b/pylabrobot/bmg_labtech/clariostar/driver/confirmed_firmware_versions.py index fa64d630714..01a2a33debe 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/confirmed_firmware_versions.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/confirmed_firmware_versions.py @@ -2,4 +2,4 @@ from typing import FrozenSet -CONFIRMED_FIRMWARE_VERSIONS: FrozenSet[str] = frozenset({"1.35"}) +CONFIRMED_FIRMWARE_VERSIONS: FrozenSet[str] = frozenset({"1.20", "1.35"}) diff --git a/pylabrobot/bmg_labtech/clariostar/driver/errors.py b/pylabrobot/bmg_labtech/clariostar/driver/errors.py index cd302db03e7..3cccc3bd87a 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/errors.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/errors.py @@ -45,9 +45,18 @@ def parse_warning_codes(reply: bytes) -> Optional[Tuple[int, int, int, int, int] } +# Where the first code alone does not name the reason: (first, second) -> description. +QUIT_CODE_PAIR_DESCRIPTIONS: Dict[Tuple[int, int], str] = { + (243, 12): "pump priming aborted: the reagent door is closed", +} + + def describe_quit_codes(quit_codes: Tuple[int, int, int, int, int]) -> str: """Format quit codes as `code (description), quit codes q1/q2/q3/q4/q5`.""" - description = QUIT_CODE_DESCRIPTIONS.get(quit_codes[0], "no description known") + description = QUIT_CODE_PAIR_DESCRIPTIONS.get( + (quit_codes[0], quit_codes[1]), + QUIT_CODE_DESCRIPTIONS.get(quit_codes[0], "no description known"), + ) return ( f"quit code {quit_codes[0]} ({description}), quit codes {'/'.join(str(q) for q in quit_codes)}" ) @@ -65,3 +74,24 @@ def __init__(self, payload: bytes, quit_codes: Tuple[int, int, int, int, int]): self.payload = payload self.quit_codes = quit_codes super().__init__(f"CLARIOstar refused {payload.hex()}: {describe_quit_codes(quit_codes)}") + + +class PumpAbortedError(Exception): + """A pump command ended early: the reader reported it aborted, or the reagent door closed.""" + + def __init__( + self, + parameters: bytes, + reason: str, + quit_codes: Optional[Tuple[int, int, int, int, int]] = None, + ): + """ + Args: + parameters: the pump command's parameters (after the family byte). + reason: why the command ended early. + quit_codes: the five codes of the reader's warning, None if it was not read. + """ + self.parameters = parameters + self.reason = reason + self.quit_codes = quit_codes + super().__init__(f"CLARIOstar pump command {parameters.hex()} ended early: {reason}") diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/__init__.py b/pylabrobot/bmg_labtech/clariostar/driver/features/__init__.py index ebc7ab5c0a8..31c87268683 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/features/__init__.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/__init__.py @@ -18,4 +18,5 @@ LuminescenceConfiguration, ) from .measurement import MeasurementSettings, ShakeSettings +from .pumps import Pumps from .temperature_controller import TemperatureController diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/measurement.py b/pylabrobot/bmg_labtech/clariostar/driver/features/measurement.py index 7e053d5ac31..3b4370e513c 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/features/measurement.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/measurement.py @@ -86,7 +86,9 @@ def _enforce_one_measurement_at_a_time(run: _Run) -> _Run: @functools.wraps(run) async def guarded(self: "Measurement", *args: Any, **kwargs: Any) -> Any: if self._driver._measuring: - raise RuntimeError("a measurement is already running on this reader; wait for it to end") + raise RuntimeError( + "a measurement or pump command is already running on this reader; wait for it to end" + ) self._driver._measuring = True try: return await run(self, *args, **kwargs) diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/pumps.py b/pylabrobot/bmg_labtech/clariostar/driver/features/pumps.py new file mode 100644 index 00000000000..a7be3aa4c93 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/pumps.py @@ -0,0 +1,294 @@ +"""The injector pumps: prime and backflush (PUMP command family, 0x02). + +Verified on an original CLARIOstar with firmware 1.20 and two XCalibur pumps: the command encoding +for the settings in `SEEN_PRIME` and `SEEN_BACKFLUSH`, backflushes of pumps 1 and 2 and primes of +pump 1 at 500 uL and 100 uL/s, and the reagent door bit while idle. +""" + +import asyncio +import logging +import time +from typing import TYPE_CHECKING, Dict, FrozenSet, Optional, Tuple + +from pylabrobot.bmg_labtech.protocol.errors import FrameError + +from ..errors import PumpAbortedError, describe_quit_codes, parse_warning_codes + +if TYPE_CHECKING: + from ..master import CLARIOstarDriver + +logger = logging.getLogger(__name__) + + +class Pumps: + """Prime and backflush the injector pumps. + + Reached as `driver.pumps` (or `CLARIOstar.pumps`), built when the reader reports a pump. A + command is `02 `; the pump byte is the pump + number, plus 0x10 for a backflush. The reader answers the command at once, then runs it for + several seconds to minutes and reports busy until it is done. + """ + + # Flow rate (uL/s) -> flow rate code. + FLOW_RATE_CODES: Dict[int, int] = {100: 0x0C, 300: 0x04, 430: 0x01} + # Volume byte = volume / 500 uL. + VOLUME_UNIT_UL = 500 + MIN_VOLUME_UL = 500 + MAX_VOLUME_UL = 4500 + # Pumps whose byte is verified (pump 3's byte is not known). + ENCODED_PUMPS = (1, 2) + BACKFLUSH_FLAG = 0x10 + # Settings whose encoding is verified on a reader, as (flow rates, volumes); the others in range + # follow the same rule and are accepted with a warning. + SEEN_PRIME: Tuple[FrozenSet[int], FrozenSet[int]] = ( + frozenset({100, 300, 430}), + frozenset({500, 2000, 4500}), + ) + SEEN_BACKFLUSH: Tuple[FrozenSet[int], FrozenSet[int]] = ( + frozenset({100, 430}), + frozenset({500, 1000}), + ) + # Firmware on which STATUS byte 3 bit 6 is checked to be the reagent door. + DOOR_CHECKED_FIRMWARE: FrozenSet[str] = frozenset({"1.20"}) + + # Seconds a pump command may run: 2000 uL at 100 uL/s takes about 1 min, 4500 uL about 2 min. + default_timeout: float = 300.0 + # Seconds for the reader to report busy after the command; a command's reply can read idle + # while the reader starts it. + default_start_timeout: float = 5.0 + default_poll_interval: float = 0.25 + + def __init__(self, driver: "CLARIOstarDriver"): + """ + Args: + driver: the driver to send commands through. + """ + self._driver = driver + + def _fitted(self) -> Tuple[int, ...]: + """The numbers of the pumps the reader reports.""" + pumps = self._driver.configuration.pumps or (None, None, None) + return tuple(i for i, kind in enumerate(pumps, 1) if kind is not None) + + def _door_checked(self) -> bool: + """Whether the reader's firmware is one on which the reagent door bit is checked.""" + return self._driver.configuration.firmware_version in self.DOOR_CHECKED_FIRMWARE + + def _encode(self, pump: int, volume: float, flow_rate: float, backflush: bool) -> bytes: + """The command's parameters (after the family byte); logs a warning for a setting whose + encoding is inferred rather than verified. + + Raises: + ValueError: listing every setting that is out of range, unencoded or not fitted. + """ + errors = [] + if not isinstance(pump, int) or pump not in self.ENCODED_PUMPS: + errors.append(f"pump must be one of {self.ENCODED_PUMPS}, got {pump!r}") + elif pump not in self._fitted(): + errors.append(f"pump {pump} is not fitted (fitted: {self._fitted() or 'none'})") + if flow_rate not in self.FLOW_RATE_CODES: + errors.append( + f"flow_rate must be one of {sorted(self.FLOW_RATE_CODES)} uL/s, got {flow_rate:g}" + ) + if not self.MIN_VOLUME_UL <= volume <= self.MAX_VOLUME_UL or volume % self.VOLUME_UNIT_UL: + errors.append( + f"volume must be a multiple of {self.VOLUME_UNIT_UL} uL from {self.MIN_VOLUME_UL} to" + f" {self.MAX_VOLUME_UL}, got {volume:g}" + ) + if errors: + raise ValueError("; ".join(errors)) + seen_flow_rates, seen_volumes = self.SEEN_BACKFLUSH if backflush else self.SEEN_PRIME + if flow_rate not in seen_flow_rates or volume not in seen_volumes: + logger.warning( + "%s at %g uL and %g uL/s: this setting's encoding is inferred, not verified on a reader", + "backflush" if backflush else "prime", + volume, + flow_rate, + ) + pump_byte = pump | (self.BACKFLUSH_FLAG if backflush else 0) + return bytes( + [pump_byte, self.FLOW_RATE_CODES[int(flow_rate)], int(volume) // self.VOLUME_UNIT_UL] + ) + + async def _run( + self, + parameters: bytes, + timeout: Optional[float], + start_timeout: Optional[float], + is_prime: bool, + ) -> None: + """Read the status, send a pump command once, and wait until the reader has run it. + + Done once the reader has reported busy (in the command's reply or a later status) and then + idle. The command is sent once: a malformed reply does not mean the reader missed the command, + so the driver discards the reply and reads busy and idle from status polls, as `initialize` + does for INIT. + + The reader reports an aborted command with a warning (quit codes) in a status reply. A + warning already present before the command is not taken for a new one. A warning sent between + exchanges can be lost on the FTDI read path, so on firmware in `DOOR_CHECKED_FIRMWARE` a prime + also watches the reagent door (STATUS byte 3 bit 6). With the door shut the driver sends + nothing. If the door closes during the prime, the driver waits until the reader reads idle, + because the reader may still be pumping, and then raises `PumpAbortedError`. + + After a timeout or a cancelled call the reader keeps running the command; the driver sends no + stop. + + Args: + parameters: the command's parameters. + timeout: seconds the command may run; None uses `default_timeout`. + start_timeout: seconds for the reader to report busy; None uses `default_start_timeout`. + is_prime: the command is a prime (the reagent door applies). + + Raises: + RuntimeError: if a measurement or another pump command is running on the reader, or for a + prime with the reagent door shut; nothing is sent. + PumpAbortedError: if a reply contains a new device warning, or the door closed during a prime + (raised once the reader is idle). + TimeoutError: if the reader does not report busy within `start_timeout`, or is still busy + after `timeout`. + CommandRefusedError: if a status reply has the refused bit set. + FrameError: if status replies stay malformed after retries. + """ + driver = self._driver + if driver._measuring: + raise RuntimeError( + "a measurement or pump command is already running on this reader; wait for it to end" + ) + driver._measuring = True + try: + await self._send_and_wait(parameters, timeout, start_timeout, is_prime) + finally: + driver._measuring = False + + async def _send_and_wait( + self, + parameters: bytes, + timeout: Optional[float], + start_timeout: Optional[float], + is_prime: bool, + ) -> None: + """`_run` without the one-operation guard.""" + driver = self._driver + timeout = self.default_timeout if timeout is None else timeout + start_timeout = self.default_start_timeout if start_timeout is None else start_timeout + door_open = (await driver.request_status()).reagent_door_open + stale = driver._last_warning + watch_door = is_prime and self._door_checked() + if watch_door and not door_open: + raise RuntimeError("open the reagent door: the reader primes only with it open") + if is_prime and not watch_door: + logger.warning( + "the reagent door bit is not checked on firmware %s; priming without checking the door", + driver.configuration.firmware_version, + ) + door_closed = False + try: + reply = await driver.send_command(driver.CommandFamily.PUMP, parameters=parameters, retries=1) + except FrameError as e: + logger.warning("pump command reply malformed (%s); waiting on status instead", e) + await driver._purge_rx() + started = False + else: + warning = parse_warning_codes(reply) + if warning is not None and warning != stale: + raise PumpAbortedError(parameters, describe_quit_codes(warning), warning) + started = driver._parse_status(reply).busy + status_payload = bytes([driver.CommandFamily.STATUS]) + start = time.monotonic() + while True: + await asyncio.sleep(self.default_poll_interval) + reply = await driver._exchange_with_retries(status_payload, retries=5) + warning = parse_warning_codes(reply) + driver._check_reply(status_payload, reply) + if warning is not None and warning != stale: + # Usually this poll's own reply. If the warning came unasked instead, the poll's own reply + # is still on its way: discard it, or every later reply is one behind. + await driver._drain_rx() + raise PumpAbortedError(parameters, describe_quit_codes(warning), warning) + status = driver._parse_status(reply) + busy = status.busy + if watch_door and not door_closed and not status.reagent_door_open: + door_closed = True + logger.warning("the reagent door closed during the prime; waiting until the reader is idle") + elapsed = time.monotonic() - start + if busy: + started = True + elif started: + if door_closed: + raise PumpAbortedError( + parameters, "the reagent door closed during the prime; the reader is idle" + ) + return + elif elapsed >= start_timeout: + raise TimeoutError(f"the reader did not start the pump command within {start_timeout} s") + if elapsed >= timeout: + raise TimeoutError(f"the pump command still runs after {timeout} s") + + async def prime( + self, + pump: int, + volume: float, + flow_rate: float = 100, + timeout: Optional[float] = None, + start_timeout: Optional[float] = None, + ) -> None: + """Prime a pump: draw reagent and push it out through the injector. + + The reader primes only with the reagent door open (STATUS byte 3 bit 6, + `CLARIOstarStatus.reagent_door_open`); with it shut the reader answers a prime with quit codes + 243/12. On firmware in `DOOR_CHECKED_FIRMWARE` this method raises `RuntimeError` without + sending when the door bit reads shut. On other firmware it does not check the door, and the + reader's quit codes raise `PumpAbortedError`. How much a prime delivers when it ends early is + not known. + + Args: + pump: 1 or 2. + volume: uL, a multiple of 500 from 500 to 4500. + flow_rate: uL/s, one of `FLOW_RATE_CODES`. + timeout: seconds the prime may run; None uses `default_timeout`. A timeout leaves the + reader pumping. + start_timeout: seconds for the reader to report busy; None uses `default_start_timeout`. + + Raises: + ValueError: if a setting is out of range or the pump is not fitted; nothing is sent. + RuntimeError: if the reagent door is shut, or another operation is running; nothing is + sent. + PumpAbortedError: if the reader reported the prime aborted, or the door closed during it. + TimeoutError: if the reader does not start or end the prime in time. + CommandRefusedError: if a status reply has the refused bit set. + FrameError: if status replies stay malformed after retries. + """ + parameters = self._encode(pump, volume, flow_rate, backflush=False) + await self._run(parameters, timeout, start_timeout, is_prime=True) + logger.info("pump %d primed with %g uL at %g uL/s", pump, volume, flow_rate) + + async def backflush( + self, + pump: int, + volume: float, + flow_rate: float = 100, + timeout: Optional[float] = None, + start_timeout: Optional[float] = None, + ) -> None: + """Backflush a pump: push its line's contents back towards the reagent. + + Args: + pump: 1 or 2. + volume: uL, a multiple of 500 from 500 to 4500. + flow_rate: uL/s, one of `FLOW_RATE_CODES`. + timeout: seconds the backflush may run; None uses `default_timeout`. A timeout leaves the + reader pumping. + start_timeout: seconds for the reader to report busy; None uses `default_start_timeout`. + + Raises: + ValueError: if a setting is out of range or the pump is not fitted; nothing is sent. + RuntimeError: if another operation is running; nothing is sent. + PumpAbortedError: if the reader reported the backflush aborted. + TimeoutError: if the reader does not start or end the backflush in time. + CommandRefusedError: if a status reply has the refused bit set. + FrameError: if status replies stay malformed after retries. + """ + parameters = self._encode(pump, volume, flow_rate, backflush=True) + await self._run(parameters, timeout, start_timeout, is_prime=False) + logger.info("pump %d backflushed with %g uL at %g uL/s", pump, volume, flow_rate) diff --git a/pylabrobot/bmg_labtech/clariostar/driver/features/pumps_tests.py b/pylabrobot/bmg_labtech/clariostar/driver/features/pumps_tests.py new file mode 100644 index 00000000000..17c97f8204c --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/driver/features/pumps_tests.py @@ -0,0 +1,311 @@ +"""Pump tests over a fake io, with command frames as an original CLARIOstar (firmware 1.20) takes them.""" + +import unittest +from typing import Optional, Tuple + +from pylabrobot.bmg_labtech.clariostar.driver.errors import PumpAbortedError, describe_quit_codes +from pylabrobot.bmg_labtech.clariostar.driver.features.pumps import Pumps +from pylabrobot.bmg_labtech.clariostar.driver.master_tests import ( + STATUS_IDLE, + STATUS_QUERY_FRAME, + FakeFTDI, + make_driver, + parse_status, +) +from pylabrobot.bmg_labtech.protocol.framing import ftdi_extract_payload, ftdi_wrap_payload + +PUMPS_LOGGER = "pylabrobot.bmg_labtech.clariostar.driver.features.pumps" + +# Command frames verified on firmware 1.20, and replies to them. +BACKFLUSH_REFERENCE = bytes.fromhex("02000c0c02110c0100003a0d") # pump 1, 100 uL/s, 500 uL +BACKFLUSH_PUMP_2 = bytes.fromhex("02000c0c02120c0100003b0d") +BACKFLUSH_1000_UL = bytes.fromhex("02000c0c02110c0200003b0d") +BACKFLUSH_430_UL_S = bytes.fromhex("02000c0c0211010100002f0d") +PRIME_REFERENCE = bytes.fromhex("02000c0c02010c0100002a0d") # pump 1, 100 uL/s, 500 uL +PRIME_300_UL_S = bytes.fromhex("02000c0c020104010000220d") +PRIME_2000_UL = bytes.fromhex("02000c0c02010c0400002d0d") +# A backflush's reply that reads idle although the reader then runs it. +BACKFLUSH_REFERENCE_REPLY = bytes.fromhex("0200180c010500043008000000000000000000c00001280d") +BACKFLUSH_REPLY = bytes.fromhex("0200180c012500042008020000000000000000c000013a0d") +PRIME_REPLY = bytes.fromhex("0200180c012500440000020000000000000000c00001520d") # door open +# A prime's reply with the door bit clear, and a warning with the door-abort codes 243/12. +PRIME_DOOR_SHUT_REPLY = bytes.fromhex("0200180c012500040000020000000000000000c00001120d") +PRIME_DOOR_SHUT_WARNING = bytes.fromhex("0200180c018500040000f30c00000000000000c000026f0d") + + +def status(busy: bool, door_open: bool, warning: Optional[Tuple[int, int]] = None) -> bytes: + """A STATUS reply from this reader with the busy and door bits, and a warning's first two codes, + set as given (synthetic), built from a reply to a prime.""" + payload = bytearray(ftdi_extract_payload(PRIME_REPLY)) + payload[1] = (payload[1] & ~0x20) | (0x20 if busy else 0) + payload[3] = (payload[3] & ~0x40) | (0x40 if door_open else 0) + if warning is not None: + payload[1] |= 0x80 + payload[6:8] = bytes(warning) + return ftdi_wrap_payload(bytes(payload), header=0x0C) + + +IDLE = status(busy=False, door_open=False) +BUSY = status(busy=True, door_open=False) +IDLE_DOOR_OPEN = status(busy=False, door_open=True) +BUSY_DOOR_OPEN = status(busy=True, door_open=True) +# A warning left from an earlier command (codes made up), present before a pump command. +STALE_IDLE = status(busy=False, door_open=False, warning=(110, 0)) +STALE_BUSY = status(busy=True, door_open=False, warning=(110, 0)) + + +class PumpTestCase(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.use_chunk(25) + + def use_chunk(self, chunk: int) -> None: + """A fresh driver over a fake io that answers in reads of `chunk` bytes.""" + self.io = FakeFTDI(chunk=chunk) + self.driver = make_driver(self.io) + self.driver.configuration.pumps = ("XCalibur", "XCalibur", None) + self.driver.configuration.firmware_version = "1.20" + self.driver._discover_features() + assert self.driver.pumps is not None + self.pumps: Pumps = self.driver.pumps + self.pumps.default_poll_interval = 0 + + +class StatusTests(unittest.TestCase): + def test_the_door_bit_reads_from_recorded_replies(self): + self.assertTrue(parse_status(PRIME_REPLY).reagent_door_open) + self.assertFalse(parse_status(BACKFLUSH_REPLY).reagent_door_open) + self.assertFalse(parse_status(PRIME_DOOR_SHUT_REPLY).reagent_door_open) + self.assertFalse(parse_status(STATUS_IDLE).reagent_door_open) # a Plus without pumps + + def test_the_door_abort_is_described_by_its_two_codes(self): + self.assertIn("reagent door", describe_quit_codes((243, 12, 0, 0, 0))) + self.assertIn("wellplate", describe_quit_codes((243, 0, 0, 0, 0))) + + +class FeatureTests(unittest.TestCase): + def test_built_only_when_a_pump_is_reported(self): + driver = make_driver(FakeFTDI()) + driver._discover_features() + self.assertIsNone(driver.pumps) + driver.configuration.pumps = (None, None, None) + driver._discover_features() + self.assertIsNone(driver.pumps) + driver.configuration.pumps = ("XCalibur", None, None) + driver._discover_features() + self.assertIsInstance(driver.pumps, Pumps) + + +class EncodingTests(PumpTestCase): + async def test_backflushes_send_the_verified_frames(self): + for (pump, volume, flow_rate), frame in [ + ((1, 500, 100), BACKFLUSH_REFERENCE), + ((2, 500, 100), BACKFLUSH_PUMP_2), + ((1, 1000, 100), BACKFLUSH_1000_UL), + ((1, 500, 430), BACKFLUSH_430_UL_S), + ]: + self.io.written.clear() + self.io.replies = [IDLE, IDLE, BACKFLUSH_REPLY, IDLE] + await self.pumps.backflush(pump, volume=volume, flow_rate=flow_rate) + self.assertEqual(self.io.written[2], frame) + + async def test_primes_send_the_verified_frames(self): + for (pump, volume, flow_rate), frame in [ + ((1, 500, 100), PRIME_REFERENCE), + ((1, 500, 300), PRIME_300_UL_S), + ((1, 2000, 100), PRIME_2000_UL), + ]: + self.io.written.clear() + self.io.replies = [IDLE_DOOR_OPEN, IDLE_DOOR_OPEN, PRIME_REPLY, IDLE_DOOR_OPEN] + await self.pumps.prime(pump, volume=volume, flow_rate=flow_rate) + self.assertEqual(self.io.written[2], frame) + + async def test_an_inferred_setting_is_sent_with_a_warning(self): + self.io.replies = [IDLE, IDLE, BACKFLUSH_REPLY, BUSY, IDLE] + with self.assertLogs(PUMPS_LOGGER, level="WARNING") as logs: + await self.pumps.backflush(1, volume=1500, flow_rate=300) + self.assertIn("inferred", "\n".join(logs.output)) + self.assertEqual(self.io.written[2][4:8], bytes.fromhex("02110403")) + + async def test_a_pump_number_that_is_not_an_int_is_refused(self): + with self.assertRaisesRegex(ValueError, "pump must be one of"): + await self.pumps.prime(1.0, volume=500) # type: ignore[arg-type] + self.assertEqual(self.io.written, []) + + def test_the_largest_volume_has_byte_9(self): + # Pump 1, 4500 uL at 100 uL/s is 02 01 0c 09. + self.assertEqual(self.pumps._encode(1, 4500, 100, backflush=False), bytes.fromhex("010c09")) + + async def test_every_setting_error_is_listed_and_nothing_is_sent(self): + with self.assertRaises(ValueError) as caught: + await self.pumps.prime(3, volume=750, flow_rate=50) + message = str(caught.exception) + self.assertIn("pump must be one of", message) + self.assertIn("flow_rate must be one of", message) + self.assertIn("volume must be a multiple", message) + for volume in (0, 5000): + with self.assertRaises(ValueError): + await self.pumps.backflush(1, volume=volume) + self.assertEqual(self.io.written, []) + + async def test_a_pump_not_fitted_is_refused(self): + self.driver.configuration.pumps = ("XCalibur", None, None) + with self.assertRaisesRegex(ValueError, "pump 2 is not fitted"): + await self.pumps.backflush(2, volume=500) + self.assertEqual(self.io.written, []) + + +class RunTests(PumpTestCase): + async def test_on_firmware_whose_door_bit_is_not_checked_a_prime_is_sent_with_a_warning(self): + self.driver.configuration.firmware_version = "1.35" + self.io.replies = [IDLE, IDLE, PRIME_DOOR_SHUT_REPLY, BUSY, IDLE] + with self.assertLogs(PUMPS_LOGGER, level="WARNING") as logs: + await self.pumps.prime(1, volume=500) + self.assertIn("not checked on firmware 1.35", "\n".join(logs.output)) + self.assertEqual(self.io.written[2], PRIME_REFERENCE) + + async def test_the_readers_refusal_of_a_shut_door_raises_where_the_door_is_not_checked(self): + self.driver.configuration.firmware_version = "1.35" + refused = status(busy=False, door_open=False, warning=(243, 12)) + self.io.replies = [IDLE, IDLE, PRIME_DOOR_SHUT_REPLY, refused, IDLE] + with self.assertLogs(PUMPS_LOGGER, level="WARNING"): + with self.assertRaises(PumpAbortedError) as caught: + await self.pumps.prime(1, volume=500) + self.assertEqual(caught.exception.quit_codes, (243, 12, 0, 0, 0)) + + async def test_a_warning_while_still_busy_aborts_at_once(self): + # The reader itself reports the command aborted, so no wait for idle. + aborted = status(busy=True, door_open=True, warning=(243, 12)) + self.io.replies = [IDLE_DOOR_OPEN, IDLE_DOOR_OPEN, PRIME_REPLY, aborted] + with self.assertRaises(PumpAbortedError) as caught: + await self.pumps.prime(1, volume=500) + self.assertEqual(caught.exception.quit_codes, (243, 12, 0, 0, 0)) + + async def test_a_door_that_closes_and_reopens_still_ends_the_prime_early(self): + self.io.replies = [ + IDLE_DOOR_OPEN, IDLE_DOOR_OPEN, PRIME_REPLY, BUSY, BUSY_DOOR_OPEN, IDLE_DOOR_OPEN, + ] # fmt: skip + with self.assertLogs(PUMPS_LOGGER, level="WARNING"): + with self.assertRaisesRegex(PumpAbortedError, "reagent door closed during the prime"): + await self.pumps.prime(1, volume=500) + + async def test_refused_while_another_operation_runs(self): + self.driver._measuring = True + with self.assertRaisesRegex(RuntimeError, "running on this reader"): + await self.pumps.backflush(1, volume=500) + self.assertEqual(self.io.written, []) + + async def test_the_guard_is_released_after_a_pump_command(self): + self.io.replies = [IDLE, IDLE, BACKFLUSH_REPLY, BUSY, IDLE] + await self.pumps.backflush(1, volume=500) + self.assertFalse(self.driver._measuring) + + async def test_a_prime_with_the_door_shut_sends_nothing(self): + self.io.replies = [IDLE] + with self.assertRaisesRegex(RuntimeError, "reagent door"): + await self.pumps.prime(1, volume=500) + self.assertEqual(self.io.written, [STATUS_QUERY_FRAME]) + + async def test_the_door_closing_with_the_reader_warning_aborts_with_its_codes(self): + # Door bit clear, busy clear and the 243/12 warning in one poll's reply. + closed = status(busy=False, door_open=False, warning=(243, 12)) + self.io.replies = [IDLE_DOOR_OPEN, IDLE_DOOR_OPEN, PRIME_REPLY, BUSY_DOOR_OPEN, closed] + with self.assertRaises(PumpAbortedError) as caught: + await self.pumps.prime(1, volume=500) + self.assertEqual(caught.exception.quit_codes, (243, 12, 0, 0, 0)) + + async def test_the_door_closing_without_a_warning_waits_for_idle_then_aborts(self): + # The reader may still be pumping: no stop is reported until it reads idle. + self.io.replies = [ + IDLE_DOOR_OPEN, IDLE_DOOR_OPEN, PRIME_REPLY, BUSY_DOOR_OPEN, BUSY, BUSY, IDLE, + ] # fmt: skip + with self.assertLogs(PUMPS_LOGGER, level="WARNING") as logs: + with self.assertRaisesRegex(PumpAbortedError, "reagent door closed during the prime"): + await self.pumps.prime(1, volume=500) + self.assertIn("waiting until the reader is idle", "\n".join(logs.output)) + self.assertEqual(len(self.io.written), 7) + + async def test_waits_for_busy_even_when_the_reply_reads_idle(self): + self.io.replies = [IDLE, IDLE, BACKFLUSH_REFERENCE_REPLY, IDLE, BUSY, BUSY, IDLE] + await self.pumps.backflush(1, volume=500) + self.assertEqual( + self.io.written, [STATUS_QUERY_FRAME] * 2 + [BACKFLUSH_REFERENCE] + [STATUS_QUERY_FRAME] * 4 + ) + + async def test_a_busy_reply_counts_as_started(self): + self.io.replies = [IDLE_DOOR_OPEN, IDLE_DOOR_OPEN, PRIME_REPLY, BUSY_DOOR_OPEN, IDLE_DOOR_OPEN] + await self.pumps.prime(1, volume=500) + self.assertEqual(len(self.io.written), 5) + + async def test_a_pump_that_never_starts_times_out(self): + self.io.replies = [IDLE, IDLE, BACKFLUSH_REFERENCE_REPLY, IDLE] + with self.assertRaisesRegex(TimeoutError, "did not start"): + await self.pumps.backflush(1, volume=500, start_timeout=0) + + async def test_a_pump_that_never_ends_times_out(self): + self.io.replies = [IDLE, IDLE, BACKFLUSH_REPLY, BUSY] + with self.assertRaisesRegex(TimeoutError, "still runs"): + await self.pumps.backflush(1, volume=500, timeout=0) + + async def test_the_door_abort_raises_and_the_link_stays_in_step(self): + # The door shuts after the check: the reader's unasked warning is read as the first poll's + # reply, and the poll's own reply must not be taken as the answer to the next command. + self.use_chunk(24) # reads end at the command reply's last byte: the warning is read whole + self.io.replies = [ + IDLE_DOOR_OPEN, # door check + IDLE_DOOR_OPEN, # idle before the command + PRIME_DOOR_SHUT_REPLY + PRIME_DOOR_SHUT_WARNING, + IDLE, # the first poll's own reply, read late and discarded + IDLE_DOOR_OPEN, # the next status, asked after the abort + ] + with self.assertRaises(PumpAbortedError) as caught: + await self.pumps.prime(1, volume=500) + self.assertEqual(caught.exception.quit_codes, (243, 12, 0, 0, 0)) + self.assertIn("reagent door", str(caught.exception)) + self.assertTrue((await self.driver.request_status()).reagent_door_open) + + async def test_a_lost_door_warning_is_caught_by_the_door_bit(self): + # Constructed: a warning sent between exchanges. Reads of 25 bytes take its first byte with + # the command reply; the first poll then + # reads a broken frame and is retried after a purge, which drops the warning. + self.use_chunk(25) + self.io.replies = [ + IDLE_DOOR_OPEN, # door check + IDLE_DOOR_OPEN, # idle before the command + PRIME_DOOR_SHUT_REPLY + PRIME_DOOR_SHUT_WARNING, + IDLE, # the first poll's reply, purged with the warning's rest + IDLE, # the retried poll: idle, door shut + IDLE_DOOR_OPEN, # the next status, asked after the abort + ] + with self.assertRaises(PumpAbortedError) as caught: + await self.pumps.prime(1, volume=500) + self.assertIsNone(caught.exception.quit_codes) + self.assertIn("reagent door closed during the prime", str(caught.exception)) + self.assertTrue((await self.driver.request_status()).reagent_door_open) + + async def test_a_backflush_does_not_watch_the_door(self): + self.io.replies = [IDLE, IDLE, BACKFLUSH_REPLY, BUSY, IDLE] + await self.pumps.backflush(1, volume=500) + self.assertEqual(len(self.io.written), 5) + + async def test_a_malformed_command_reply_is_not_resent(self): + # As for INIT: the reader may already be pumping, so the driver polls status instead of + # sending the command again. + self.io.replies = [IDLE, IDLE, BACKFLUSH_REPLY[:10], BUSY, IDLE] + with self.assertLogs(PUMPS_LOGGER, level="WARNING"): + await self.pumps.backflush(1, volume=500) + self.assertEqual(self.io.written.count(BACKFLUSH_REFERENCE), 1) + + async def test_a_warning_in_the_command_reply_aborts(self): + self.io.replies = [IDLE_DOOR_OPEN, IDLE_DOOR_OPEN, PRIME_DOOR_SHUT_WARNING] + with self.assertRaises(PumpAbortedError) as caught: + await self.pumps.prime(1, volume=500) + self.assertEqual(caught.exception.quit_codes, (243, 12, 0, 0, 0)) + + async def test_a_warning_present_before_the_command_is_not_taken_for_new(self): + self.io.replies = [STALE_IDLE, STALE_IDLE, BACKFLUSH_REPLY, STALE_BUSY, STALE_IDLE] + await self.pumps.backflush(1, volume=500) + self.assertEqual(len(self.io.written), 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/bmg_labtech/clariostar/driver/master.py b/pylabrobot/bmg_labtech/clariostar/driver/master.py index 9c5bac1d207..7157abef6f7 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/master.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/master.py @@ -46,6 +46,7 @@ from .features.drawer import Drawer from .features.fluorescence import Fluorescence from .features.luminescence import Luminescence +from .features.pumps import Pumps from .features.temperature_controller import TemperatureController from .framing import FTDI_HEADER, USB_HOST_ACK from .parameter_table import ( @@ -71,6 +72,9 @@ class CLARIOstarStatus: plate_detected: bool drawer_open: bool motors_enabled: bool + # Byte 3 bit 6, checked on firmware 1.20: set with the reagent door open, clear with it latched + # shut. + reagent_door_open: bool temperature_bottom: Optional[float] temperature_top: Optional[float] @@ -113,6 +117,7 @@ class CommandFamily: """Command family: byte 0 of every logical payload.""" INITIALIZE = 0x01 + PUMP = 0x02 DRAWER = 0x03 RUN = 0x04 REQUEST = 0x05 @@ -149,6 +154,7 @@ class Command: _NO_COMMAND_FAMILIES = frozenset( { CommandFamily.RUN, + CommandFamily.PUMP, CommandFamily.STOP, CommandFamily.STATUS, CommandFamily.TEMPERATURE_CONTROLLER, @@ -167,6 +173,7 @@ class Command: ("plate_detected", 3, 1 << 1), ("drawer_open", 3, 1 << 0), ("motors_enabled", 3, 1 << 5), + ("reagent_door_open", 3, 1 << 6), ] @property @@ -244,8 +251,8 @@ def __init__( # One lock over the link: a reply belongs to the last command written, so exchanges never # overlap. self._lock = asyncio.Lock() - # Whether a measurement is running; the reader runs only one at a time - # (`_enforce_one_measurement_at_a_time`). + # Whether a measurement or pump command is running; the reader runs only one at a time + # (`_enforce_one_measurement_at_a_time`, `Pumps._run`). self._measuring = False self._last_warning: Optional[Tuple[int, int, int, int, int]] = None # Built on first use over the current io (`_ftdi`). @@ -257,6 +264,8 @@ def __init__( self.absorbance: Optional[Absorbance] = None self.fluorescence: Optional[Fluorescence] = None self.luminescence: Optional[Luminescence] = None + # Built by `discover` only if the reader reports a pump. + self.pumps: Optional[Pumps] = None # -- FTDI interface -------------------------------------------------------- @@ -709,6 +718,8 @@ def _discover_features(self) -> None: self.fluorescence = Fluorescence(self) if self.configuration.luminescence_channels and self.luminescence is None: self.luminescence = Luminescence(self) + if any(self.configuration.pumps or ()) and self.pumps is None: + self.pumps = Pumps(self) def _check_firmware_compatibility(self) -> None: """Log a warning if the firmware version has not been checked on hardware.""" diff --git a/pylabrobot/bmg_labtech/clariostar/driver/recordings/README.md b/pylabrobot/bmg_labtech/clariostar/driver/recordings/README.md index 5a1494132df..6f8d6e3f672 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/recordings/README.md +++ b/pylabrobot/bmg_labtech/clariostar/driver/recordings/README.md @@ -19,6 +19,7 @@ of the same build. | file | reader | |---|---| | `clariostar_plus_1_35_temperature_controller.json` | CLARIOstar Plus, firmware 1.35. Decoded from the INIT and stored-parameters replies of one reader; the default for a simulated `CLARIOstarPlus`. | +| `clariostar_1_20_temperature_controller_pumps_2.json` | CLARIOstar (original, main board revision 1), firmware 1.20 compiled 2017-04-27, two XCalibur injector pumps. Decoded from the INIT and stored-parameters replies of one reader. | ## What is not in one diff --git a/pylabrobot/bmg_labtech/clariostar/driver/recordings/clariostar_1_20_temperature_controller_pumps_2.json b/pylabrobot/bmg_labtech/clariostar/driver/recordings/clariostar_1_20_temperature_controller_pumps_2.json new file mode 100644 index 00000000000..8d50720f628 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/driver/recordings/clariostar_1_20_temperature_controller_pumps_2.json @@ -0,0 +1,86 @@ +{ + "device": { + "model": "CLARIOstar", + "serial_number": "000-0000", + "mainboard_revision": 1, + "firmware_version": "1.20", + "firmware_build": 1, + "firmware_build_date": null, + "extended_red_photomultiplier_tube": false, + "dual_photomultiplier_tubes": false, + "photomultiplier_tube_crossover": false, + "excitation_slide_positions": 3, + "spectrometer": true, + "monochromator_slides": { + "EX_SP": { + "1": "open", + "2": "Filter 1", + "3": "Filter 2" + }, + "EX_LP": { + "1": "open", + "2": "Filter 1", + "3": "Filter 2" + }, + "BSP": { + "1": "open", + "2": "Filter 1", + "3": "Filter 2", + "4": "Filter 3" + }, + "EM_SP": { + "1": "open", + "2": "Filter 1", + "3": "Filter 2" + }, + "EM_LP": { + "1": "open", + "2": "Filter 1", + "3": "Filter 2" + }, + "EM_SLIT": { + "1": "open", + "2": "Slit 0.8", + "3": "Slit 1.2", + "4": "Slit 2.0", + "5": "Slit 3.0", + "7": "FP par", + "8": "FP per", + "6": "LUM lense" + }, + "EX_SLIT": { + "1": "open", + "2": "Slit 0.6", + "3": "Slit 1.0", + "4": "Slit 1.6", + "5": "Slit 2.3", + "8": "FP Stdrd", + "7": "FP UV", + "9": "ABS", + "6": "F-Check" + } + }, + "linear_variable_filters": true, + "temperature_controller": true, + "temperature_controller_channels": 2, + "pumps": [ + "XCalibur", + "XCalibur", + null + ], + "stacker_connected": false, + "barcode_readers": {}, + "atmospheric_control_unit_connected": false, + "fluorescence_channels": 1, + "luminescence_channels": 1, + "time_resolved_fluorescence_channels": 0, + "absorbance_available": true, + "alpha_screen_available": false, + "fluorescence_polarization_available": false, + "fluorescence_polarization_ultraviolet_available": false, + "bottom_measurement_available": true, + "extended_temperature_range": false, + "high_speed_shaking_allowed": false, + "well_1536_plates_supported": false + } +} diff --git a/pylabrobot/bmg_labtech/clariostar/driver/simulator.py b/pylabrobot/bmg_labtech/clariostar/driver/simulator.py index 0a8a6289209..27f66575590 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/simulator.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/simulator.py @@ -54,6 +54,7 @@ _get_measured_identifiers, _get_readings_by_well, ) +from .features.pumps import Pumps from .features.temperature_controller import TemperatureController from .master import CLARIOstarDriver, CLARIOstarStatus @@ -135,6 +136,38 @@ async def _unchecked_fw_close(self) -> None: self._update_location(False) +class CLARIOstarSimulationPumps(Pumps): + """The pumps of a simulated reader: a prime or backflush checks its settings and is done at once.""" + + async def prime( + self, + pump: int, + volume: float, + flow_rate: float = 100, + timeout: Optional[float] = None, + start_timeout: Optional[float] = None, + ) -> None: + """Check the settings as a reader's prime does, and return without pumping.""" + self._encode(pump, volume, flow_rate, backflush=False) + logger.debug( + "%s write: prime pump %d, %g uL at %g uL/s", SIMULATED_LINK, pump, volume, flow_rate + ) + + async def backflush( + self, + pump: int, + volume: float, + flow_rate: float = 100, + timeout: Optional[float] = None, + start_timeout: Optional[float] = None, + ) -> None: + """Check the settings as a reader's backflush does, and return without pumping.""" + self._encode(pump, volume, flow_rate, backflush=True) + logger.debug( + "%s write: backflush pump %d, %g uL at %g uL/s", SIMULATED_LINK, pump, volume, flow_rate + ) + + class CLARIOstarSimulationTemperatureController(TemperatureController): """The temperature controller of a simulated reader: it reaches its target at once. @@ -721,6 +754,7 @@ async def request_status(self, retries: int = 5) -> CLARIOstarStatus: plate_detected=self._simulation_drawer._get_plate_present(), drawer_open=self._simulation_drawer._get_open(), motors_enabled=self.initialized, + reagent_door_open=True, temperature_bottom=temperature, temperature_top=temperature, ) @@ -811,6 +845,8 @@ def _discover_features(self) -> None: self.fluorescence = CLARIOstarSimulationFluorescence(self) if self.configuration.luminescence_channels and self.luminescence is None: self.luminescence = CLARIOstarSimulationLuminescence(self) + if any(self.configuration.pumps or ()) and self.pumps is None: + self.pumps = CLARIOstarSimulationPumps(self) def _describe_link(self) -> str: """The simulated link, and the configuration it answers from.""" diff --git a/pylabrobot/bmg_labtech/clariostar/driver/simulator_tests.py b/pylabrobot/bmg_labtech/clariostar/driver/simulator_tests.py index 782ce047cb9..1667dc9c7ac 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver/simulator_tests.py +++ b/pylabrobot/bmg_labtech/clariostar/driver/simulator_tests.py @@ -17,6 +17,10 @@ read_configuration, ) from pylabrobot.bmg_labtech.clariostar.driver.configuration_tests import decoded +from pylabrobot.bmg_labtech.clariostar.driver.simulator import ( + CLARIOstarSimulationDriver, + CLARIOstarSimulationPumps, +) from pylabrobot.bmg_labtech.clariostar.driver.master_tests import ( PARAMETERS_REPLY, STATUS_IDLE, @@ -248,5 +252,30 @@ def test_simulation_takes_no_link(self): CLARIOstarPlus(simulation=True, io=FakeFTDI()) +class SimulatorPumpsTests(unittest.IsolatedAsyncioTestCase): + async def test_a_simulated_reader_with_pumps_checks_settings_and_pumps_at_once(self): + recording = os.path.join( + os.path.dirname(__file__), "recordings", "clariostar_1_20_temperature_controller_pumps_2.json" + ) + driver = CLARIOstarSimulationDriver(declared_configuration_json=recording) + await driver.discover() + self.assertIsInstance(driver.pumps, CLARIOstarSimulationPumps) + assert driver.pumps is not None + self.assertTrue((await driver.request_status()).reagent_door_open) + await driver.pumps.prime(1, volume=500) + await driver.pumps.backflush(2, volume=1000, flow_rate=430) + with self.assertRaises(ValueError): + await driver.pumps.prime(1, volume=500, flow_rate=50) + + async def test_the_device_exposes_the_pumps(self): + recording = os.path.join( + os.path.dirname(__file__), "recordings", "clariostar_1_20_temperature_controller_pumps_2.json" + ) + reader = CLARIOstar(simulation=True, declared_configuration_json=recording) + await reader.driver.discover() + self.assertIsNotNone(reader.pumps) + self.assertIs(reader.pumps, reader.driver.pumps) + + if __name__ == "__main__": unittest.main()