Skip to content

Commit be42e00

Browse files
BioCamclaude
andcommitted
STAR pipettes: TADM - record a channel's pressure curve and read it back
There was no way to monitor the pressure in a channel while it aspirates or dispenses (TADM): no command put a channel into TADM mode, started a recording, or read a recorded curve back. set_tadm_mode and request_tadm_mode switch and query the mode (Px AF, QF); clear_tadm_fifo (AN) empties the curve FIFO and reset_tadm_limit_curves (AQ) loads the default limit curve. start_tadm_monitoring (BG) records every stroke until stop_tadm_monitoring (BH), refusing a limit curve outside 0-999, a label not 4 characters, or errors_only storage without enforcement. read_tadm_curve advances the FIFO (QM), reads the curve's header (QL) and pages its pressures up to 50 at a time (QN) into a TADMCurve; None when the FIFO is empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b7f2517 commit be42e00

2 files changed

Lines changed: 241 additions & 1 deletion

File tree

‎pylabrobot/hamilton/star/driver/features/pipettes.py‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import enum
77
import logging
88
import math
9+
import re
910
from contextlib import asynccontextmanager
1011
from dataclasses import dataclass, field
1112
from typing import (
@@ -299,6 +300,23 @@ def resolve_channels(self, num_channels: int) -> None:
299300
raise ValueError(f"configuration has {len(self.channels)} channels, expected {num_channels}")
300301

301302

303+
@dataclass(frozen=True)
304+
class TADMCurve:
305+
"""One recorded TADM pressure curve, read back from a channel's FIFO.
306+
307+
Attributes:
308+
measurement_id: the 4-character label stamped on the recording (`nr`).
309+
operation: which stroke recorded it, from the liquid-handling-type field.
310+
had_error: whether the firmware flagged a TADM error on it.
311+
pressures: signed pressures in Pa, in time order.
312+
"""
313+
314+
measurement_id: str
315+
operation: Literal["aspirate", "dispense", "other"]
316+
had_error: bool
317+
pressures: List[int]
318+
319+
302320
class Pipettes:
303321
"""The pipetting channels.
304322
@@ -4348,3 +4366,152 @@ async def auto_adjust_pressure_sensor(self, channel: int) -> None:
43484366
"""
43494367
self._require_channel(channel)
43504368
await self._driver.send_command(module=self.channel_id(channel), command="AC")
4369+
4370+
# -- total aspiration and dispense monitoring (TADM) ---------------------------------------------
4371+
4372+
# Index is the `gk` wire value.
4373+
_TADM_STORAGE_LEVELS = ("none", "errors_only", "all")
4374+
4375+
async def set_tadm_mode(self, channel: int, enabled: bool = True) -> None:
4376+
"""Switch a channel between TADM mode and pressure/capacitive LLD mode. `Px AF`.
4377+
4378+
A curve is recorded only in TADM mode, and the FIFO is readable only while the mode stays on.
4379+
4380+
Args:
4381+
channel: which channel, 0-indexed from the back.
4382+
enabled: True for TADM mode, False for LLD mode.
4383+
"""
4384+
self._require_channel(channel)
4385+
await self._driver.send_command(
4386+
module=self.channel_id(channel), command="AF", af="1" if enabled else "0"
4387+
)
4388+
4389+
async def request_tadm_mode(self, channel: int) -> bool:
4390+
"""Whether a channel is in TADM mode. `Px QF`.
4391+
4392+
Args:
4393+
channel: which channel, 0-indexed from the back.
4394+
"""
4395+
self._require_channel(channel)
4396+
resp = await self._driver.send_command(module=self.channel_id(channel), command="QF")
4397+
return "qf1" in resp
4398+
4399+
async def clear_tadm_fifo(self, channel: int) -> None:
4400+
"""Empty a channel's TADM curve FIFO. `Px AN`.
4401+
4402+
Args:
4403+
channel: which channel, 0-indexed from the back.
4404+
"""
4405+
self._require_channel(channel)
4406+
await self._driver.send_command(module=self.channel_id(channel), command="AN")
4407+
4408+
async def reset_tadm_limit_curves(self, channel: int) -> None:
4409+
"""Erase a channel's limit-curve bank and load the default curve at index 0. `Px AQ`.
4410+
4411+
Enforcing a limit curve fails with an invalid-index error until the default is loaded.
4412+
4413+
Args:
4414+
channel: which channel, 0-indexed from the back.
4415+
"""
4416+
self._require_channel(channel)
4417+
await self._driver.send_command(module=self.channel_id(channel), command="AQ")
4418+
4419+
async def start_tadm_monitoring(
4420+
self,
4421+
channel: int,
4422+
*,
4423+
enforce_limit_curve_control: bool = True,
4424+
storage_level: Literal["none", "errors_only", "all"] = "all",
4425+
limit_curve_index: int = 0,
4426+
measurement_id: Optional[str] = None,
4427+
) -> None:
4428+
"""Record every aspirate and dispense on a channel until `stop_tadm_monitoring`. `Px BG`.
4429+
4430+
Needs TADM mode on and a limit curve loaded. With enforcement off, nothing is recorded.
4431+
4432+
Args:
4433+
channel: which channel, 0-indexed from the back.
4434+
enforce_limit_curve_control: abort the plunger when pressure leaves the limit curve (`gj`).
4435+
storage_level: which curves the FIFO keeps (`gk`).
4436+
limit_curve_index: the limit curve enforced against, 0 to 999 (`gi`).
4437+
measurement_id: 4-character label stamped on each curve (`nr`). Omitted when None.
4438+
"""
4439+
self._require_channel(channel)
4440+
if storage_level not in self._TADM_STORAGE_LEVELS:
4441+
raise ValueError(f"storage_level must be one of {self._TADM_STORAGE_LEVELS}")
4442+
if not 0 <= limit_curve_index <= 999:
4443+
raise ValueError(f"limit_curve_index must be in [0, 999], is {limit_curve_index}")
4444+
if measurement_id is not None and len(measurement_id) != 4:
4445+
raise ValueError(f"measurement_id must be 4 characters, is {measurement_id!r}")
4446+
if storage_level == "errors_only" and not enforce_limit_curve_control:
4447+
raise ValueError('storage_level="errors_only" keeps nothing without enforcement')
4448+
fields: Dict[str, Any] = {
4449+
"gi": f"{limit_curve_index:03}",
4450+
"gj": "1" if enforce_limit_curve_control else "0",
4451+
"gk": str(self._TADM_STORAGE_LEVELS.index(storage_level)),
4452+
}
4453+
if measurement_id is not None:
4454+
fields["nr"] = measurement_id
4455+
await self._driver.send_command(module=self.channel_id(channel), command="BG", **fields)
4456+
4457+
async def stop_tadm_monitoring(self, channel: int) -> None:
4458+
"""End the monitoring `start_tadm_monitoring` began. `Px BH`.
4459+
4460+
Args:
4461+
channel: which channel, 0-indexed from the back.
4462+
"""
4463+
self._require_channel(channel)
4464+
await self._driver.send_command(module=self.channel_id(channel), command="BH")
4465+
4466+
async def _advance_tadm_fifo(self, channel: int) -> bool:
4467+
"""Move the FIFO pointer to the next stored curve; whether there is one. `Px QM`."""
4468+
resp = await self._driver.send_command(module=self.channel_id(channel), command="QM")
4469+
return "qm1" in resp
4470+
4471+
async def _request_tadm_curve_parameters(
4472+
self, channel: int
4473+
) -> Tuple[int, Literal["aspirate", "dispense", "other"], bool, str]:
4474+
"""Point count, operation, error flag and label of the curve at the pointer. `Px QL`."""
4475+
resp = await self._driver.send_command(module=self.channel_id(channel), command="QL")
4476+
match = re.search(r"ql([\d ]+?)nr(.*?)gd", resp)
4477+
if match is None:
4478+
raise ValueError(f"could not parse QL reply: {resp!r}")
4479+
fields = match.group(1).split()
4480+
operation: Literal["aspirate", "dispense", "other"] = (
4481+
"aspirate" if int(fields[1]) == 0 else "dispense" if int(fields[1]) == 1 else "other"
4482+
)
4483+
return int(fields[0]), operation, int(fields[2]) != 0, match.group(2).strip()
4484+
4485+
async def _request_tadm_curve_data(self, channel: int, start: int, count: int) -> List[int]:
4486+
"""`count` signed pressures in Pa from index `start` of the curve at the pointer. `Px QN`."""
4487+
resp = await self._driver.send_command(
4488+
module=self.channel_id(channel), command="QN", li=f"{start:04}", ln=f"{count:02}"
4489+
)
4490+
return [int(value) for value in resp.split("qn")[-1].split()]
4491+
4492+
async def read_tadm_curve(self, channel: int, points_per_read: int = 50) -> Optional[TADMCurve]:
4493+
"""Read the next recorded curve from a channel's TADM FIFO.
4494+
4495+
Args:
4496+
channel: which channel, 0-indexed from the back.
4497+
points_per_read: pressures per `QN` request, 1 to 50.
4498+
4499+
Returns:
4500+
The curve, or None when the FIFO is empty.
4501+
"""
4502+
self._require_channel(channel)
4503+
if not 1 <= points_per_read <= 50:
4504+
raise ValueError(f"points_per_read must be in [1, 50], is {points_per_read}")
4505+
if not await self._advance_tadm_fifo(channel):
4506+
return None
4507+
n_points, operation, had_error, measurement_id = await self._request_tadm_curve_parameters(
4508+
channel
4509+
)
4510+
pressures: List[int] = []
4511+
while len(pressures) < n_points:
4512+
count = min(points_per_read, n_points - len(pressures))
4513+
values = await self._request_tadm_curve_data(channel, len(pressures), count)
4514+
if not values: # an empty reply would otherwise loop forever
4515+
break
4516+
pressures.extend(values)
4517+
return TADMCurve(measurement_id, operation, had_error, pressures)

‎pylabrobot/hamilton/star/driver/features/pipettes_tests.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from pylabrobot.hamilton.protocol.text.framing import assemble_command
99
from pylabrobot.hamilton.star.device import RECORDING_STAR
1010
from pylabrobot.hamilton.star.driver.errors import STARFirmwareError, check_fw_string_error
11-
from pylabrobot.hamilton.star.driver.features.pipettes import Pipettes, PipettesConfiguration
11+
from pylabrobot.hamilton.star.driver.features.pipettes import (
12+
Pipettes,
13+
PipettesConfiguration,
14+
TADMCurve,
15+
)
1216
from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver
1317
from pylabrobot.lib.liquid_handling.pipette_batch_scheduling import plan_batches
1418
from pylabrobot.resources.coordinate import Coordinate
@@ -2551,3 +2555,72 @@ async def test_request_channel_pressure(self):
25512555
async def test_auto_adjust_pressure_sensor(self):
25522556
await self.pipettes.auto_adjust_pressure_sensor(7)
25532557
self.assertEqual(self.send.call_args.kwargs, {"module": "P8", "command": "AC"})
2558+
2559+
async def test_set_and_request_tadm_mode(self):
2560+
await self.pipettes.set_tadm_mode(7)
2561+
self.assertEqual(self.send.call_args.kwargs, {"module": "P8", "command": "AF", "af": "1"})
2562+
await self.pipettes.set_tadm_mode(7, enabled=False)
2563+
self.assertEqual(self.send.call_args.kwargs["af"], "0")
2564+
self.answer("P8QFid0001qf1", "P8QFid0002qf0")
2565+
self.assertTrue(await self.pipettes.request_tadm_mode(7))
2566+
self.assertFalse(await self.pipettes.request_tadm_mode(7))
2567+
2568+
async def test_fifo_and_limit_curves(self):
2569+
await self.pipettes.clear_tadm_fifo(7)
2570+
self.assertEqual(self.send.call_args.kwargs, {"module": "P8", "command": "AN"})
2571+
await self.pipettes.reset_tadm_limit_curves(7)
2572+
self.assertEqual(self.send.call_args.kwargs, {"module": "P8", "command": "AQ"})
2573+
2574+
async def test_monitoring_session(self):
2575+
await self.pipettes.start_tadm_monitoring(7)
2576+
self.assertEqual(
2577+
self.send.call_args.kwargs,
2578+
{"module": "P8", "command": "BG", "gi": "000", "gj": "1", "gk": "2"},
2579+
)
2580+
await self.pipettes.start_tadm_monitoring(
2581+
7, storage_level="errors_only", limit_curve_index=5, measurement_id="REF0"
2582+
)
2583+
self.assertEqual(
2584+
self.send.call_args.kwargs,
2585+
{"module": "P8", "command": "BG", "gi": "005", "gj": "1", "gk": "1", "nr": "REF0"},
2586+
)
2587+
await self.pipettes.stop_tadm_monitoring(7)
2588+
self.assertEqual(self.send.call_args.kwargs, {"module": "P8", "command": "BH"})
2589+
2590+
async def test_monitoring_rejects_what_the_firmware_cannot_do(self):
2591+
with self.assertRaises(ValueError):
2592+
await self.pipettes.start_tadm_monitoring(7, limit_curve_index=1000)
2593+
with self.assertRaises(ValueError):
2594+
await self.pipettes.start_tadm_monitoring(7, measurement_id="TOOLONG")
2595+
with self.assertRaises(ValueError):
2596+
await self.pipettes.start_tadm_monitoring(
2597+
7, enforce_limit_curve_control=False, storage_level="errors_only"
2598+
)
2599+
self.send.assert_not_called()
2600+
2601+
async def test_read_tadm_curve(self):
2602+
self.answer(
2603+
"P8QMid0001qm1",
2604+
"P8QLid0002qm1ql0003 0001 0000 0000 0000nrMHD0gd00000000-0000-0000-0000-000000000000",
2605+
"P8QNid0003qn-0439",
2606+
"P8QNid0004qn-0434",
2607+
"P8QNid0005qn+0012",
2608+
)
2609+
curve = await self.pipettes.read_tadm_curve(7, points_per_read=1)
2610+
self.assertEqual(curve, TADMCurve("MHD0", "dispense", False, [-439, -434, 12]))
2611+
2612+
async def test_read_tadm_curve_in_batches(self):
2613+
self.answer(
2614+
"P8QMid1qm1",
2615+
"P8QLid2qm1ql0005 0000 0000 0000 0000nrAAAAgd00000000-0000-0000-0000-000000000000",
2616+
"P8QNid3qn-0439 -0434 -0434",
2617+
"P8QNid4qn-0439 +0012",
2618+
)
2619+
curve = await self.pipettes.read_tadm_curve(7, points_per_read=3)
2620+
self.assertEqual(curve, TADMCurve("AAAA", "aspirate", False, [-439, -434, -434, -439, 12]))
2621+
qn = [call.kwargs for call in self.send.call_args_list if call.kwargs["command"] == "QN"]
2622+
self.assertEqual([(c["li"], c["ln"]) for c in qn], [("0000", "03"), ("0003", "02")])
2623+
2624+
async def test_read_tadm_curve_from_an_empty_fifo(self):
2625+
self.answer("P8QMid0001qm0")
2626+
self.assertIsNone(await self.pipettes.read_tadm_curve(7))

0 commit comments

Comments
 (0)