Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions pylabrobot/bmg_labtech/clariostar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
Luminescence,
LuminescenceConfiguration,
MeasurementSettings,
PumpAbortedError,
Pumps,
ShakeSettings,
TemperatureController,
)
6 changes: 6 additions & 0 deletions pylabrobot/bmg_labtech/clariostar/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion pylabrobot/bmg_labtech/clariostar/driver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +18,7 @@
Luminescence,
LuminescenceConfiguration,
MeasurementSettings,
Pumps,
ShakeSettings,
TemperatureController,
)
Expand Down
6 changes: 4 additions & 2 deletions pylabrobot/bmg_labtech/clariostar/driver/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
91 changes: 89 additions & 2 deletions pylabrobot/bmg_labtech/clariostar/driver/configuration_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
32 changes: 31 additions & 1 deletion pylabrobot/bmg_labtech/clariostar/driver/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
)
Expand All @@ -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}")
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@
LuminescenceConfiguration,
)
from .measurement import MeasurementSettings, ShakeSettings
from .pumps import Pumps
from .temperature_controller import TemperatureController
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading