diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py index 2a426177ba0..59d37fccec7 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -105,6 +105,9 @@ def __init__( self._num_channels = num_channels self._iswap_parked = True self._sim_iswap_information = iswap_information # None means use default at setup + # Absolute heights latched by simulated LLD, one per channel. See + # `request_pip_height_last_lld`; `_run_lld_on_channel_batch` writes them. + self._last_lld_absolute_heights = [0.0] * num_channels if core96_head_installed is not None or iswap_installed is not None: extended_configuration = copy.deepcopy(extended_configuration) @@ -484,8 +487,18 @@ async def request_tip_len_on_channel(self, channel_idx: int) -> float: async def position_channels_in_y_direction(self, ys, make_space=True): logger.info("positioning channels in y: %s make_space: %s", ys, make_space) - async def request_pip_height_last_lld(self): - return list(range(12)) + async def request_pip_height_last_lld(self) -> List[float]: + """Return the absolute heights latched by simulated liquid-level detection (LLD). + + `_run_lld_on_channel_batch` records each simulated measurement by physical channel. + Values remain latched until another simulated LLD updates that channel. Channels with + no recorded simulated LLD report the initial value of 0.0. + + Returns: + Simulated absolute liquid heights (mm) from the last LLD event for each channel, + ordered by channel index. + """ + return list(self._last_lld_absolute_heights) async def _run_lld_on_channel_batch( self, @@ -503,9 +516,13 @@ async def _run_lld_on_channel_batch( Empty containers report the cavity-bottom Z (relative height 0). Non-empty containers report ``cavity_bottom + compute_height_from_volume(volume)`` so the parent ``probe_liquid_heights`` can subtract ``z_cavity_bottom`` consistently. + + Each reading is also latched per physical channel for ``request_pip_height_last_lld``. """ measurements: Dict[int, List[Optional[float]]] = {} - for orig_idx in batch.indices: + # ``indices`` are job indices and ``channels`` the physical channels running them; they are + # parallel, so a job's reading is latched at its channel, not at its position in the batch. + for orig_idx, channel_idx in zip(batch.indices, batch.channels): container = containers[orig_idx] volume = container.tracker.get_used_volume() if volume == 0: @@ -513,4 +530,5 @@ async def _run_lld_on_channel_batch( else: absolute_height = z_cavity_bottom[orig_idx] + container.compute_height_from_volume(volume) measurements[orig_idx] = [absolute_height] * n_replicates + self._last_lld_absolute_heights[channel_idx] = absolute_height return measurements diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py index 4ab384fa381..d2f822bcd4d 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py @@ -2597,6 +2597,68 @@ async def test_duplicate_channels_serialize_measurements(self): self.assertAlmostEqual(result[1], 0 - well_b.get_absolute_location("c", "c", "cavity_bottom").z) +class TestChatterboxLastLLDHeights(unittest.IsolatedAsyncioTestCase): + """The chatterbox's last-LLD query reports the heights its simulated sensing produced. + + `STARChatterboxBackend` simulates sensing in `_run_lld_on_channel_batch`, computing absolute + heights from each container's volume tracker. `request_pip_height_last_lld` must report those + same heights, under the base method's contract: one absolute height (mm) per channel, indexed + by physical channel index. + """ + + PROBE_VOLUME = 150.0 # uL + + async def asyncSetUp(self): + self.backend = STARChatterboxBackend() + self.deck = STARLetDeck() + self.lh = LiquidHandler(self.backend, deck=self.deck) + + self.tip_car = TIP_CAR_480_A00(name="tip carrier") + self.tip_car[1] = self.tip_rack = hamilton_96_tiprack_300uL_filter(name="tip_rack_01") + self.deck.assign_child_resource(self.tip_car, rails=1) + + self.plt_car = PLT_CAR_L5AC_A00(name="plate carrier") + self.plt_car[0] = self.plate = cor_96_wellplate_360uL_Fb(name="plate_01") + self.deck.assign_child_resource(self.plt_car, rails=9) + + await self.lh.setup() + + async def asyncTearDown(self): + await self.lh.stop() + + async def test_reports_no_measurement_before_any_lld(self): + heights = await self.backend.request_pip_height_last_lld() + + self.assertEqual(len(heights), self.backend.num_channels) + self.assertTrue(all(isinstance(h, float) for h in heights)) + self.assertEqual(heights, [0.0] * self.backend.num_channels) + + async def test_latches_simulated_height_at_physical_channel(self): + # Channel 3, not 0: `_run_lld_on_channel_batch` keys its result by job index + # (`batch.indices`), but the query is indexed by physical channel (`batch.channels`). + # With a single job on channel 0 the two coincide and the distinction goes untested. + channel = 3 + well = self.plate.get_item("D1") + await self.lh.pick_up_tips(self.tip_rack["D1"], use_channels=[channel]) + well.tracker.set_volume(self.PROBE_VOLUME) + + await self.backend.probe_liquid_heights(containers=[well], use_channels=[channel]) + + expected = well.get_absolute_location( + "c", "c", "cavity_bottom" + ).z + well.compute_height_from_volume(self.PROBE_VOLUME) + heights = await self.backend.request_pip_height_last_lld() + + self.assertEqual(len(heights), self.backend.num_channels) + self.assertAlmostEqual(heights[channel], expected) + # Channels that did not probe still report no measurement. Index 0 in particular is where + # the measurement would land if job indices were mistaken for physical channel indices. + self.assertEqual( + [h for i, h in enumerate(heights) if i != channel], + [0.0] * (self.backend.num_channels - 1), + ) + + class TestXArmGeometry(unittest.IsolatedAsyncioTestCase): """setup() resolves QM/RU/UA firmware onto the X-drive DriveConfigurations."""