|
6 | 6 | import enum |
7 | 7 | import logging |
8 | 8 | import math |
| 9 | +import re |
9 | 10 | from contextlib import asynccontextmanager |
10 | 11 | from dataclasses import dataclass, field |
11 | 12 | from typing import ( |
@@ -299,6 +300,23 @@ def resolve_channels(self, num_channels: int) -> None: |
299 | 300 | raise ValueError(f"configuration has {len(self.channels)} channels, expected {num_channels}") |
300 | 301 |
|
301 | 302 |
|
| 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 | + |
302 | 320 | class Pipettes: |
303 | 321 | """The pipetting channels. |
304 | 322 |
|
@@ -4348,3 +4366,152 @@ async def auto_adjust_pressure_sensor(self, channel: int) -> None: |
4348 | 4366 | """ |
4349 | 4367 | self._require_channel(channel) |
4350 | 4368 | 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) |
0 commit comments