From b3bd5a7ff875221b337629b0aed91faaf44fdecc Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Tue, 6 Oct 2026 10:48:40 +0800 Subject: [PATCH] Preserve saved tip state during tracker restoration Compare committed and pending tips before attaching a tip to its holder. Resource placement must not turn a committed state into a pending transaction. Restore saved liquid volume through the committed tip volume tracker. Reuse the committed tip when both tip states match, while preserving pending pickup and removal transactions for commit or rollback. Keep resource-tree tip ownership and the saved state format unchanged. --- pylabrobot/legacy/tip_tracker.py | 9 +++- pylabrobot/legacy/tip_tracker_tests.py | 59 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/pylabrobot/legacy/tip_tracker.py b/pylabrobot/legacy/tip_tracker.py index e010d1b5429..f901abd6bad 100644 --- a/pylabrobot/legacy/tip_tracker.py +++ b/pylabrobot/legacy/tip_tracker.py @@ -199,15 +199,20 @@ def serialize(self) -> dict: } def load_state(self, state: dict) -> None: - """Load a saved tip tracker state.""" + """Load committed and pending tips, including the committed tip's liquid state.""" tip_data, pending_tip_data = state.get("tip"), state.get("pending_tip") tip = Tip.deserialize(tip_data) if tip_data is not None else None pending_tip = Tip.deserialize(pending_tip_data) if pending_tip_data is not None else None - self._put(pending_tip) same = (tip is None) == (pending_tip is None) and ( tip is None or tip.serialize() == cast(Tip, pending_tip).serialize() ) + tip_state = state.get("tip_state") + if tip is not None and tip_state is not None: + tip.tracker.load_state(tip_state) + if same: + pending_tip = tip + self._put(pending_tip) self._before = _NOTHING_PENDING if same else tip def get_tip_origin(self) -> Optional["TipSpot"]: diff --git a/pylabrobot/legacy/tip_tracker_tests.py b/pylabrobot/legacy/tip_tracker_tests.py index b79ac977b85..e23ad317a16 100644 --- a/pylabrobot/legacy/tip_tracker_tests.py +++ b/pylabrobot/legacy/tip_tracker_tests.py @@ -8,6 +8,7 @@ from pylabrobot.resources import Coordinate from pylabrobot.resources.hamilton import HamiltonTip, hamilton_tip_300uL from pylabrobot.resources.hamilton.tip_creators import TIP_DIAMETER, TipSize +from pylabrobot.resources.opentrons import opentrons_96_tiprack_300ul from pylabrobot.resources.tip import Tip @@ -76,6 +77,64 @@ def test_load_empty_state(self): with self.assertRaises(NoTipError): tracker.get_tip() + def test_load_committed_tip_spot_state_remains_committed(self): + """A restored occupied spot retains its committed tip and liquid volume.""" + spot = opentrons_96_tiprack_300ul("tips").get_item("A1") + spot.tracker.get_tip().tracker.set_volume(17) + state = spot.serialize_state() + + spot.load_state(state) + + self.assertEqual(spot.serialize_state(), state) + spot.tracker.rollback() + self.assertEqual(spot.serialize_state(), state) + + def test_load_pending_tip_spot_removal_preserves_rollback(self): + """Rollback restores a removed tip with its saved liquid volume.""" + spot = opentrons_96_tiprack_300ul("tips").get_item("A1") + spot.tracker.get_tip().tracker.set_volume(17) + committed = spot.serialize_state() + spot.tracker.remove_tip(commit=False) + pending = spot.serialize_state() + + spot.load_state(pending) + + self.assertFalse(spot.tracker.has_tip) + self.assertEqual(spot.tracker.get_tip().name, committed["tip"]["name"]) + spot.tracker.rollback() + self.assertEqual(spot.serialize_state(), committed) + + def test_load_channel_tip_preserves_pending_liquid_state(self): + """Restoration preserves uncommitted liquid changes until explicit rollback.""" + tracker = TipTracker("channel") + tracker.add_tip(self.tip) + self.tip.tracker.set_volume(4) + self.tip.tracker.add_liquid(3) + saved = tracker.serialize() + + restored = TipTracker("restored") + restored.load_state(saved) + + self.assertEqual(restored.serialize(), saved) + restored.get_tip().tracker.rollback() + self.assertEqual(restored.get_tip().tracker.get_used_volume(), 4) + + def test_load_pending_pickup_preserves_tip_transaction(self): + """A pending pickup remains pending and can be rolled back after restoration.""" + tracker = TipTracker("channel") + tracker.add_tip(self.tip, commit=False) + saved = tracker.serialize() + + restored = TipTracker("restored") + restored.load_state(saved) + + self.assertEqual(restored.serialize(), saved) + self.assertTrue(restored.has_tip) + with self.assertRaises(NoTipError): + restored.get_tip() + restored.rollback() + self.assertFalse(restored.has_tip) + def test_a_pending_removal_keeps_the_tip_readable_until_it_commits(self): """A liquid handler removes a spot's tip before its backend asks the spot which tip it is.""" tracker = TipTracker(thing="tester")