Skip to content

Commit bbd3a83

Browse files
BioCamclaude
authored andcommitted
STAR pipettes: where each channel's piston stands, in uL
Nothing could read a channel's dispensing drive, so where its piston stood was unknown. dispensing_drive_request_uL_position reads one channel's, `Px RD`, in uL; dispensing_drives_request_uL_positions reads every channel's, by channel. The simulator answers from where it has each piston, at rest until something moves it. The reads record what they read on piston_positions, one entry per channel; the all-channel read takes the channels to ask, reads them together and answers None for the rest. Initialization homes the pistons, so it ends with the first read. The setup-sequence test no longer pins where the autoload, its own unit, comes up among the arm's features. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d55672e commit bbd3a83

4 files changed

Lines changed: 81 additions & 9 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ def __init__(self, driver: "STARDriver", configuration: Optional[PipettesConfigu
347347
# One resource per channel, in channel order, when the driver was given a deck. Setup puts them
348348
# on the arm; the reads keep them in step. Without a deck the list stays empty.
349349
self.resources: List[Resource] = []
350+
# Where each piston stands, in uL, by channel, as last read; empty until a piston is read.
351+
self.piston_positions: List[float] = []
350352
self.configuration = configuration or PipettesConfiguration()
351353
# The height the channels travel at when a command names none, in mm. Legacy STARBackend's
352354
# channel traversal height.
@@ -809,6 +811,8 @@ async def initialize(
809811
# `z_position_at_end_of_a_command`. Read both back, or the model has them where they were.
810812
await self._record_where_they_stopped("y")
811813
await self._record_where_they_stopped("z")
814+
# Initialization homes the pistons as well: the first read of where they stand comes here.
815+
await self.dispensing_drives_request_uL_positions()
812816
return resp
813817

814818
def _min_pair_spacing(self, i: int, j: int) -> float:
@@ -1759,6 +1763,46 @@ async def move_to_safe_z(
17591763
async with self._temporary_z_drive_profile(speed=speed, acceleration=acceleration):
17601764
await self.probe_z_max()
17611765

1766+
# -- dispensing drive position -------------------------------------------------------------------
1767+
1768+
async def dispensing_drive_request_uL_position(self, channel: int) -> float:
1769+
"""Read where one channel's dispensing drive stands, in uL, and record it. `Px RD`.
1770+
1771+
Args:
1772+
channel: which channel, 0-indexed from the back.
1773+
1774+
Returns:
1775+
The piston's position in uL, 0.0 at rest; air and liquid alike. Kept on `piston_positions`.
1776+
"""
1777+
self._require_channel(channel)
1778+
resp = await self._driver.send_command(
1779+
module=self.channel_id(channel), command="RD", fmt="rd#####"
1780+
)
1781+
uL = self.configuration.dispensing_drive_increments_to_uL(cast(int, resp["rd"]))
1782+
# The channels not read yet stand at rest, where initialization leaves every piston.
1783+
self.piston_positions += [0.0] * (self.num_channels - len(self.piston_positions))
1784+
self.piston_positions[channel] = uL
1785+
return uL
1786+
1787+
async def dispensing_drives_request_uL_positions(
1788+
self, channels: Optional[List[int]] = None
1789+
) -> List[Optional[float]]:
1790+
"""Read where the dispensing drives stand, in uL, the channels together, and record them.
1791+
1792+
Args:
1793+
channels: which channels, 0-indexed from the back. Every channel when None.
1794+
1795+
Returns:
1796+
One entry per channel of the device: the piston's position in uL, 0.0 at rest, air and
1797+
liquid alike, for a channel asked; None for one not asked, whose record is left alone.
1798+
"""
1799+
asked = list(range(self.num_channels)) if channels is None else channels
1800+
read = await asyncio.gather(*(self.dispensing_drive_request_uL_position(ch) for ch in asked))
1801+
positions: List[Optional[float]] = [None] * self.num_channels
1802+
for channel, uL in zip(asked, read):
1803+
positions[channel] = uL
1804+
return positions
1805+
17621806
# -- x and y together ----------------------------------------------------------------------------
17631807

17641808
async def _traverse_raise_targets(self, height: float) -> Dict[int, float]:

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,12 @@ def _stand_at_y(self, *ys: List[float]):
566566
self.pipettes.request_y_positions = reads # type: ignore[method-assign]
567567
self.pipettes.move_to_y_position = self.moves # type: ignore[method-assign]
568568

569+
async def test_dispensing_drive_read(self):
570+
reads = unittest.mock.AsyncMock(return_value={"rd": 1067})
571+
self.pipettes._driver.send_command = reads # type: ignore[method-assign]
572+
self.assertEqual(await self.pipettes.dispensing_drive_request_uL_position(2), 50.0)
573+
reads.assert_awaited_once_with(module="P3", command="RD", fmt="rd#####")
574+
569575
async def test_x_firmware(self):
570576
await self.pipettes._unchecked_fw_probe_x_using_clld(134.0)
571577
self.assertEqual(self.sent, ["C0XLxs01340"])
@@ -1070,6 +1076,22 @@ async def asyncSetUp(self):
10701076
async def asyncTearDown(self):
10711077
await self.driver.stop()
10721078

1079+
async def test_the_pistons_stand_where_the_simulator_has_them(self):
1080+
# Initialization read them once already.
1081+
self.assertEqual(self.pipettes.piston_positions, [0.0] * 8)
1082+
self.assertEqual(await self.pipettes.dispensing_drives_request_uL_positions(), [0.0] * 8)
1083+
self.driver.dispensing_drive_uL[0] = 50.0
1084+
self.driver.dispensing_drive_uL[1] = 20.0
1085+
self.assertEqual(await self.pipettes.dispensing_drive_request_uL_position(0), 50.0)
1086+
self.assertEqual(self.pipettes.piston_positions[0], 50.0)
1087+
# Asking for some channels answers None for the others and leaves their record alone.
1088+
self.pipettes.piston_positions[0] = 99.0
1089+
self.assertEqual(
1090+
await self.pipettes.dispensing_drives_request_uL_positions([1, 3]),
1091+
[None, 20.0, None, 0.0, None, None, None, None],
1092+
)
1093+
self.assertEqual(self.pipettes.piston_positions[:2], [99.0, 20.0])
1094+
10731095
def _surface(self, well) -> float:
10741096
"""Where the tracker's water stands in `well`, in mm on the deck, by the well's own model."""
10751097
volume = well.tracker.get_used_volume()

‎pylabrobot/hamilton/star/driver/master_tests.py‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,13 @@ async def run_setup(
319319
)
320320
with recorded_moves() as moves:
321321
await star.setup()
322-
return list(moves)
322+
# The autoload is its own unit and comes up alongside the arm's features, so where its two
323+
# steps fall among theirs is the event loop's business; that it homes after the device is not.
324+
autoload = [move for move in moves if "autoload" in move]
325+
self.assertEqual(autoload, ["II autoload", "autoload park"])
326+
if "VI device" in moves:
327+
self.assertGreater(moves.index("II autoload"), moves.index("VI device"))
328+
return [move for move in moves if "autoload" not in move]
323329

324330
async def test_everything_already_up(self):
325331
self.assertEqual(
@@ -330,8 +336,6 @@ async def test_everything_already_up(self):
330336
"ZA channels to safe Z",
331337
"iSWAP park",
332338
"EV 96-head probe and retract",
333-
"II autoload",
334-
"autoload park",
335339
],
336340
)
337341

@@ -345,8 +349,6 @@ async def test_head_down_on_a_device_that_is_up(self):
345349
"iSWAP park",
346350
"EI 96-head",
347351
"EV 96-head probe and retract",
348-
"II autoload",
349-
"autoload park",
350352
],
351353
)
352354

@@ -362,8 +364,6 @@ async def test_head_down_with_nowhere_to_eject(self):
362364
"ZA channels to safe Z",
363365
"iSWAP park",
364366
"EV 96-head probe and retract",
365-
"II autoload",
366-
"autoload park",
367367
],
368368
)
369369

@@ -424,8 +424,6 @@ async def test_device_not_up(self):
424424
"iSWAP park",
425425
"EI 96-head",
426426
"EV 96-head probe and retract",
427-
"II autoload",
428-
"autoload park",
429427
],
430428
)
431429

‎pylabrobot/hamilton/star/driver/simulator.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,12 @@ async def answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tup
444444
{"rz": c.z_drive_mm_to_increments(self._modelled_z(channel))},
445445
f"where the model has channel {channel}'s stop disc",
446446
)
447+
if command == "RD":
448+
uL = self.device.dispensing_drive_uL.get(channel, 0.0)
449+
return (
450+
{"rd": c.dispensing_drive_uL_to_increments(uL)},
451+
f"where channel {channel}'s piston stands",
452+
)
447453
if command in ("ZL", "ZE"):
448454
return self._answer_liquid_search(channel, command, **kwargs)
449455
if command == "ZH":
@@ -1407,6 +1413,8 @@ def __init__(
14071413
self.liquid_searches: Dict[int, Container] = {}
14081414
# What each channel last detected liquid at, in mm on the deck; 0.0 until a search finds any.
14091415
self.last_lld_heights: Dict[int, float] = {}
1416+
# Where each channel's piston stands, in uL.
1417+
self.dispensing_drive_uL: Dict[int, float] = {}
14101418

14111419
# What each module says when asked whether it is initialized, and where things are.
14121420
self.initialized = {module: initialized for module in ("C0", "I0", "R0", "H0")}

0 commit comments

Comments
 (0)