diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index ff20bf2014e..ce5663aece9 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1413,6 +1413,25 @@ class Head96Information: """Dispensing-drive (piston) volume window (uL); applies to both aspirate and dispense.""" dispensing_drive_speed_range: Tuple[float, float] """Dispensing-drive speed window (uL/s).""" + # Per-drive default speed / acceleration that vary by firmware version (resolved at setup). + y_drive_speed_default: float + """Y-drive default speed (mm/s).""" + y_drive_acceleration_default: float + """Y-drive default acceleration (mm/s2).""" + dispensing_drive_acceleration_default: float + """Dispensing-drive default acceleration (uL/s2).""" + squeezer_drive_speed_default: float + """Squeezer-drive default speed (mm/s).""" + squeezer_drive_acceleration_default: float + """Squeezer-drive default acceleration (mm/s2).""" + + # === Per-drive default speed / acceleration that are constant across firmware (standard units). === + z_drive_speed_default: float = 85.0 + """Z-drive default speed (mm/s).""" + z_drive_acceleration_default: float = 400.0 + """Z-drive default acceleration (mm/s2).""" + dispensing_drive_speed_default: float = 261.1 + """Dispensing-drive default speed (uL/s).""" # === Encoder resolutions (defaulted device facts). Y/Z are unchanged across firmware; the # dispensing/squeezer resolutions are the 2013+ generation values (2008-era heads differ). === @@ -2098,6 +2117,19 @@ async def set_up_core96_head(): dispensing_drive_speed_range=self._head96_resolve_dispensing_drive_speed_range( fw_version ), + y_drive_speed_default=self._head96_resolve_y_drive_speed_default(fw_version), + y_drive_acceleration_default=self._head96_resolve_y_drive_acceleration_default( + fw_version + ), + dispensing_drive_acceleration_default=self._head96_resolve_dispensing_drive_acceleration_default( + fw_version + ), + squeezer_drive_speed_default=self._head96_resolve_squeezer_drive_speed_default( + fw_version + ), + squeezer_drive_acceleration_default=self._head96_resolve_squeezer_drive_acceleration_default( + fw_version + ), ) async def set_up_arm_modules(): @@ -7806,6 +7838,35 @@ def _head96_resolve_dispensing_drive_speed_range( self._head96_dispensing_drive_increment_to_uL(max_inc), ) + # Per-drive default speed / acceleration that vary by firmware version (the constant ones are plain + # Head96Information fields). 2013 firmware raised them. The dispensing/squeezer values use the 2013+ + # encoder resolutions, so for pre-2010 heads they are approximate (as the ranges above are). + def _head96_resolve_y_drive_speed_default(self, fw_version: datetime.date) -> float: + """Y-drive default speed (mm/s); 2013 firmware raised it.""" + return self._head96_y_drive_increment_to_mm(25000 if fw_version.year >= 2010 else 20000) + + def _head96_resolve_y_drive_acceleration_default(self, fw_version: datetime.date) -> float: + """Y-drive default acceleration (mm/s2); 2013 firmware raised it.""" + return self._head96_y_drive_increment_to_mm(35000 if fw_version.year >= 2010 else 32000) + + def _head96_resolve_dispensing_drive_acceleration_default( + self, fw_version: datetime.date + ) -> float: + """Dispensing-drive default acceleration (uL/s2); 2013 firmware raised it.""" + return self._head96_dispensing_drive_increment_to_uL( + 900000 if fw_version.year >= 2010 else 150000 + ) + + def _head96_resolve_squeezer_drive_speed_default(self, fw_version: datetime.date) -> float: + """Squeezer-drive default speed (mm/s); 2013 firmware raised it.""" + return self._head96_squeezer_drive_increment_to_mm(76000 if fw_version.year >= 2010 else 16000) + + def _head96_resolve_squeezer_drive_acceleration_default(self, fw_version: datetime.date) -> float: + """Squeezer-drive default acceleration (mm/s2); 2013 firmware raised it.""" + return self._head96_squeezer_drive_increment_to_mm( + 300000 if fw_version.year >= 2010 else 100000 + ) + # -------------- 3.10.1 Initialization -------------- async def initialize_core_96_head( diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 7ce9e157719..731401dd0e4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -172,6 +172,15 @@ async def setup( z_range=self._head96_resolve_z_range(instrument_type), dispensing_drive_range=self._head96_resolve_dispensing_drive_range(fw_version), dispensing_drive_speed_range=self._head96_resolve_dispensing_drive_speed_range(fw_version), + y_drive_speed_default=self._head96_resolve_y_drive_speed_default(fw_version), + y_drive_acceleration_default=self._head96_resolve_y_drive_acceleration_default(fw_version), + dispensing_drive_acceleration_default=self._head96_resolve_dispensing_drive_acceleration_default( + fw_version + ), + squeezer_drive_speed_default=self._head96_resolve_squeezer_drive_speed_default(fw_version), + squeezer_drive_acceleration_default=self._head96_resolve_squeezer_drive_acceleration_default( + fw_version + ), ) else: self._head96_information = None diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index ddf54a2ae8f..ffcd377a030 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -2,6 +2,7 @@ import contextlib import copy +import datetime import unittest import unittest.mock from typing import Literal, cast @@ -447,6 +448,38 @@ async def test_skipped_when_iswap_not_installed(self): _ = cb.iswap_information +class TestHead96DriveDefaults(unittest.IsolatedAsyncioTestCase): + """Head96Information carries the firmware default speed/acceleration for every drive, in standard + units: version-resolved where it changed across firmware, constant fields where it did not.""" + + async def test_setup_resolves_all_drive_defaults(self): + """setup() populates all eight per-drive defaults with the 2013+ firmware values.""" + cb = STARChatterboxBackend() # mocks a 2023 (2013+) head + cb.set_deck(STARLetDeck()) + await cb.setup() + info = cb._head96_information + assert info is not None + self.assertAlmostEqual(info.y_drive_speed_default, 390.62, places=2) + self.assertAlmostEqual(info.y_drive_acceleration_default, 546.88, places=2) + self.assertEqual(info.z_drive_speed_default, 85.0) + self.assertEqual(info.z_drive_acceleration_default, 400.0) + self.assertEqual(info.dispensing_drive_speed_default, 261.1) + self.assertAlmostEqual(info.dispensing_drive_acceleration_default, 17406.84, places=2) + self.assertAlmostEqual(info.squeezer_drive_speed_default, 15.86, places=2) + self.assertAlmostEqual(info.squeezer_drive_acceleration_default, 62.6, places=2) + + def test_version_resolved_default_falls_back_for_pre_2010_firmware(self): + """A version-resolved default switches to the 2008 firmware value for pre-2010 heads (Y, whose + encoder resolution is constant, so both branches are exact).""" + star = STARBackend(read_timeout=1) + self.assertAlmostEqual( + star._head96_resolve_y_drive_speed_default(datetime.date(2008, 11, 11)), 312.5, places=2 + ) + self.assertAlmostEqual( + star._head96_resolve_y_drive_speed_default(datetime.date(2013, 9, 2)), 390.62, places=2 + ) + + class TestiSWAPYMaxBootstrap(unittest.IsolatedAsyncioTestCase): """`_iswap_rotation_drive_request_y_max` runs during setup, before `iswap_information` exists, so it must not read it (regression: it used to,