diff --git a/pylabrobot/hamilton/star/driver/features/head96.py b/pylabrobot/hamilton/star/driver/features/head96.py index 98d938f712b..c866a1c9db0 100644 --- a/pylabrobot/hamilton/star/driver/features/head96.py +++ b/pylabrobot/hamilton/star/driver/features/head96.py @@ -88,8 +88,6 @@ class Head96Configuration(HeadConfiguration): first_documented_firmware_year: int = 2010 # As on the base: what the head reported, standing in front of the derived values below. - dispensing_drive_speed_firmware_reported: Optional[float] = None - dispensing_drive_acceleration_firmware_reported: Optional[float] = None squeezer_drive_speed_firmware_reported: Optional[float] = None squeezer_drive_acceleration_firmware_reported: Optional[float] = None @@ -223,13 +221,6 @@ def dispensing_drive_speed_range(self) -> Tuple[float, float]: self.dispensing_drive_increments_to_uL(max_inc), ) - @property - def dispensing_drive_speed_default(self) -> float: - """Dispensing-drive default speed (uL/s); constant across firmware.""" - if self.dispensing_drive_speed_firmware_reported is not None: - return self.dispensing_drive_speed_firmware_reported - return 261.1 - @property def dispensing_drive_acceleration_range(self) -> Tuple[float, float]: """Dispensing-drive acceleration window (uL/s2); its max is the default 2013 firmware raised.""" @@ -239,14 +230,6 @@ def dispensing_drive_acceleration_range(self) -> Tuple[float, float]: self.dispensing_drive_increments_to_uL(max_inc), ) - @property - def dispensing_drive_acceleration_default(self) -> float: - """Dispensing-drive default acceleration (uL/s2); 2013 firmware raised it.""" - if self.dispensing_drive_acceleration_firmware_reported is not None: - return self.dispensing_drive_acceleration_firmware_reported - increments = 900000 if self.firmware_year >= 2010 else 150000 - return self.dispensing_drive_increments_to_uL(increments) - @property def squeezer_drive_speed_default(self) -> float: """Squeezer-drive default speed (mm/s); 2013 firmware raised it.""" @@ -295,6 +278,9 @@ class Head96(Head): default_mix_position_from_liquid_surface: float = 2.0 # Aspirate under cLLD: how far below the surface found the tips draw, in mm. default_aspirate_immersion_depth: float = 2.0 + # Behind the properties of the same names, which check a value against its range when assigned. + _default_dispensing_drive_speed: float = 250.0 + _default_dispensing_drive_acceleration: float = 17000.0 # How far above a container's top a liquid search starts, in mm. search_start_clearance: float = 5.0 @@ -308,6 +294,34 @@ def __init__(self, driver: "STARDriver", configuration: Optional[Head96Configura # Where the piston stands, in uL, as last read or moved; 0.0 at rest. self.piston_position: float = 0.0 + @property + def default_dispensing_drive_speed(self) -> float: + """In uL/s. Not read from the head, which answers with the speed its last command set.""" + return self._default_dispensing_drive_speed + + @default_dispensing_drive_speed.setter + def default_dispensing_drive_speed(self, speed: float) -> None: + low, high = self.configuration.dispensing_drive_speed_range + if not low <= speed <= high: + raise ValueError( + f"default_dispensing_drive_speed must be between {low} and {high}, is {speed}" + ) + self._default_dispensing_drive_speed = speed + + @property + def default_dispensing_drive_acceleration(self) -> float: + """In uL/s2. A head with firmware from before 2010 gets 2900.0 at setup.""" + return self._default_dispensing_drive_acceleration + + @default_dispensing_drive_acceleration.setter + def default_dispensing_drive_acceleration(self, acceleration: float) -> None: + low, high = self.configuration.dispensing_drive_acceleration_range + if not low <= acceleration <= high: + raise ValueError( + f"default_dispensing_drive_acceleration must be between {low} and {high}, is {acceleration}" + ) + self._default_dispensing_drive_acceleration = acceleration + # -- session / discovery ------------------------------------------------------------------------- def _apply_firmware_generation(self) -> None: @@ -320,17 +334,16 @@ def _apply_firmware_generation(self) -> None: c = self.configuration if c.firmware_year >= 2010: return + self.default_dispensing_drive_acceleration = 2900.0 c.y_drive_acceleration_mm_per_increment = c.y_drive_mm_per_increment * 1000 c.z_drive_acceleration_mm_per_increment = c.z_drive_mm_per_increment * 1000 c.drive_parameters = {"yv": 5, "yr": 3, "zv": 5, "zr": 3, "dv": 5, "dr": 4, "sv": 5, "sr": 3} c.z_acceleration_range_increments = (5, 100) async def discover(self): - """Read what head this is, then take its dispensing and squeezer defaults from the head.""" + """Read what head this is, then take its squeezer defaults from the head.""" await super().discover() c = self.configuration - c.dispensing_drive_speed_firmware_reported = await self._reported_drive_parameter("dv") - c.dispensing_drive_acceleration_firmware_reported = await self._reported_drive_parameter("dr") c.squeezer_drive_speed_firmware_reported = await self._reported_drive_parameter("sv") c.squeezer_drive_acceleration_firmware_reported = await self._reported_drive_parameter("sr") @@ -387,10 +400,9 @@ async def move_dispensing_drive_to_position( Args: volume: where to send the piston, as the volume it would hold, in uL. - speed: how fast, in uL/s. Defaults to `configuration.dispensing_drive_speed_default`. + speed: how fast, in uL/s. Defaults to `default_dispensing_drive_speed`. stop_speed: what to slow to at the end, in uL/s. - acceleration: how hard, in uL/s2. Defaults to - `configuration.dispensing_drive_acceleration_default`. + acceleration: how hard, in uL/s2. Defaults to `default_dispensing_drive_acceleration`. current_limit: the motor current limit. read_timeout: how long to wait for the device to answer, in seconds. @@ -399,9 +411,9 @@ async def move_dispensing_drive_to_position( """ c = self.configuration if speed is None: - speed = c.dispensing_drive_speed_default + speed = self.default_dispensing_drive_speed if acceleration is None: - acceleration = c.dispensing_drive_acceleration_default + acceleration = self.default_dispensing_drive_acceleration for value, (low, high), name in ( (volume, c.dispensing_drive_range, "volume"), @@ -1397,7 +1409,7 @@ async def _aspirate_in_place( Args: volume: per channel, in uL. - flow_rate: in uL/s. `dispensing_drive_speed_default` when None. + flow_rate: in uL/s. `default_dispensing_drive_speed` when None. surface_following_distance: how far down it follows the surface, in mm. minimum_height: lowest tip bottom height, in mm. The lowest reachable when None. @@ -1406,7 +1418,7 @@ async def _aspirate_in_place( """ c = self.configuration if flow_rate is None: - flow_rate = c.dispensing_drive_speed_default + flow_rate = self.default_dispensing_drive_speed following_max = c.z_drive_increments_to_mm(9999) for checked, (low, high), name in ( (volume, c.dispensing_drive_range, "volume"), @@ -1448,7 +1460,7 @@ async def _dispense_in_place( Args: volume: per channel, in uL. - flow_rate: in uL/s. `dispensing_drive_speed_default` when None. + flow_rate: in uL/s. `default_dispensing_drive_speed` when None. stop_flow_rate: in uL/s. stop_back_volume: drawn back at the end, in uL. surface_following_distance: how far up it follows the surface, in mm. @@ -1459,7 +1471,7 @@ async def _dispense_in_place( """ c = self.configuration if flow_rate is None: - flow_rate = c.dispensing_drive_speed_default + flow_rate = self.default_dispensing_drive_speed speed_max = c.dispensing_drive_speed_range[1] following_max = c.z_drive_increments_to_mm(9999) for checked, (low, high), name in ( diff --git a/pylabrobot/hamilton/star/driver/features/head_tests.py b/pylabrobot/hamilton/star/driver/features/head_tests.py index d746b60a286..d0cda31e608 100644 --- a/pylabrobot/hamilton/star/driver/features/head_tests.py +++ b/pylabrobot/hamilton/star/driver/features/head_tests.py @@ -85,8 +85,8 @@ async def test_the_defaults_are_what_the_head_reported(self): (200.0, 300.0, 50.0, 250.0), ) - async def test_the_96_head_takes_its_dispensing_and_squeezer_defaults_too(self): - """`Head96.discover` reads four drive parameters on top of the Y and Z ones every head shares, + async def test_the_96_head_takes_its_squeezer_defaults_too(self): + """`Head96.discover` reads two drive parameters on top of the Y and Z ones every head shares, and its defaults answer with what it reported for them. Apart from the test above because it covers the override rather than the base: the 384-head adds no reads of its own. @@ -96,8 +96,6 @@ async def test_the_96_head_takes_its_dispensing_and_squeezer_defaults_too(self): decoded rather than handed over whole.""" declared = dataclasses.replace( RECORDED_HEAD96, - dispensing_drive_speed_firmware_reported=400.0, - dispensing_drive_acceleration_firmware_reported=9000.0, squeezer_drive_speed_firmware_reported=12.0, squeezer_drive_acceleration_firmware_reported=50.0, ) @@ -109,15 +107,37 @@ async def test_the_96_head_takes_its_dispensing_and_squeezer_defaults_too(self): c = cast(Head96, driver.x_arm.head96).configuration for read, declared_value in zip( ( - c.dispensing_drive_speed_default, - c.dispensing_drive_acceleration_default, c.squeezer_drive_speed_default, c.squeezer_drive_acceleration_default, ), - (400.0, 9000.0, 12.0, 50.0), + (12.0, 50.0), ): self.assertAlmostEqual(read, declared_value, places=1) + async def test_a_head_with_firmware_from_before_2010_gets_its_lower_dispensing_acceleration(self): + declared = dataclasses.replace(RECORDED_HEAD96, firmware_version="1.0S 2008-11-11 (H0)") + driver = STARSimulationDriver( + deck=STARDeck(), declared_configuration_json=declaring(head96=declared) + ) + await driver.setup() + head = cast(Head96, driver.x_arm.head96) + self.assertEqual(head.default_dispensing_drive_acceleration, 2900.0) + + async def test_a_default_outside_the_range_of_its_drive_is_refused_when_assigned(self): + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + await driver.setup() + head = cast(Head96, driver.x_arm.head96) + with self.assertRaisesRegex(ValueError, "default_dispensing_drive_speed must be between"): + head.default_dispensing_drive_speed = 5000.0 + with self.assertRaisesRegex(ValueError, "default_dispensing_drive_acceleration must be"): + head.default_dispensing_drive_acceleration = 50.0 + self.assertEqual( + (head.default_dispensing_drive_speed, head.default_dispensing_drive_acceleration), + (250.0, 17000.0), + ) + head.default_dispensing_drive_speed = 200.0 + self.assertEqual(head.default_dispensing_drive_speed, 200.0) + async def test_a_head_that_will_not_say_keeps_what_its_firmware_documents(self): """A head that refuses the read leaves discovery with nothing to record, and the defaults fall back to the increments its firmware documents rather than the read failing setup. Driven @@ -207,10 +227,10 @@ async def test_pick_up_and_drop_send_what_legacy_sends(self): self.sent, [ "C0TTtt01tf1tl0519tv03600tg2tu0", - "H0DQdq11281dv13500du00000dr900000dw15", + "H0DQdq11281dv12926du00000dr878965dw15", "C0EPxs01179xd0yh2418tt01wu0za2164zh2450ze2450", "C0ERxs01179xd0yh2418za2164zh2450ze2450", - "H0DQdq11281dv13500du00000dr900000dw15", + "H0DQdq11281dv12926du00000dr878965dw15", "C0EPxs01179xd0yh2418tt01wu0za2164zh2450ze2450", # The trash drop centres the array; legacy put A1 4.5 mm right and 58.5 mm forward of this. "C0ERxs00465xd1yh1788za2164zh2450ze2450", diff --git a/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head96_autoload1D.json b/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head96_autoload1D.json index c0335611906..30b2a9e7696 100644 --- a/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head96_autoload1D.json +++ b/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head96_autoload1D.json @@ -275,8 +275,6 @@ "y_drive_acceleration_firmware_reported": null, "z_drive_speed_firmware_reported": null, "z_drive_acceleration_firmware_reported": null, - "dispensing_drive_speed_firmware_reported": null, - "dispensing_drive_acceleration_firmware_reported": null, "squeezer_drive_speed_firmware_reported": null, "squeezer_drive_acceleration_firmware_reported": null, "stop_disc_type": "core_ii", diff --git a/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head96_autoload1D.json b/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head96_autoload1D.json index 500e0e96f57..7adb59ee110 100644 --- a/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head96_autoload1D.json +++ b/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head96_autoload1D.json @@ -275,8 +275,6 @@ "y_drive_acceleration_firmware_reported": null, "z_drive_speed_firmware_reported": null, "z_drive_acceleration_firmware_reported": null, - "dispensing_drive_speed_firmware_reported": null, - "dispensing_drive_acceleration_firmware_reported": null, "squeezer_drive_speed_firmware_reported": null, "squeezer_drive_acceleration_firmware_reported": null, "stop_disc_type": "core_ii", diff --git a/pylabrobot/hamilton/star/driver/recordings/starplus_legacy_2021_8ch_head96.json b/pylabrobot/hamilton/star/driver/recordings/starplus_legacy_2021_8ch_head96.json index 07f32b196c6..1fc4622f3dd 100644 --- a/pylabrobot/hamilton/star/driver/recordings/starplus_legacy_2021_8ch_head96.json +++ b/pylabrobot/hamilton/star/driver/recordings/starplus_legacy_2021_8ch_head96.json @@ -275,8 +275,6 @@ "y_drive_acceleration_firmware_reported": null, "z_drive_speed_firmware_reported": null, "z_drive_acceleration_firmware_reported": null, - "dispensing_drive_speed_firmware_reported": null, - "dispensing_drive_acceleration_firmware_reported": null, "squeezer_drive_speed_firmware_reported": null, "squeezer_drive_acceleration_firmware_reported": null, "stop_disc_type": "core_ii", diff --git a/pylabrobot/hamilton/star/driver/simulator.py b/pylabrobot/hamilton/star/driver/simulator.py index 76f721542e6..86be2bf50ef 100644 --- a/pylabrobot/hamilton/star/driver/simulator.py +++ b/pylabrobot/hamilton/star/driver/simulator.py @@ -1010,9 +1010,10 @@ def _simulated_drive_parameter(self, parameter: str) -> float: # The dispensing and squeezer drives have no register to read, so they answer with what this # head's firmware documents. head = self._declared + newer = head.firmware_year >= 2010 documented = { - "dv": head.dispensing_drive_speed_default, - "dr": head.dispensing_drive_acceleration_default, + "dv": 261.1, + "dr": head.dispensing_drive_increments_to_uL(900000 if newer else 150000), "sv": head.squeezer_drive_speed_default, "sr": head.squeezer_drive_acceleration_default, }