From 297dc94b3d2c3848ed8e035560dd5e9ac4c5dacd Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 10 Jun 2026 22:44:33 +0100 Subject: [PATCH 01/16] `STARBackend`: add 96-head Y/Z speed & acceleration set/request via AA/RA Add head96_set_y_speed / head96_set_y_acceleration / head96_set_z_speed / head96_set_z_acceleration, each saving the persistent drive parameter (yv/yr/zv/zr) standalone via H0 AA - no move - so subsequent moves (including the C0-level 96-head commands) inherit it, the same mechanism slow_iswap uses with R0 AA on wv/tv. Add the read counterparts head96_request_y_speed / _y_acceleration / _z_speed / _z_acceleration via H0 RA, with z-acceleration inverting the firmware-version scaling that the setter applies. Validation mirrors the existing move methods; setters are @_requires_head96, getters unguarded. NOTE: unverified on hardware - that AA accepts these parameter names and RA reads them back in the assumed field widths is inferred by analogy to slow_iswap; confirm with a set/request round-trip on the device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 88f813c571e..82aa81657c8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -8322,6 +8322,59 @@ async def head96_move_tool_z(self, z: float, speed: Optional[float] = None): return await self.head96_move_stop_disk_z(z + tip_overhang, speed=speed) + @_requires_head96 + async def head96_set_y_speed(self, speed: float): + """Set the persistent 96-head Y-drive speed (mm/s) without moving, via H0 AA (save parameter yv). + + Subsequent Y moves that don't pass their own speed - including the C0-level 96-head commands - + inherit this until it is changed or the drive re-initialises. Same standalone-set mechanism as + slow_iswap (which sets the iSWAP wv/tv velocities via R0 AA). + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + y_speed_min, y_speed_max = self._head96_information.y_speed_range + assert y_speed_min <= speed <= y_speed_max, ( + f"speed must be between {y_speed_min} and {y_speed_max} mm/sec" + ) + return await self.send_command( + module="H0", command="AA", yv=f"{self._head96_y_drive_mm_to_increment(speed):05}" + ) + + @_requires_head96 + async def head96_set_y_acceleration(self, acceleration: float): + """Set the persistent 96-head Y-drive acceleration (mm/s^2) without moving, via H0 AA (save yr).""" + assert 78.125 <= acceleration <= 781.25, ( + "acceleration must be between 78.125 and 781.25 mm/sec**2" + ) + return await self.send_command( + module="H0", command="AA", yr=f"{self._head96_y_drive_mm_to_increment(acceleration):05}" + ) + + @_requires_head96 + async def head96_set_z_speed(self, speed: float): + """Set the persistent 96-head Z-drive speed (mm/s) without moving, via H0 AA (save parameter zv).""" + assert 0.25 <= speed <= 100.0, "speed must be between 0.25 and 100.0 mm/sec" + return await self.send_command( + module="H0", command="AA", zv=f"{self._head96_z_drive_mm_to_increment(speed):05}" + ) + + @_requires_head96 + async def head96_set_z_acceleration(self, acceleration: float): + """Set the persistent 96-head Z-drive acceleration (mm/s^2) without moving, via H0 AA (save zr). + + Applies the same firmware-version acceleration scaling as head96_move_stop_disk_z (pre-2010 x0.001). + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + assert 25.0 <= acceleration <= 500.0, "acceleration must be between 25.0 and 500.0 mm/sec**2" + acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 + acceleration_increment = round( + self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier + ) + return await self.send_command(module="H0", command="AA", zr=f"{acceleration_increment:06}") + # -------------- 3.10.2 Tip handling using CoRe 96 Head -------------- @need_iswap_parked @@ -9277,6 +9330,43 @@ async def _head96_probe_z_max(self) -> float: await self.send_command(module="C0", command="EV", read_timeout=20) return await self.head96_request_stop_disk_z() + async def head96_request_y_speed(self) -> float: + """Request the persistent 96-head Y-drive speed (mm/s), via H0 RA (read parameter yv). + + The read counterpart of head96_set_y_speed. + """ + resp = await self.send_command(module="H0", command="RA", ra="yv", fmt="yv#####") + return self._head96_y_drive_increment_to_mm(resp["yv"]) + + async def head96_request_y_acceleration(self) -> float: + """Request the persistent 96-head Y-drive acceleration (mm/s^2), via H0 RA (read parameter yr). + + The read counterpart of head96_set_y_acceleration. + """ + resp = await self.send_command(module="H0", command="RA", ra="yr", fmt="yr#####") + return self._head96_y_drive_increment_to_mm(resp["yr"]) + + async def head96_request_z_speed(self) -> float: + """Request the persistent 96-head Z-drive speed (mm/s), via H0 RA (read parameter zv). + + The read counterpart of head96_set_z_speed. + """ + resp = await self.send_command(module="H0", command="RA", ra="zv", fmt="zv#####") + return self._head96_z_drive_increment_to_mm(resp["zv"]) + + async def head96_request_z_acceleration(self) -> float: + """Request the persistent 96-head Z-drive acceleration (mm/s^2), via H0 RA (read parameter zr). + + The read counterpart of head96_set_z_acceleration; inverts the firmware-version acceleration + scaling that the setter (and head96_move_stop_disk_z) applies. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + resp = await self.send_command(module="H0", command="RA", ra="zr", fmt="zr######") + acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 + return self._head96_z_drive_increment_to_mm(round(resp["zr"] / acceleration_multiplier)) + async def request_core_96_head_channel_tadm_status(self): """Request CoRe 96 Head channel TADM Status From 9ae840edf2b344eb008709654c22d7a76c378dcb Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 11 Jun 2026 18:51:46 +0100 Subject: [PATCH 02/16] `STARBackend`: scope 96-head Y/Z move speed & acceleration per call, retract Z on crash A ZA/YA move leaves its speed and acceleration in the drive's volatile register, so a later move or C0-level command inherits them (confirmed on hardware: a speed=20 Z move reads zv back as 20). head96_move_stop_disk_z and head96_move_y reset the drive parameters to the head's default after the move, skipping the reset for parameters the caller left at default (no churn on plain moves); reset_z_parameters / reset_y_parameters opt out. head96_move_stop_disk_z retracts to Z-safety on any firmware error before re-raising, via retract_on_crash (head96_move_to_z_safety passes False so the retract cannot recurse). head96_move_y speed/acceleration now default to None (firmware default) instead of 300, matching head96_move_stop_disk_z. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 102 +++++++++++++----- 1 file changed, 77 insertions(+), 25 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 82aa81657c8..9970dbb2730 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -8056,7 +8056,9 @@ async def head96_move_to_z_safety( "requires 96-head firmware version information for safe operation" ) z_max = self._head96_information.z_range[1] - return await self.head96_move_stop_disk_z(z_max, speed=speed, acceleration=acceleration) + return await self.head96_move_stop_disk_z( + z_max, speed=speed, acceleration=acceleration, retract_on_crash=False + ) @_requires_head96 async def head96_park( @@ -8111,17 +8113,25 @@ async def head96_move_x( async def head96_move_y( self, y: float, - speed: float = 300.0, - acceleration: float = 300.0, + speed: Optional[float] = None, + acceleration: Optional[float] = None, current_protection_limiter: int = 15, + reset_y_parameters: bool = True, ): """Move the 96-head to a specified Y-axis coordinate. + An overridden speed/acceleration persists in the drive's volatile register and is inherited by + later moves, so it is reset to the head's default afterwards unless `reset_y_parameters` is False. + Args: y: Target Y coordinate in mm. Valid range: [93.75, 562.5] - speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]. Default: 300.0 - acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 781.25]. Default: 300.0 + speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]; None uses the head's + y_drive_speed_default. + acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 781.25]; None uses the + head's y_drive_acceleration_default. current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 + reset_y_parameters: If True (default), reset an overridden speed/acceleration to the head's + defaults after the move so it does not persist; set False to deliberately keep it. Returns: Response from the hardware command. @@ -8140,6 +8150,15 @@ async def head96_move_y( "requires 96-head firmware version information for safe operation" ) + # Reset only what the caller overrode: None means "use the default", which already leaves the + # register at the default, so there is nothing to clean up (no churn on default moves). + restore_speed = reset_y_parameters and speed is not None + restore_acceleration = reset_y_parameters and acceleration is not None + if speed is None: + speed = self._head96_information.y_drive_speed_default + if acceleration is None: + acceleration = self._head96_information.y_drive_acceleration_default + fw_version = self._head96_information.fw_version y_min, y_max = self._head96_information.y_range y_speed_min, y_speed_max = self._head96_information.y_speed_range @@ -8164,16 +8183,20 @@ async def head96_move_y( speed_increment = self._head96_y_drive_mm_to_increment(speed) acceleration_increment = self._head96_y_drive_mm_to_increment(acceleration) - resp = await self.send_command( - module="H0", - command="YA", - ya=f"{y_increment:05}", - yv=f"{speed_increment:05}", - yr=f"{acceleration_increment:05}", - yw=f"{current_protection_limiter:02}", - ) - - return resp + try: + return await self.send_command( + module="H0", + command="YA", + ya=f"{y_increment:05}", + yv=f"{speed_increment:05}", + yr=f"{acceleration_increment:05}", + yw=f"{current_protection_limiter:02}", + ) + finally: + if restore_speed: + await self.head96_set_y_speed(self._head96_information.y_drive_speed_default) + if restore_acceleration: + await self.head96_set_y_acceleration(self._head96_information.y_drive_acceleration_default) @_requires_head96 async def head96_move_z( @@ -8209,12 +8232,19 @@ async def head96_move_stop_disk_z( speed: Optional[float] = None, acceleration: Optional[float] = None, current_protection_limiter: int = 15, + reset_z_parameters: bool = True, + retract_on_crash: bool = True, ): """Move the 96-head z-drive (stop disk) to an absolute Z position in mm. Stop-disk reference, mirroring the single-channel `move_channel_stop_disk_z`: use this for moves without a tip; for the tip end with a tip on, use `head96_move_tool_z`. + An overridden speed/acceleration persists in the drive's volatile register and would be inherited + by later moves (and C0-level commands), so it is reset to the head's default afterwards unless + `reset_z_parameters` is False. On any firmware error during the move (e.g. the head crashing into + something) the head retracts to Z-safety before the error is re-raised. + Args: z: Target stop-disk Z in mm. Valid range: Head96Information.z_range (180.5-342.5 mm; FM-STAR extends it). @@ -8223,6 +8253,10 @@ async def head96_move_stop_disk_z( acceleration: Movement acceleration in mm/sec^2, [25.0, 500.0]; None uses the head's z_drive_acceleration_default (400 mm/s^2; likewise constant for the Z drive). current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 + reset_z_parameters: If True (default), reset an overridden speed/acceleration to the head's + defaults after the move so it does not persist; set False to deliberately keep it. + retract_on_crash: If True (default), retract to Z-safety on any firmware error (e.g. a crash) + before re-raising. head96_move_to_z_safety passes False so its own retract cannot recurse. Returns: Response from the hardware command. @@ -8238,6 +8272,10 @@ async def head96_move_stop_disk_z( assert self._head96_information is not None, ( "requires 96-head firmware version information for safe operation" ) + # Reset only what the caller actually overrode: a None means "use the default", which already + # leaves the register at the default, so there is nothing to clean up (no churn on default moves). + restore_speed = reset_z_parameters and speed is not None + restore_acceleration = reset_z_parameters and acceleration is not None if speed is None: speed = self._head96_information.z_drive_speed_default if acceleration is None: @@ -8268,16 +8306,30 @@ async def head96_move_stop_disk_z( self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier ) - resp = await self.send_command( - module="H0", - command="ZA", - za=f"{z_increment:05}", - zv=f"{speed_increment:05}", - zr=f"{acceleration_increment:06}", - zw=f"{current_protection_limiter:02}", - ) - - return resp + try: + return await self.send_command( + module="H0", + command="ZA", + za=f"{z_increment:05}", + zv=f"{speed_increment:05}", + zr=f"{acceleration_increment:06}", + zw=f"{current_protection_limiter:02}", + ) + except STARFirmwareError: + # Any firmware error here (most importantly a Z-drive crash) can leave the head against an + # obstacle, so retract to Z-safety before re-raising. head96_move_to_z_safety calls back into + # this method with retract_on_crash=False, so the retract cannot recurse into recovery. + if retract_on_crash: + try: + await self.head96_move_to_z_safety() + except STARFirmwareError: + pass # retract failed too; surface the original error below + raise + finally: + if restore_speed: + await self.head96_set_z_speed(self._head96_information.z_drive_speed_default) + if restore_acceleration: + await self.head96_set_z_acceleration(self._head96_information.z_drive_acceleration_default) @_requires_head96 async def head96_move_tool_z(self, z: float, speed: Optional[float] = None): From 8726b22b6f7c97ba3ecdd5c2116eec439ad3e748 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 11 Jun 2026 22:37:57 +0100 Subject: [PATCH 03/16] `STARBackend`: validate 96-head Z speed/acceleration against `Head96Information` Add z_speed_range and z_acceleration_range to Head96Information as constant fields (verified unchanged across the 2008/2013/2025 firmware command sets). head96_move_stop_disk_z, head96_set_z_speed, and head96_set_z_acceleration now validate against the record instead of hardcoded literals, mirroring head96_move_y's existing use of y_speed_range; head96_set_z_speed gains the missing Head96Information guard. The crash-recovery retract now moves at a quarter of the max Z speed (z_speed_range[1] * 0.25) rather than the default - the head may be submerged in liquid after a crash. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 9970dbb2730..8153277c5d2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1432,6 +1432,12 @@ class Head96Information: """Z-drive default acceleration (mm/s2).""" dispensing_drive_speed_default: float = 261.1 """Dispensing-drive default speed (uL/s).""" + z_speed_range: Tuple[float, float] = (0.25, 100.0) + """Z-drive speed window (mm/s); unchanged across the 2008/2013/2025 firmware, unlike the + version-resolved y_speed_range.""" + z_acceleration_range: Tuple[float, float] = (25.0, 500.0) + """Z-drive acceleration window (mm/s2); unchanged across the 2008/2013/2025 firmware (the + pre-2010 encoding differs, the physical range does not).""" # === Encoder resolutions (defaulted device facts). Y/Z are unchanged across firmware; the # dispensing/squeezer resolutions are the 2013+ generation values (2008-era heads differ). === @@ -8286,9 +8292,15 @@ async def head96_move_stop_disk_z( # Validate parameters before hardware communication. The Z window is firmware/variant-adaptive # (FM-STAR extends it), so read it from Head96Information rather than hardcoding the legacy range. z_min, z_max = self._head96_information.z_range + z_speed_min, z_speed_max = self._head96_information.z_speed_range + z_accel_min, z_accel_max = self._head96_information.z_acceleration_range assert z_min <= z <= z_max, f"z must be between {z_min} and {z_max} mm" - assert 0.25 <= speed <= 100.0, "speed must be between 0.25 and 100.0 mm/sec" - assert 25.0 <= acceleration <= 500.0, "acceleration must be between 25.0 and 500.0 mm/sec**2" + assert z_speed_min <= speed <= z_speed_max, ( + f"speed must be between {z_speed_min} and {z_speed_max} mm/sec" + ) + assert z_accel_min <= acceleration <= z_accel_max, ( + f"acceleration must be between {z_accel_min} and {z_accel_max} mm/sec**2" + ) assert isinstance(current_protection_limiter, int) and ( 0 <= current_protection_limiter <= 15 ), "current_protection_limiter must be an integer between 0 and 15" @@ -8321,7 +8333,8 @@ async def head96_move_stop_disk_z( # this method with retract_on_crash=False, so the retract cannot recurse into recovery. if retract_on_crash: try: - await self.head96_move_to_z_safety() + # retract slowly (quarter of max speed) - the head may be in liquid after a crash + await self.head96_move_to_z_safety(speed=self._head96_information.z_speed_range[1] * 0.25) except STARFirmwareError: pass # retract failed too; surface the original error below raise @@ -8406,7 +8419,13 @@ async def head96_set_y_acceleration(self, acceleration: float): @_requires_head96 async def head96_set_z_speed(self, speed: float): """Set the persistent 96-head Z-drive speed (mm/s) without moving, via H0 AA (save parameter zv).""" - assert 0.25 <= speed <= 100.0, "speed must be between 0.25 and 100.0 mm/sec" + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + z_speed_min, z_speed_max = self._head96_information.z_speed_range + assert z_speed_min <= speed <= z_speed_max, ( + f"speed must be between {z_speed_min} and {z_speed_max} mm/sec" + ) return await self.send_command( module="H0", command="AA", zv=f"{self._head96_z_drive_mm_to_increment(speed):05}" ) @@ -8420,7 +8439,10 @@ async def head96_set_z_acceleration(self, acceleration: float): assert self._head96_information is not None, ( "requires 96-head firmware version information for safe operation" ) - assert 25.0 <= acceleration <= 500.0, "acceleration must be between 25.0 and 500.0 mm/sec**2" + z_accel_min, z_accel_max = self._head96_information.z_acceleration_range + assert z_accel_min <= acceleration <= z_accel_max, ( + f"acceleration must be between {z_accel_min} and {z_accel_max} mm/sec**2" + ) acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 acceleration_increment = round( self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier From b2cc2899fd802afc87e9eaef9c4d6d01ae33ce07 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 11 Jun 2026 22:46:56 +0100 Subject: [PATCH 04/16] `STARBackend`: test 96-head Z crash-retract and its no-recurse guard TestHead96CrashRecovery covers head96_move_stop_disk_z's firmware-error path: a ZA error retracts the head to z_range[1] (a second ZA) before re-raising the original error, and when the retract itself errors the move sends exactly two ZA commands (no recursion) and surfaces the original error, not the retract's. The crash is injected via send_command side_effect; z targets are read from the record so the tests track the resolved Z window. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_tests.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index ffcd377a030..caae087c75a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -480,6 +480,75 @@ def test_version_resolved_default_falls_back_for_pre_2010_firmware(self): ) +class TestHead96CrashRecovery(unittest.IsolatedAsyncioTestCase): + """head96_move_stop_disk_z retracts the head to Z-safety on a firmware error then re-raises, and + the retract - which routes back through the same primitive - cannot recurse.""" + + async def asyncSetUp(self): + self.cb = STARChatterboxBackend() + self.cb.set_deck(STARLetDeck()) + await self.cb.setup() + assert self.cb._head96_information is not None + z_min, z_max = self.cb._head96_information.z_range + self.z_target = round((z_min + z_max) / 2, 1) + self.z_safety_za = f"{self.cb._head96_z_drive_mm_to_increment(z_max):05}" + + def _crash(self, message): + return STARFirmwareError( + errors={ + "CoRe 96 Head": UnknownHamiltonError( + message=message, trace_information=62, raw_response=message, raw_module="H0" + ) + }, + raw_response=message, + ) + + async def test_crash_retracts_to_z_safety_then_reraises(self): + """A ZA firmware error retracts the head to z_range[1] (a second ZA) before the original error + propagates.""" + original = self._crash("z drive movement error") + za_targets = [] + + async def fake_send(module, command, **kwargs): + if command == "ZA": + za_targets.append(kwargs["za"]) + if len(za_targets) == 1: + raise original # the move crashes + return {} # the safety retract succeeds + return {} # AA restore etc. + + self.cb.send_command = unittest.mock.AsyncMock(side_effect=fake_send) + with self.assertRaises(STARFirmwareError) as ctx: + await self.cb.head96_move_stop_disk_z(self.z_target) + + self.assertIs(ctx.exception, original) + move_za = f"{self.cb._head96_z_drive_mm_to_increment(self.z_target):05}" + self.assertEqual(za_targets, [move_za, self.z_safety_za]) + + async def test_retract_that_also_crashes_does_not_recurse(self): + """If the safety retract itself errors, exactly two ZA moves are sent (no recursion) and the + ORIGINAL error re-raises, not the retract's.""" + original = self._crash("original crash") + retract_err = self._crash("retract crash") + za_count = 0 + + async def fake_send(module, command, **kwargs): + nonlocal za_count + if command == "ZA": + za_count += 1 + if za_count == 1: + raise original + raise retract_err + return {} + + self.cb.send_command = unittest.mock.AsyncMock(side_effect=fake_send) + with self.assertRaises(STARFirmwareError) as ctx: + await self.cb.head96_move_stop_disk_z(self.z_target) + + self.assertIs(ctx.exception, original) + self.assertEqual(za_count, 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, From 9a099466e9a72c5afaa359045950fe4ddd836626 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sat, 13 Jun 2026 11:32:18 +0100 Subject: [PATCH 05/16] `STARBackend`: make 96-head drive speed/acceleration defaults user-overridable Expose the 96-head Y/Z drive speed and acceleration defaults as range-checked properties (head96_{y,z}_drive_{speed,acceleration}_default), seeded from Head96Information at setup. A move's None-default resolution and its post-move register restore both read these, so a user can set their own default at runtime, have it drive plain moves, and have it survive the restore. Head96Information stays frozen as the factory reference; the live values live on the backend. Add Head96Information.y_acceleration_range, resolved per firmware (max 500.0 on 2008, 781.25 on 2013+) like y_speed_range rather than a constant, and validate Y acceleration against it in head96_move_y and the Y-acceleration setter. The previous hardcoded 78.125..781.25 literal over-permitted 2008-era heads. Make the on-device AA register writes private (_head96_set_*); the public knob is the property. The RA reads stay public as diagnostics for troubleshooting. Regroup the 96-head section so it opens with the conversion helpers, then the overridable-default properties, the request reads, the private setters, then the movement commands. Co-Authored-By: Claude Fable 5 --- .../backends/hamilton/STAR_backend.py | 364 ++++++++++++------ .../backends/hamilton/STAR_chatterbox.py | 10 + 2 files changed, 257 insertions(+), 117 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 8153277c5d2..3bb52217a86 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1407,6 +1407,9 @@ class Head96Information: """Y-drive position window (mm).""" y_speed_range: Tuple[float, float] """Y-drive speed window (mm/s).""" + y_acceleration_range: Tuple[float, float] + """Y-drive acceleration window (mm/s2); the max changed across firmware (500.0 on 2008, 781.25 on + 2013+), so it is version-resolved like y_speed_range, not a constant.""" z_range: Tuple[float, float] """Z-drive position window (mm); FM-STAR extends it.""" dispensing_drive_range: Tuple[float, float] @@ -1626,6 +1629,13 @@ def __init__( # `set_up_iswap` from firmware/EEPROM. See `iswap_information` property # for guarded access. None pre-setup; immutable post-setup. self._iswap_information: Optional[iSWAPInformation] = None + # User-overridable 96-head Y/Z drive speed/acceleration defaults, exposed via @property with a + # range check in the setter. Seeded from the Head96Information factory defaults at setup; a move + # restores the drive register to these afterwards. None pre-setup. + self._head96_y_drive_speed_default: Optional[float] = None + self._head96_y_drive_acceleration_default: Optional[float] = None + self._head96_z_drive_speed_default: Optional[float] = None + self._head96_z_drive_acceleration_default: Optional[float] = None self.core_adjustment = Coordinate.zero() self._unsafe = UnSafe(self) @@ -2120,6 +2130,7 @@ async def set_up_core96_head(): head_type=head96_type, y_range=self._head96_resolve_y_range(fw_version), y_speed_range=self._head96_resolve_y_speed_range(fw_version), + y_acceleration_range=self._head96_resolve_y_acceleration_range(fw_version), # probing safe max z position also acts a safety retraction of the head96 on every setup call z_range=( self._head96_resolve_z_range(instrument_type)[0], @@ -2143,6 +2154,16 @@ async def set_up_core96_head(): fw_version ), ) + # Seed the user-overridable drive defaults from the frozen factory facts (assign the backing + # fields directly, not through the validating properties: the factory values are in range). + self._head96_y_drive_speed_default = self._head96_information.y_drive_speed_default + self._head96_y_drive_acceleration_default = ( + self._head96_information.y_drive_acceleration_default + ) + self._head96_z_drive_speed_default = self._head96_information.z_drive_speed_default + self._head96_z_drive_acceleration_default = ( + self._head96_information.z_drive_acceleration_default + ) async def set_up_arm_modules(): await set_up_pip() @@ -7824,6 +7845,15 @@ def _head96_resolve_y_speed_range(self, fw_version: datetime.date) -> Tuple[floa Verify on a pre-2021 head before raising it. Refactored verbatim from head96_move_y.""" return (0.78125, 390.625 if fw_version.year <= 2021 else 625.0) + def _head96_resolve_y_acceleration_range(self, fw_version: datetime.date) -> Tuple[float, float]: + """Y-drive acceleration window (mm/s2). The min (5000 inc) is constant; the max rose from 32000 + inc (2008) to 50000 inc (2013+), so it is resolved per firmware like the Y range / speed.""" + max_inc = 50000 if fw_version.year >= 2010 else 32000 + return ( + self._head96_y_drive_increment_to_mm(5000), + self._head96_y_drive_increment_to_mm(max_inc), + ) + def _head96_resolve_z_range(self, instrument_type: str) -> Tuple[float, float]: """Z-drive position window (mm); FM-STAR extends it (za/zb/zh all share this range).""" min_inc, max_inc = (24200, 76200) if instrument_type == "FM-STAR" else (36100, 68500) @@ -8039,6 +8069,204 @@ def _head96_squeezer_drive_increment_to_mm(self, value_increments: int) -> float """Convert squeezer drive hardware increments to mm for 96-head.""" return round(value_increments * self._head96_squeezer_drive_mm_per_increment, 2) + # User-overridable drive defaults. Each is seeded from the frozen Head96Information factory default + # at setup and restored after a move; the setter range-checks against Head96Information so a user + # can set their own default safely. + + @property + def head96_y_drive_speed_default(self) -> float: + """User-overridable 96-head Y-drive speed default (mm/s) a move restores the drive register to. + + Seeded from Head96Information.y_drive_speed_default at setup; assign your own default and it is + validated against y_speed_range before taking effect. + """ + assert self._head96_y_drive_speed_default is not None, ( + "96-head information not loaded; run setup()" + ) + return self._head96_y_drive_speed_default + + @head96_y_drive_speed_default.setter + def head96_y_drive_speed_default(self, value: float): + assert self._head96_information is not None, "96-head information not loaded; run setup()" + lo, hi = self._head96_information.y_speed_range + if not lo <= value <= hi: + raise ValueError(f"speed must be between {lo} and {hi} mm/sec") + self._head96_y_drive_speed_default = value + + @property + def head96_y_drive_acceleration_default(self) -> float: + """User-overridable 96-head Y-drive acceleration default (mm/s2) a move restores the register to. + + Seeded from Head96Information.y_drive_acceleration_default at setup; assign your own default and + it is validated against y_acceleration_range before taking effect. + """ + assert self._head96_y_drive_acceleration_default is not None, ( + "96-head information not loaded; run setup()" + ) + return self._head96_y_drive_acceleration_default + + @head96_y_drive_acceleration_default.setter + def head96_y_drive_acceleration_default(self, value: float): + assert self._head96_information is not None, "96-head information not loaded; run setup()" + lo, hi = self._head96_information.y_acceleration_range + if not lo <= value <= hi: + raise ValueError(f"acceleration must be between {lo} and {hi} mm/sec**2") + self._head96_y_drive_acceleration_default = value + + @property + def head96_z_drive_speed_default(self) -> float: + """User-overridable 96-head Z-drive speed default (mm/s) a move restores the drive register to. + + Seeded from Head96Information.z_drive_speed_default at setup; assign your own default and it is + validated against z_speed_range before taking effect. + """ + assert self._head96_z_drive_speed_default is not None, ( + "96-head information not loaded; run setup()" + ) + return self._head96_z_drive_speed_default + + @head96_z_drive_speed_default.setter + def head96_z_drive_speed_default(self, value: float): + assert self._head96_information is not None, "96-head information not loaded; run setup()" + lo, hi = self._head96_information.z_speed_range + if not lo <= value <= hi: + raise ValueError(f"speed must be between {lo} and {hi} mm/sec") + self._head96_z_drive_speed_default = value + + @property + def head96_z_drive_acceleration_default(self) -> float: + """User-overridable 96-head Z-drive acceleration default (mm/s2) a move restores the register to. + + Seeded from Head96Information.z_drive_acceleration_default at setup; assign your own default and + it is validated against z_acceleration_range before taking effect. + """ + assert self._head96_z_drive_acceleration_default is not None, ( + "96-head information not loaded; run setup()" + ) + return self._head96_z_drive_acceleration_default + + @head96_z_drive_acceleration_default.setter + def head96_z_drive_acceleration_default(self, value: float): + assert self._head96_information is not None, "96-head information not loaded; run setup()" + lo, hi = self._head96_information.z_acceleration_range + if not lo <= value <= hi: + raise ValueError(f"acceleration must be between {lo} and {hi} mm/sec**2") + self._head96_z_drive_acceleration_default = value + + async def head96_request_y_speed(self) -> float: + """Request the persistent 96-head Y-drive speed (mm/s), via H0 RA (read parameter yv). + + The read counterpart of _head96_set_y_speed. + """ + resp = await self.send_command(module="H0", command="RA", ra="yv", fmt="yv#####") + return self._head96_y_drive_increment_to_mm(resp["yv"]) + + async def head96_request_y_acceleration(self) -> float: + """Request the persistent 96-head Y-drive acceleration (mm/s^2), via H0 RA (read parameter yr). + + The read counterpart of _head96_set_y_acceleration. + """ + resp = await self.send_command(module="H0", command="RA", ra="yr", fmt="yr#####") + return self._head96_y_drive_increment_to_mm(resp["yr"]) + + async def head96_request_z_speed(self) -> float: + """Request the persistent 96-head Z-drive speed (mm/s), via H0 RA (read parameter zv). + + The read counterpart of _head96_set_z_speed. + """ + resp = await self.send_command(module="H0", command="RA", ra="zv", fmt="zv#####") + return self._head96_z_drive_increment_to_mm(resp["zv"]) + + async def head96_request_z_acceleration(self) -> float: + """Request the persistent 96-head Z-drive acceleration (mm/s^2), via H0 RA (read parameter zr). + + The read counterpart of _head96_set_z_acceleration; undoes the firmware-version acceleration + scaling that the setter (and head96_move_stop_disk_z) applies. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + resp = await self.send_command(module="H0", command="RA", ra="zr", fmt="zr######") + acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 + return self._head96_z_drive_increment_to_mm(round(resp["zr"] / acceleration_multiplier)) + + @_requires_head96 + async def _head96_set_y_speed(self, speed: float): + """Set the persistent 96-head Y-drive speed (mm/s) on the device without moving. + + On-device write for troubleshooting or specialized use, not day-to-day - set + head96_y_drive_speed_default for routine control. Subsequent Y moves that don't pass their own + speed - including the C0-level 96-head commands - inherit this until it is changed or the drive + re-initialises. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + y_speed_min, y_speed_max = self._head96_information.y_speed_range + assert y_speed_min <= speed <= y_speed_max, ( + f"speed must be between {y_speed_min} and {y_speed_max} mm/sec" + ) + return await self.send_command( + module="H0", command="AA", yv=f"{self._head96_y_drive_mm_to_increment(speed):05}" + ) + + @_requires_head96 + async def _head96_set_y_acceleration(self, acceleration: float): + """Set the persistent 96-head Y-drive acceleration (mm/s^2) on the device without moving. + + On-device write for troubleshooting or specialized use, not day-to-day - set + head96_y_drive_acceleration_default for routine control. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + y_accel_min, y_accel_max = self._head96_information.y_acceleration_range + assert y_accel_min <= acceleration <= y_accel_max, ( + f"acceleration must be between {y_accel_min} and {y_accel_max} mm/sec**2" + ) + return await self.send_command( + module="H0", command="AA", yr=f"{self._head96_y_drive_mm_to_increment(acceleration):05}" + ) + + @_requires_head96 + async def _head96_set_z_speed(self, speed: float): + """Set the persistent 96-head Z-drive speed (mm/s) on the device without moving. + + On-device write for troubleshooting or specialized use, not day-to-day - set + head96_z_drive_speed_default for routine control. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + z_speed_min, z_speed_max = self._head96_information.z_speed_range + assert z_speed_min <= speed <= z_speed_max, ( + f"speed must be between {z_speed_min} and {z_speed_max} mm/sec" + ) + return await self.send_command( + module="H0", command="AA", zv=f"{self._head96_z_drive_mm_to_increment(speed):05}" + ) + + @_requires_head96 + async def _head96_set_z_acceleration(self, acceleration: float): + """Set the persistent 96-head Z-drive acceleration (mm/s^2) on the device without moving. + + On-device write for troubleshooting or specialized use, not day-to-day - set + head96_z_drive_acceleration_default for routine control. Applies the same firmware-version + acceleration scaling as head96_move_stop_disk_z (pre-2010 x0.001). + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + z_accel_min, z_accel_max = self._head96_information.z_acceleration_range + assert z_accel_min <= acceleration <= z_accel_max, ( + f"acceleration must be between {z_accel_min} and {z_accel_max} mm/sec**2" + ) + acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 + acceleration_increment = round( + self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier + ) + return await self.send_command(module="H0", command="AA", zr=f"{acceleration_increment:06}") + # Movement commands async def move_core_96_to_safe_position(self): @@ -8131,10 +8359,10 @@ async def head96_move_y( Args: y: Target Y coordinate in mm. Valid range: [93.75, 562.5] - speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]; None uses the head's - y_drive_speed_default. - acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 781.25]; None uses the - head's y_drive_acceleration_default. + speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]; None uses + head96_y_drive_speed_default. + acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 500.0 or 781.25]; None + uses head96_y_drive_acceleration_default. current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 reset_y_parameters: If True (default), reset an overridden speed/acceleration to the head's defaults after the move so it does not persist; set False to deliberately keep it. @@ -8161,13 +8389,14 @@ async def head96_move_y( restore_speed = reset_y_parameters and speed is not None restore_acceleration = reset_y_parameters and acceleration is not None if speed is None: - speed = self._head96_information.y_drive_speed_default + speed = self.head96_y_drive_speed_default if acceleration is None: - acceleration = self._head96_information.y_drive_acceleration_default + acceleration = self.head96_y_drive_acceleration_default fw_version = self._head96_information.fw_version y_min, y_max = self._head96_information.y_range y_speed_min, y_speed_max = self._head96_information.y_speed_range + y_accel_min, y_accel_max = self._head96_information.y_acceleration_range # Validate parameters before hardware communication assert y_min <= y <= y_max, f"y must be between {y_min} and {y_max} mm" @@ -8177,8 +8406,8 @@ async def head96_move_y( "If this limit seems incorrect, please test cautiously with an empty deck and report " "accurate limits + firmware to PyLabRobot: https://github.com/PyLabRobot/pylabrobot/issues" ) - assert 78.125 <= acceleration <= 781.25, ( - "acceleration must be between 78.125 and 781.25 mm/sec**2" + assert y_accel_min <= acceleration <= y_accel_max, ( + f"acceleration must be between {y_accel_min} and {y_accel_max} mm/sec**2" ) assert isinstance(current_protection_limiter, int) and ( 0 <= current_protection_limiter <= 15 @@ -8200,9 +8429,9 @@ async def head96_move_y( ) finally: if restore_speed: - await self.head96_set_y_speed(self._head96_information.y_drive_speed_default) + await self._head96_set_y_speed(self.head96_y_drive_speed_default) if restore_acceleration: - await self.head96_set_y_acceleration(self._head96_information.y_drive_acceleration_default) + await self._head96_set_y_acceleration(self.head96_y_drive_acceleration_default) @_requires_head96 async def head96_move_z( @@ -8254,10 +8483,10 @@ async def head96_move_stop_disk_z( Args: z: Target stop-disk Z in mm. Valid range: Head96Information.z_range (180.5-342.5 mm; FM-STAR extends it). - speed: Movement speed in mm/sec, [0.25, 100.0]; None uses the head's z_drive_speed_default - (85 mm/s; constant for the Z drive, not version-resolved like the Y-drive default). - acceleration: Movement acceleration in mm/sec^2, [25.0, 500.0]; None uses the head's - z_drive_acceleration_default (400 mm/s^2; likewise constant for the Z drive). + speed: Movement speed in mm/sec, [0.25, 100.0]; None uses head96_z_drive_speed_default + (seeded to 85 mm/s; constant for the Z drive, not version-resolved like the Y-drive default). + acceleration: Movement acceleration in mm/sec^2, [25.0, 500.0]; None uses + head96_z_drive_acceleration_default (seeded to 400 mm/s^2; likewise constant for the Z drive). current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 reset_z_parameters: If True (default), reset an overridden speed/acceleration to the head's defaults after the move so it does not persist; set False to deliberately keep it. @@ -8283,9 +8512,9 @@ async def head96_move_stop_disk_z( restore_speed = reset_z_parameters and speed is not None restore_acceleration = reset_z_parameters and acceleration is not None if speed is None: - speed = self._head96_information.z_drive_speed_default + speed = self.head96_z_drive_speed_default if acceleration is None: - acceleration = self._head96_information.z_drive_acceleration_default + acceleration = self.head96_z_drive_acceleration_default fw_version = self._head96_information.fw_version @@ -8340,9 +8569,9 @@ async def head96_move_stop_disk_z( raise finally: if restore_speed: - await self.head96_set_z_speed(self._head96_information.z_drive_speed_default) + await self._head96_set_z_speed(self.head96_z_drive_speed_default) if restore_acceleration: - await self.head96_set_z_acceleration(self._head96_information.z_drive_acceleration_default) + await self._head96_set_z_acceleration(self.head96_z_drive_acceleration_default) @_requires_head96 async def head96_move_tool_z(self, z: float, speed: Optional[float] = None): @@ -8387,68 +8616,6 @@ async def head96_move_tool_z(self, z: float, speed: Optional[float] = None): return await self.head96_move_stop_disk_z(z + tip_overhang, speed=speed) - @_requires_head96 - async def head96_set_y_speed(self, speed: float): - """Set the persistent 96-head Y-drive speed (mm/s) without moving, via H0 AA (save parameter yv). - - Subsequent Y moves that don't pass their own speed - including the C0-level 96-head commands - - inherit this until it is changed or the drive re-initialises. Same standalone-set mechanism as - slow_iswap (which sets the iSWAP wv/tv velocities via R0 AA). - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - y_speed_min, y_speed_max = self._head96_information.y_speed_range - assert y_speed_min <= speed <= y_speed_max, ( - f"speed must be between {y_speed_min} and {y_speed_max} mm/sec" - ) - return await self.send_command( - module="H0", command="AA", yv=f"{self._head96_y_drive_mm_to_increment(speed):05}" - ) - - @_requires_head96 - async def head96_set_y_acceleration(self, acceleration: float): - """Set the persistent 96-head Y-drive acceleration (mm/s^2) without moving, via H0 AA (save yr).""" - assert 78.125 <= acceleration <= 781.25, ( - "acceleration must be between 78.125 and 781.25 mm/sec**2" - ) - return await self.send_command( - module="H0", command="AA", yr=f"{self._head96_y_drive_mm_to_increment(acceleration):05}" - ) - - @_requires_head96 - async def head96_set_z_speed(self, speed: float): - """Set the persistent 96-head Z-drive speed (mm/s) without moving, via H0 AA (save parameter zv).""" - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - z_speed_min, z_speed_max = self._head96_information.z_speed_range - assert z_speed_min <= speed <= z_speed_max, ( - f"speed must be between {z_speed_min} and {z_speed_max} mm/sec" - ) - return await self.send_command( - module="H0", command="AA", zv=f"{self._head96_z_drive_mm_to_increment(speed):05}" - ) - - @_requires_head96 - async def head96_set_z_acceleration(self, acceleration: float): - """Set the persistent 96-head Z-drive acceleration (mm/s^2) without moving, via H0 AA (save zr). - - Applies the same firmware-version acceleration scaling as head96_move_stop_disk_z (pre-2010 x0.001). - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - z_accel_min, z_accel_max = self._head96_information.z_acceleration_range - assert z_accel_min <= acceleration <= z_accel_max, ( - f"acceleration must be between {z_accel_min} and {z_accel_max} mm/sec**2" - ) - acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 - acceleration_increment = round( - self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier - ) - return await self.send_command(module="H0", command="AA", zr=f"{acceleration_increment:06}") - # -------------- 3.10.2 Tip handling using CoRe 96 Head -------------- @need_iswap_parked @@ -9404,43 +9571,6 @@ async def _head96_probe_z_max(self) -> float: await self.send_command(module="C0", command="EV", read_timeout=20) return await self.head96_request_stop_disk_z() - async def head96_request_y_speed(self) -> float: - """Request the persistent 96-head Y-drive speed (mm/s), via H0 RA (read parameter yv). - - The read counterpart of head96_set_y_speed. - """ - resp = await self.send_command(module="H0", command="RA", ra="yv", fmt="yv#####") - return self._head96_y_drive_increment_to_mm(resp["yv"]) - - async def head96_request_y_acceleration(self) -> float: - """Request the persistent 96-head Y-drive acceleration (mm/s^2), via H0 RA (read parameter yr). - - The read counterpart of head96_set_y_acceleration. - """ - resp = await self.send_command(module="H0", command="RA", ra="yr", fmt="yr#####") - return self._head96_y_drive_increment_to_mm(resp["yr"]) - - async def head96_request_z_speed(self) -> float: - """Request the persistent 96-head Z-drive speed (mm/s), via H0 RA (read parameter zv). - - The read counterpart of head96_set_z_speed. - """ - resp = await self.send_command(module="H0", command="RA", ra="zv", fmt="zv#####") - return self._head96_z_drive_increment_to_mm(resp["zv"]) - - async def head96_request_z_acceleration(self) -> float: - """Request the persistent 96-head Z-drive acceleration (mm/s^2), via H0 RA (read parameter zr). - - The read counterpart of head96_set_z_acceleration; inverts the firmware-version acceleration - scaling that the setter (and head96_move_stop_disk_z) applies. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - resp = await self.send_command(module="H0", command="RA", ra="zr", fmt="zr######") - acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 - return self._head96_z_drive_increment_to_mm(round(resp["zr"] / acceleration_multiplier)) - async def request_core_96_head_channel_tadm_status(self): """Request CoRe 96 Head channel TADM Status diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 731401dd0e4..6b5977acb6f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -169,6 +169,7 @@ async def setup( head_type="96 head II", y_range=self._head96_resolve_y_range(fw_version), y_speed_range=self._head96_resolve_y_speed_range(fw_version), + y_acceleration_range=self._head96_resolve_y_acceleration_range(fw_version), 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), @@ -182,6 +183,15 @@ async def setup( fw_version ), ) + # Seed the user-overridable drive defaults from the frozen factory facts (mirrors STARBackend). + self._head96_y_drive_speed_default = self._head96_information.y_drive_speed_default + self._head96_y_drive_acceleration_default = ( + self._head96_information.y_drive_acceleration_default + ) + self._head96_z_drive_speed_default = self._head96_information.z_drive_speed_default + self._head96_z_drive_acceleration_default = ( + self._head96_information.z_drive_acceleration_default + ) else: self._head96_information = None From 48d114ba80823b812a983e4aaf953dc20e7a7b1d Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sat, 13 Jun 2026 18:22:53 +0100 Subject: [PATCH 06/16] `STARBackend`: backtick symbol references in 96-head drive-default docstrings Wrap symbol references (Head96Information fields, the `*_range` records, the `head96_*_default` properties, `_head96_set_*`, `head96_move_stop_disk_z`) in backticks across the 96-head drive-default docstrings, matching the convention used elsewhere in the file. Docstrings only - comments and error-message strings are left as-is. No behaviour change. Co-Authored-By: Claude Fable 5 --- .../backends/hamilton/STAR_backend.py | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 3bb52217a86..f19fec16a72 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1409,7 +1409,7 @@ class Head96Information: """Y-drive speed window (mm/s).""" y_acceleration_range: Tuple[float, float] """Y-drive acceleration window (mm/s2); the max changed across firmware (500.0 on 2008, 781.25 on - 2013+), so it is version-resolved like y_speed_range, not a constant.""" + 2013+), so it is version-resolved like `y_speed_range`, not a constant.""" z_range: Tuple[float, float] """Z-drive position window (mm); FM-STAR extends it.""" dispensing_drive_range: Tuple[float, float] @@ -1437,7 +1437,7 @@ class Head96Information: """Dispensing-drive default speed (uL/s).""" z_speed_range: Tuple[float, float] = (0.25, 100.0) """Z-drive speed window (mm/s); unchanged across the 2008/2013/2025 firmware, unlike the - version-resolved y_speed_range.""" + version-resolved `y_speed_range`.""" z_acceleration_range: Tuple[float, float] = (25.0, 500.0) """Z-drive acceleration window (mm/s2); unchanged across the 2008/2013/2025 firmware (the pre-2010 encoding differs, the physical range does not).""" @@ -8077,8 +8077,8 @@ def _head96_squeezer_drive_increment_to_mm(self, value_increments: int) -> float def head96_y_drive_speed_default(self) -> float: """User-overridable 96-head Y-drive speed default (mm/s) a move restores the drive register to. - Seeded from Head96Information.y_drive_speed_default at setup; assign your own default and it is - validated against y_speed_range before taking effect. + Seeded from `Head96Information.y_drive_speed_default` at setup; assign your own default and it is + validated against `y_speed_range` before taking effect. """ assert self._head96_y_drive_speed_default is not None, ( "96-head information not loaded; run setup()" @@ -8097,8 +8097,8 @@ def head96_y_drive_speed_default(self, value: float): def head96_y_drive_acceleration_default(self) -> float: """User-overridable 96-head Y-drive acceleration default (mm/s2) a move restores the register to. - Seeded from Head96Information.y_drive_acceleration_default at setup; assign your own default and - it is validated against y_acceleration_range before taking effect. + Seeded from `Head96Information.y_drive_acceleration_default` at setup; assign your own default and + it is validated against `y_acceleration_range` before taking effect. """ assert self._head96_y_drive_acceleration_default is not None, ( "96-head information not loaded; run setup()" @@ -8117,8 +8117,8 @@ def head96_y_drive_acceleration_default(self, value: float): def head96_z_drive_speed_default(self) -> float: """User-overridable 96-head Z-drive speed default (mm/s) a move restores the drive register to. - Seeded from Head96Information.z_drive_speed_default at setup; assign your own default and it is - validated against z_speed_range before taking effect. + Seeded from `Head96Information.z_drive_speed_default` at setup; assign your own default and it is + validated against `z_speed_range` before taking effect. """ assert self._head96_z_drive_speed_default is not None, ( "96-head information not loaded; run setup()" @@ -8137,8 +8137,8 @@ def head96_z_drive_speed_default(self, value: float): def head96_z_drive_acceleration_default(self) -> float: """User-overridable 96-head Z-drive acceleration default (mm/s2) a move restores the register to. - Seeded from Head96Information.z_drive_acceleration_default at setup; assign your own default and - it is validated against z_acceleration_range before taking effect. + Seeded from `Head96Information.z_drive_acceleration_default` at setup; assign your own default and + it is validated against `z_acceleration_range` before taking effect. """ assert self._head96_z_drive_acceleration_default is not None, ( "96-head information not loaded; run setup()" @@ -8156,7 +8156,7 @@ def head96_z_drive_acceleration_default(self, value: float): async def head96_request_y_speed(self) -> float: """Request the persistent 96-head Y-drive speed (mm/s), via H0 RA (read parameter yv). - The read counterpart of _head96_set_y_speed. + The read counterpart of `_head96_set_y_speed`. """ resp = await self.send_command(module="H0", command="RA", ra="yv", fmt="yv#####") return self._head96_y_drive_increment_to_mm(resp["yv"]) @@ -8164,7 +8164,7 @@ async def head96_request_y_speed(self) -> float: async def head96_request_y_acceleration(self) -> float: """Request the persistent 96-head Y-drive acceleration (mm/s^2), via H0 RA (read parameter yr). - The read counterpart of _head96_set_y_acceleration. + The read counterpart of `_head96_set_y_acceleration`. """ resp = await self.send_command(module="H0", command="RA", ra="yr", fmt="yr#####") return self._head96_y_drive_increment_to_mm(resp["yr"]) @@ -8172,7 +8172,7 @@ async def head96_request_y_acceleration(self) -> float: async def head96_request_z_speed(self) -> float: """Request the persistent 96-head Z-drive speed (mm/s), via H0 RA (read parameter zv). - The read counterpart of _head96_set_z_speed. + The read counterpart of `_head96_set_z_speed`. """ resp = await self.send_command(module="H0", command="RA", ra="zv", fmt="zv#####") return self._head96_z_drive_increment_to_mm(resp["zv"]) @@ -8180,8 +8180,8 @@ async def head96_request_z_speed(self) -> float: async def head96_request_z_acceleration(self) -> float: """Request the persistent 96-head Z-drive acceleration (mm/s^2), via H0 RA (read parameter zr). - The read counterpart of _head96_set_z_acceleration; undoes the firmware-version acceleration - scaling that the setter (and head96_move_stop_disk_z) applies. + The read counterpart of `_head96_set_z_acceleration`; undoes the firmware-version acceleration + scaling that the setter (and `head96_move_stop_disk_z`) applies. """ assert self._head96_information is not None, ( "requires 96-head firmware version information for safe operation" @@ -8195,7 +8195,7 @@ async def _head96_set_y_speed(self, speed: float): """Set the persistent 96-head Y-drive speed (mm/s) on the device without moving. On-device write for troubleshooting or specialized use, not day-to-day - set - head96_y_drive_speed_default for routine control. Subsequent Y moves that don't pass their own + `head96_y_drive_speed_default` for routine control. Subsequent Y moves that don't pass their own speed - including the C0-level 96-head commands - inherit this until it is changed or the drive re-initialises. """ @@ -8215,7 +8215,7 @@ async def _head96_set_y_acceleration(self, acceleration: float): """Set the persistent 96-head Y-drive acceleration (mm/s^2) on the device without moving. On-device write for troubleshooting or specialized use, not day-to-day - set - head96_y_drive_acceleration_default for routine control. + `head96_y_drive_acceleration_default` for routine control. """ assert self._head96_information is not None, ( "requires 96-head firmware version information for safe operation" @@ -8233,7 +8233,7 @@ async def _head96_set_z_speed(self, speed: float): """Set the persistent 96-head Z-drive speed (mm/s) on the device without moving. On-device write for troubleshooting or specialized use, not day-to-day - set - head96_z_drive_speed_default for routine control. + `head96_z_drive_speed_default` for routine control. """ assert self._head96_information is not None, ( "requires 96-head firmware version information for safe operation" @@ -8251,8 +8251,8 @@ async def _head96_set_z_acceleration(self, acceleration: float): """Set the persistent 96-head Z-drive acceleration (mm/s^2) on the device without moving. On-device write for troubleshooting or specialized use, not day-to-day - set - head96_z_drive_acceleration_default for routine control. Applies the same firmware-version - acceleration scaling as head96_move_stop_disk_z (pre-2010 x0.001). + `head96_z_drive_acceleration_default` for routine control. Applies the same firmware-version + acceleration scaling as `head96_move_stop_disk_z` (pre-2010 x0.001). """ assert self._head96_information is not None, ( "requires 96-head firmware version information for safe operation" @@ -8360,9 +8360,9 @@ async def head96_move_y( Args: y: Target Y coordinate in mm. Valid range: [93.75, 562.5] speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]; None uses - head96_y_drive_speed_default. + `head96_y_drive_speed_default`. acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 500.0 or 781.25]; None - uses head96_y_drive_acceleration_default. + uses `head96_y_drive_acceleration_default`. current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 reset_y_parameters: If True (default), reset an overridden speed/acceleration to the head's defaults after the move so it does not persist; set False to deliberately keep it. @@ -8483,10 +8483,10 @@ async def head96_move_stop_disk_z( Args: z: Target stop-disk Z in mm. Valid range: Head96Information.z_range (180.5-342.5 mm; FM-STAR extends it). - speed: Movement speed in mm/sec, [0.25, 100.0]; None uses head96_z_drive_speed_default + speed: Movement speed in mm/sec, [0.25, 100.0]; None uses `head96_z_drive_speed_default` (seeded to 85 mm/s; constant for the Z drive, not version-resolved like the Y-drive default). acceleration: Movement acceleration in mm/sec^2, [25.0, 500.0]; None uses - head96_z_drive_acceleration_default (seeded to 400 mm/s^2; likewise constant for the Z drive). + `head96_z_drive_acceleration_default` (seeded to 400 mm/s^2; likewise constant for the Z drive). current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 reset_z_parameters: If True (default), reset an overridden speed/acceleration to the head's defaults after the move so it does not persist; set False to deliberately keep it. From 948e82d46ad83605ef62cde20bd5a416362a9a50 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 15 Jun 2026 14:20:04 -0700 Subject: [PATCH 07/16] docs(STAR): clarify firmware-dependent 96-head Y speed/accel ranges Replace the ambiguous '390.625 or 625.0' / '500.0 or 781.25' range notation in head96_move_y with explicit firmware-keyed ranges, and note that the speed and acceleration cutoffs differ (speed flips at 2021, acceleration at 2010), matching the _head96_resolve_y_* resolvers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index f19fec16a72..c9eb18ddb42 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -8359,10 +8359,13 @@ async def head96_move_y( Args: y: Target Y coordinate in mm. Valid range: [93.75, 562.5] - speed: Movement speed in mm/sec. Valid range: [0.78125, 390.625 or 625.0]; None uses - `head96_y_drive_speed_default`. - acceleration: Movement acceleration in mm/sec**2. Valid range: [78.125, 500.0 or 781.25]; None - uses `head96_y_drive_acceleration_default`. + speed: Movement speed in mm/sec; None uses `head96_y_drive_speed_default`. The valid range is + firmware-dependent (resolved into `Head96Information.y_speed_range`): [0.78125, 390.625] + pre-2021, [0.78125, 625.0] on 2021+ firmware. + acceleration: Movement acceleration in mm/sec**2; None uses + `head96_y_drive_acceleration_default`. The valid range is firmware-dependent (resolved into + `Head96Information.y_acceleration_range`): [78.125, 500.0] pre-2010, [78.125, 781.25] on + 2013+ firmware. current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 reset_y_parameters: If True (default), reset an overridden speed/acceleration to the head's defaults after the move so it does not persist; set False to deliberately keep it. @@ -8375,10 +8378,11 @@ async def head96_move_y( AssertionError: If firmware info missing or parameters out of range. Note: - Maximum speed varies by firmware version: - - Pre-2021: 390.625 mm/sec (25,000 increments) - - 2021+: 625.0 mm/sec (40,000 increments) - The exact firmware version introducing this change is undocumented. + The maxima rose across firmware generations, and the speed and acceleration cutoffs differ: + - Speed: 390.625 mm/sec pre-2021 (25,000 increments), 625.0 mm/sec on 2021+ (40,000 + increments). The exact firmware version introducing this change is undocumented. + - Acceleration: 500.0 mm/sec**2 pre-2010 (32,000 increments), 781.25 mm/sec**2 on 2013+ + (50,000 increments). """ assert self._head96_information is not None, ( "requires 96-head firmware version information for safe operation" From 78ea87f99a249cd1bf6264933d4af0290b88ab73 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 15 Jun 2026 14:31:29 -0700 Subject: [PATCH 08/16] refactor(STAR): make 96-head Y/Z moves restore the pre-command speed/accel Remove the reset_{y,z}_parameters flags and the restore-to-PLR-default behaviour. head96_move_y and head96_move_stop_disk_z now snapshot the speed/acceleration currently on the robot (read via RA, so an external AA edit the user made is preserved), then restore those exact values after the move - skipping the AA write when the move's value already matches (compared in increments, the unit actually stored). The command is now self-contained: it leaves the persistent drive register exactly as it found it, rather than overwriting it with the PLR default. Addresses review: a move should not also mutate persistent machine state, and any restore must return the register to what was there before, not to our default. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 66 +++++++++++-------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index c9eb18ddb42..2f401405a6f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -8350,12 +8350,13 @@ async def head96_move_y( speed: Optional[float] = None, acceleration: Optional[float] = None, current_protection_limiter: int = 15, - reset_y_parameters: bool = True, ): """Move the 96-head to a specified Y-axis coordinate. - An overridden speed/acceleration persists in the drive's volatile register and is inherited by - later moves, so it is reset to the head's default afterwards unless `reset_y_parameters` is False. + A YA move writes its speed/acceleration into the drive's volatile register, where later moves + would inherit them. This command snapshots whatever speed/acceleration are on the robot before + it runs and restores them afterwards, so it leaves the persistent machine state untouched (the + restore is skipped when the move's value already matches what was there). Args: y: Target Y coordinate in mm. Valid range: [93.75, 562.5] @@ -8367,8 +8368,6 @@ async def head96_move_y( `Head96Information.y_acceleration_range`): [78.125, 500.0] pre-2010, [78.125, 781.25] on 2013+ firmware. current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 - reset_y_parameters: If True (default), reset an overridden speed/acceleration to the head's - defaults after the move so it does not persist; set False to deliberately keep it. Returns: Response from the hardware command. @@ -8388,10 +8387,6 @@ async def head96_move_y( "requires 96-head firmware version information for safe operation" ) - # Reset only what the caller overrode: None means "use the default", which already leaves the - # register at the default, so there is nothing to clean up (no churn on default moves). - restore_speed = reset_y_parameters and speed is not None - restore_acceleration = reset_y_parameters and acceleration is not None if speed is None: speed = self.head96_y_drive_speed_default if acceleration is None: @@ -8422,6 +8417,13 @@ async def head96_move_y( speed_increment = self._head96_y_drive_mm_to_increment(speed) acceleration_increment = self._head96_y_drive_mm_to_increment(acceleration) + # Snapshot what is on the robot now (read from the device, not a tracked default, so an external + # AA edit is preserved) so the move can restore it afterwards and leave the register untouched. + prev_speed = await self.head96_request_y_speed() + prev_acceleration = await self.head96_request_y_acceleration() + prev_speed_increment = self._head96_y_drive_mm_to_increment(prev_speed) + prev_acceleration_increment = self._head96_y_drive_mm_to_increment(prev_acceleration) + try: return await self.send_command( module="H0", @@ -8432,10 +8434,12 @@ async def head96_move_y( yw=f"{current_protection_limiter:02}", ) finally: - if restore_speed: - await self._head96_set_y_speed(self.head96_y_drive_speed_default) - if restore_acceleration: - await self._head96_set_y_acceleration(self.head96_y_drive_acceleration_default) + # Restore the pre-command register values, skipping the AA write where the move's value + # already matched what was there (compared in increments, the unit actually stored). + if speed_increment != prev_speed_increment: + await self._head96_set_y_speed(prev_speed) + if acceleration_increment != prev_acceleration_increment: + await self._head96_set_y_acceleration(prev_acceleration) @_requires_head96 async def head96_move_z( @@ -8471,7 +8475,6 @@ async def head96_move_stop_disk_z( speed: Optional[float] = None, acceleration: Optional[float] = None, current_protection_limiter: int = 15, - reset_z_parameters: bool = True, retract_on_crash: bool = True, ): """Move the 96-head z-drive (stop disk) to an absolute Z position in mm. @@ -8479,10 +8482,12 @@ async def head96_move_stop_disk_z( Stop-disk reference, mirroring the single-channel `move_channel_stop_disk_z`: use this for moves without a tip; for the tip end with a tip on, use `head96_move_tool_z`. - An overridden speed/acceleration persists in the drive's volatile register and would be inherited - by later moves (and C0-level commands), so it is reset to the head's default afterwards unless - `reset_z_parameters` is False. On any firmware error during the move (e.g. the head crashing into - something) the head retracts to Z-safety before the error is re-raised. + A ZA move writes its speed/acceleration into the drive's volatile register, where later + moves (and C0-level commands) would inherit them. This command snapshots whatever + speed/acceleration are on the robot before it runs and restores them afterwards, so it + leaves the persistent machine state untouched (the restore is skipped when the move's value + already matches what was there). On any firmware error during the move (e.g. the head crashing + into something) the head retracts to Z-safety before the error is re-raised. Args: z: Target stop-disk Z in mm. Valid range: Head96Information.z_range (180.5-342.5 mm; FM-STAR @@ -8492,8 +8497,6 @@ async def head96_move_stop_disk_z( acceleration: Movement acceleration in mm/sec^2, [25.0, 500.0]; None uses `head96_z_drive_acceleration_default` (seeded to 400 mm/s^2; likewise constant for the Z drive). current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 - reset_z_parameters: If True (default), reset an overridden speed/acceleration to the head's - defaults after the move so it does not persist; set False to deliberately keep it. retract_on_crash: If True (default), retract to Z-safety on any firmware error (e.g. a crash) before re-raising. head96_move_to_z_safety passes False so its own retract cannot recurse. @@ -8511,10 +8514,6 @@ async def head96_move_stop_disk_z( assert self._head96_information is not None, ( "requires 96-head firmware version information for safe operation" ) - # Reset only what the caller actually overrode: a None means "use the default", which already - # leaves the register at the default, so there is nothing to clean up (no churn on default moves). - restore_speed = reset_z_parameters and speed is not None - restore_acceleration = reset_z_parameters and acceleration is not None if speed is None: speed = self.head96_z_drive_speed_default if acceleration is None: @@ -8551,6 +8550,15 @@ async def head96_move_stop_disk_z( self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier ) + # Snapshot what is on the robot now (read from the device, not a tracked default, so an external + # AA edit is preserved) so the move can restore it afterwards and leave the register untouched. + prev_speed = await self.head96_request_z_speed() + prev_acceleration = await self.head96_request_z_acceleration() + prev_speed_increment = self._head96_z_drive_mm_to_increment(prev_speed) + prev_acceleration_increment = round( + self._head96_z_drive_mm_to_increment(prev_acceleration) * acceleration_multiplier + ) + try: return await self.send_command( module="H0", @@ -8572,10 +8580,12 @@ async def head96_move_stop_disk_z( pass # retract failed too; surface the original error below raise finally: - if restore_speed: - await self._head96_set_z_speed(self.head96_z_drive_speed_default) - if restore_acceleration: - await self._head96_set_z_acceleration(self.head96_z_drive_acceleration_default) + # Restore the pre-command register values, skipping the AA write where the move's value + # already matched what was there (compared in increments, the unit actually stored). + if speed_increment != prev_speed_increment: + await self._head96_set_z_speed(prev_speed) + if acceleration_increment != prev_acceleration_increment: + await self._head96_set_z_acceleration(prev_acceleration) @_requires_head96 async def head96_move_tool_z(self, z: float, speed: Optional[float] = None): From dd4f78e6f24946c0732e6bc63c05f647949e4f21 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 15 Jun 2026 15:03:27 -0700 Subject: [PATCH 09/16] test(STAR): stub 96-head Z speed/accel reads in crash-recovery tests head96_move_stop_disk_z now snapshots the current Z speed/accel (to restore after the move) via head96_request_z_speed/head96_request_z_acceleration. The crash-recovery tests mock send_command to only answer ZA, so those reads hit a bare {} response and raised KeyError: 'zv'. Mock the two request methods with in-range values so the tests exercise only the ZA crash-retract path. Co-Authored-By: Claude Opus 4.8 --- pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index caae087c75a..a73ef2dda69 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -492,6 +492,10 @@ async def asyncSetUp(self): z_min, z_max = self.cb._head96_information.z_range self.z_target = round((z_min + z_max) / 2, 1) self.z_safety_za = f"{self.cb._head96_z_drive_mm_to_increment(z_max):05}" + # head96_move_stop_disk_z snapshots the current Z speed/accel (to restore after the move); these + # tests only exercise the ZA crash-retract path, so stub the reads with in-range values. + self.cb.head96_request_z_speed = unittest.mock.AsyncMock(return_value=85.0) + self.cb.head96_request_z_acceleration = unittest.mock.AsyncMock(return_value=400.0) def _crash(self, message): return STARFirmwareError( From e1bdd0968f4b4cd4258f56a702d2492bfbbf60a4 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 15 Jun 2026 15:03:27 -0700 Subject: [PATCH 10/16] docs(STAR): blank line before head96_move_y firmware-cutoff bullet list reStructuredText needs a blank line between a paragraph ending in ':' and the following bullet list; without it docutils raised "Unexpected indentation", failing the warnings-as-errors docs build. Co-Authored-By: Claude Opus 4.8 --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 2f401405a6f..1cc6d3fb342 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -8378,6 +8378,7 @@ async def head96_move_y( Note: The maxima rose across firmware generations, and the speed and acceleration cutoffs differ: + - Speed: 390.625 mm/sec pre-2021 (25,000 increments), 625.0 mm/sec on 2021+ (40,000 increments). The exact firmware version introducing this change is undocumented. - Acceleration: 500.0 mm/sec**2 pre-2010 (32,000 increments), 781.25 mm/sec**2 on 2013+ From 4e04e01cdeffae7130b80b971e521beb3b76d9c7 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 15 Jun 2026 22:44:43 -0700 Subject: [PATCH 11/16] refactor(STAR): make 96-head drive defaults override-only, fix stale docs After moves were changed to snapshot and restore the live drive register, the `head96_*_drive_*_default` values are no longer "what a move restores to" - they are just the default speed/accel a move uses when the caller passes none. Update the now-wrong comments/docstrings accordingly. Also stop copying the frozen Head96Information factory defaults into mutable backing fields at setup. The frozen dataclass holds the immutable factory defaults; the backend keeps an Optional user override (None = use factory). Each getter returns the override if set, else falls back to Head96Information. This drops the redundant seeding block and keeps the user-override + range-check. Co-Authored-By: Claude Opus 4.8 --- .../backends/hamilton/STAR_backend.py | 83 +++++++++---------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 1cc6d3fb342..adefcbe81c4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1629,9 +1629,9 @@ def __init__( # `set_up_iswap` from firmware/EEPROM. See `iswap_information` property # for guarded access. None pre-setup; immutable post-setup. self._iswap_information: Optional[iSWAPInformation] = None - # User-overridable 96-head Y/Z drive speed/acceleration defaults, exposed via @property with a - # range check in the setter. Seeded from the Head96Information factory defaults at setup; a move - # restores the drive register to these afterwards. None pre-setup. + # Optional user overrides for the 96-head Y/Z drive speed/acceleration defaults, exposed via + # @property with a range check in the setter. None means "use the firmware-resolved factory + # default from Head96Information"; a move falls back to that default when no value is passed. self._head96_y_drive_speed_default: Optional[float] = None self._head96_y_drive_acceleration_default: Optional[float] = None self._head96_z_drive_speed_default: Optional[float] = None @@ -2154,16 +2154,6 @@ async def set_up_core96_head(): fw_version ), ) - # Seed the user-overridable drive defaults from the frozen factory facts (assign the backing - # fields directly, not through the validating properties: the factory values are in range). - self._head96_y_drive_speed_default = self._head96_information.y_drive_speed_default - self._head96_y_drive_acceleration_default = ( - self._head96_information.y_drive_acceleration_default - ) - self._head96_z_drive_speed_default = self._head96_information.z_drive_speed_default - self._head96_z_drive_acceleration_default = ( - self._head96_information.z_drive_acceleration_default - ) async def set_up_arm_modules(): await set_up_pip() @@ -8069,21 +8059,23 @@ def _head96_squeezer_drive_increment_to_mm(self, value_increments: int) -> float """Convert squeezer drive hardware increments to mm for 96-head.""" return round(value_increments * self._head96_squeezer_drive_mm_per_increment, 2) - # User-overridable drive defaults. Each is seeded from the frozen Head96Information factory default - # at setup and restored after a move; the setter range-checks against Head96Information so a user - # can set their own default safely. + # Default drive speed/acceleration a move uses when the caller passes none. Each getter returns the + # user override if one was set, otherwise the firmware-resolved Head96Information factory default; + # the setter range-checks against Head96Information so a user can set their own default safely. A + # move does not persist these to the drive - it snapshots the live register and restores it after. @property def head96_y_drive_speed_default(self) -> float: - """User-overridable 96-head Y-drive speed default (mm/s) a move restores the drive register to. + """Default 96-head Y-drive speed (mm/s) used when a Y move is called without an explicit speed. - Seeded from `Head96Information.y_drive_speed_default` at setup; assign your own default and it is - validated against `y_speed_range` before taking effect. + Falls back to the firmware-resolved `Head96Information.y_drive_speed_default`; assign your own and + it is validated against `y_speed_range` before taking effect. A move does not persist this to the + drive: it snapshots the live register and restores it afterwards. """ - assert self._head96_y_drive_speed_default is not None, ( - "96-head information not loaded; run setup()" - ) - return self._head96_y_drive_speed_default + assert self._head96_information is not None, "96-head information not loaded; run setup()" + if self._head96_y_drive_speed_default is not None: + return self._head96_y_drive_speed_default + return self._head96_information.y_drive_speed_default @head96_y_drive_speed_default.setter def head96_y_drive_speed_default(self, value: float): @@ -8095,15 +8087,16 @@ def head96_y_drive_speed_default(self, value: float): @property def head96_y_drive_acceleration_default(self) -> float: - """User-overridable 96-head Y-drive acceleration default (mm/s2) a move restores the register to. + """Default 96-head Y-drive acceleration (mm/s2) used when a Y move is called without one. - Seeded from `Head96Information.y_drive_acceleration_default` at setup; assign your own default and - it is validated against `y_acceleration_range` before taking effect. + Falls back to the firmware-resolved `Head96Information.y_drive_acceleration_default`; assign your + own and it is validated against `y_acceleration_range` before taking effect. A move does not + persist this to the drive: it snapshots the live register and restores it afterwards. """ - assert self._head96_y_drive_acceleration_default is not None, ( - "96-head information not loaded; run setup()" - ) - return self._head96_y_drive_acceleration_default + assert self._head96_information is not None, "96-head information not loaded; run setup()" + if self._head96_y_drive_acceleration_default is not None: + return self._head96_y_drive_acceleration_default + return self._head96_information.y_drive_acceleration_default @head96_y_drive_acceleration_default.setter def head96_y_drive_acceleration_default(self, value: float): @@ -8115,15 +8108,16 @@ def head96_y_drive_acceleration_default(self, value: float): @property def head96_z_drive_speed_default(self) -> float: - """User-overridable 96-head Z-drive speed default (mm/s) a move restores the drive register to. + """Default 96-head Z-drive speed (mm/s) used when a Z move is called without an explicit speed. - Seeded from `Head96Information.z_drive_speed_default` at setup; assign your own default and it is - validated against `z_speed_range` before taking effect. + Falls back to the firmware-resolved `Head96Information.z_drive_speed_default`; assign your own and + it is validated against `z_speed_range` before taking effect. A move does not persist this to the + drive: it snapshots the live register and restores it afterwards. """ - assert self._head96_z_drive_speed_default is not None, ( - "96-head information not loaded; run setup()" - ) - return self._head96_z_drive_speed_default + assert self._head96_information is not None, "96-head information not loaded; run setup()" + if self._head96_z_drive_speed_default is not None: + return self._head96_z_drive_speed_default + return self._head96_information.z_drive_speed_default @head96_z_drive_speed_default.setter def head96_z_drive_speed_default(self, value: float): @@ -8135,15 +8129,16 @@ def head96_z_drive_speed_default(self, value: float): @property def head96_z_drive_acceleration_default(self) -> float: - """User-overridable 96-head Z-drive acceleration default (mm/s2) a move restores the register to. + """Default 96-head Z-drive acceleration (mm/s2) used when a Z move is called without one. - Seeded from `Head96Information.z_drive_acceleration_default` at setup; assign your own default and - it is validated against `z_acceleration_range` before taking effect. + Falls back to the firmware-resolved `Head96Information.z_drive_acceleration_default`; assign your + own and it is validated against `z_acceleration_range` before taking effect. A move does not + persist this to the drive: it snapshots the live register and restores it afterwards. """ - assert self._head96_z_drive_acceleration_default is not None, ( - "96-head information not loaded; run setup()" - ) - return self._head96_z_drive_acceleration_default + assert self._head96_information is not None, "96-head information not loaded; run setup()" + if self._head96_z_drive_acceleration_default is not None: + return self._head96_z_drive_acceleration_default + return self._head96_information.z_drive_acceleration_default @head96_z_drive_acceleration_default.setter def head96_z_drive_acceleration_default(self, value: float): From 7229f0c71e826b359d53ff95d0b9440c75fae2c8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 15 Jun 2026 23:06:43 -0700 Subject: [PATCH 12/16] refactor(STAR): compute 96-head drive defaults as Head96Information properties The per-drive factory defaults were resolved by backend `_head96_resolve_*_default` methods and stored as Head96Information fields. They depend only on fw_version and the encoder resolutions already on the dataclass, so make them computed @property methods on Head96Information instead and drop the resolve methods and stored fields. Ranges stay resolved-and-stored: z_range's max comes from a hardware probe at setup, so they cannot all be pure properties. Co-Authored-By: Claude Opus 4.8 --- .../backends/hamilton/STAR_backend.py | 108 ++++++++---------- .../backends/hamilton/STAR_chatterbox.py | 9 -- .../backends/hamilton/STAR_tests.py | 28 +++-- 3 files changed, 68 insertions(+), 77 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index adefcbe81c4..864209ae9f5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1416,25 +1416,7 @@ 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).""" + z_speed_range: Tuple[float, float] = (0.25, 100.0) """Z-drive speed window (mm/s); unchanged across the 2008/2013/2025 firmware, unlike the version-resolved `y_speed_range`.""" @@ -1450,6 +1432,52 @@ class Head96Information: dispensing_drive_uL_per_increment: float = 0.019340933 squeezer_drive_mm_per_increment: float = 0.0002086672009 + # === Per-drive factory default speed / acceleration (standard units). === + @property + def y_drive_speed_default(self) -> float: + """Y-drive default speed (mm/s); 2013 firmware raised it.""" + increments = 25000 if self.fw_version.year >= 2010 else 20000 + return round(increments * self.y_drive_mm_per_increment, 2) + + @property + def y_drive_acceleration_default(self) -> float: + """Y-drive default acceleration (mm/s2); 2013 firmware raised it.""" + increments = 35000 if self.fw_version.year >= 2010 else 32000 + return round(increments * self.y_drive_mm_per_increment, 2) + + @property + def z_drive_speed_default(self) -> float: + """Z-drive default speed (mm/s); constant across firmware.""" + return 85.0 + + @property + def z_drive_acceleration_default(self) -> float: + """Z-drive default acceleration (mm/s2); constant across firmware.""" + return 400.0 + + @property + def dispensing_drive_speed_default(self) -> float: + """Dispensing-drive default speed (uL/s); constant across firmware.""" + return 261.1 + + @property + def dispensing_drive_acceleration_default(self) -> float: + """Dispensing-drive default acceleration (uL/s2); 2013 firmware raised it.""" + increments = 900000 if self.fw_version.year >= 2010 else 150000 + return round(increments * self.dispensing_drive_uL_per_increment, 2) + + @property + def squeezer_drive_speed_default(self) -> float: + """Squeezer-drive default speed (mm/s); 2013 firmware raised it.""" + increments = 76000 if self.fw_version.year >= 2010 else 16000 + return round(increments * self.squeezer_drive_mm_per_increment, 2) + + @property + def squeezer_drive_acceleration_default(self) -> float: + """Squeezer-drive default acceleration (mm/s2); 2013 firmware raised it.""" + increments = 300000 if self.fw_version.year >= 2010 else 100000 + return round(increments * self.squeezer_drive_mm_per_increment, 2) + @dataclass(frozen=True, eq=False) class iSWAPInformation: @@ -2140,19 +2168,6 @@ 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(): @@ -7870,35 +7885,6 @@ 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 6b5977acb6f..89de75e8baa 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -173,15 +173,6 @@ 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 - ), ) # Seed the user-overridable drive defaults from the frozen factory facts (mirrors STARBackend). self._head96_y_drive_speed_default = self._head96_information.y_drive_speed_default diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index a73ef2dda69..56b91de246c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -38,6 +38,7 @@ CommandSyntaxError, HamiltonNoTipError, HardwareError, + Head96Information, PipChannelInformation, STARBackend, STARFirmwareError, @@ -471,13 +472,26 @@ async def test_setup_resolves_all_drive_defaults(self): 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 - ) + + def info(fw_version: datetime.date) -> Head96Information: + # Only fw_version drives the computed default; the rest are placeholder facts. + return Head96Information( + fw_version=fw_version, + x_offset=0.0, + supports_clot_monitoring_clld=False, + stop_disc_type="core_ii", + instrument_type="FM-STAR", + head_type="96 head II", + y_range=(0.0, 0.0), + y_speed_range=(0.0, 0.0), + y_acceleration_range=(0.0, 0.0), + z_range=(0.0, 0.0), + dispensing_drive_range=(0.0, 0.0), + dispensing_drive_speed_range=(0.0, 0.0), + ) + + self.assertAlmostEqual(info(datetime.date(2008, 11, 11)).y_drive_speed_default, 312.5, places=2) + self.assertAlmostEqual(info(datetime.date(2013, 9, 2)).y_drive_speed_default, 390.62, places=2) class TestHead96CrashRecovery(unittest.IsolatedAsyncioTestCase): From db74495da91def6ba595ae1efdfaee46fd40ffdf Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 18 Jun 2026 15:24:04 +0100 Subject: [PATCH 13/16] y-first organisation --- .../backends/hamilton/STAR_backend.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 864209ae9f5..6114f526cdf 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -7985,16 +7985,6 @@ async def head96_dispensing_drive_and_squeezer_driver_initialize( _head96_dispensing_drive_uL_per_increment = Head96Information.dispensing_drive_uL_per_increment _head96_squeezer_drive_mm_per_increment = Head96Information.squeezer_drive_mm_per_increment - # Z-axis conversions - - def _head96_z_drive_mm_to_increment(self, value_mm: float) -> int: - """Convert mm to Z-axis hardware increments for 96-head.""" - return round(value_mm / self._head96_z_drive_mm_per_increment) - - def _head96_z_drive_increment_to_mm(self, value_increments: int) -> float: - """Convert Z-axis hardware increments to mm for 96-head.""" - return round(value_increments * self._head96_z_drive_mm_per_increment, 2) - # Y-axis conversions def _head96_y_drive_mm_to_increment(self, value_mm: float) -> int: @@ -8005,6 +7995,16 @@ def _head96_y_drive_increment_to_mm(self, value_increments: int) -> float: """Convert Y-axis hardware increments to mm for 96-head.""" return round(value_increments * self._head96_y_drive_mm_per_increment, 2) + # Z-axis conversions + + def _head96_z_drive_mm_to_increment(self, value_mm: float) -> int: + """Convert mm to Z-axis hardware increments for 96-head.""" + return round(value_mm / self._head96_z_drive_mm_per_increment) + + def _head96_z_drive_increment_to_mm(self, value_increments: int) -> float: + """Convert Z-axis hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_z_drive_mm_per_increment, 2) + # Dispensing drive conversions (mm and uL) def _head96_dispensing_drive_mm_to_increment(self, value_mm: float) -> int: From d22cb5177bb28e94861b159d3ba510c90cfe01ae Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 18 Jun 2026 23:09:26 -0700 Subject: [PATCH 14/16] refactor(STAR): expose 96-head firmware-derived windows as properties The Y and dispensing-drive area-of-operation windows are pure functions of fw_version and the encoder resolutions, so compute them as Head96Information properties (mirroring the drive-default properties) instead of resolving them at setup. Drops the five _head96_resolve_* helpers and their constructor kwargs. z_range stays a setup field since its max is a hardware probe. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 111 ++++++++---------- .../backends/hamilton/STAR_chatterbox.py | 5 - .../backends/hamilton/STAR_tests.py | 5 - 3 files changed, 50 insertions(+), 71 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 6114f526cdf..e0930eb8d5e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1402,20 +1402,12 @@ class Head96Information: instrument_type: InstrumentType head_type: HeadType - # === Firmware/variant-derived limits, in standard units (resolved at setup) === - y_range: Tuple[float, float] - """Y-drive position window (mm).""" - y_speed_range: Tuple[float, float] - """Y-drive speed window (mm/s).""" - y_acceleration_range: Tuple[float, float] - """Y-drive acceleration window (mm/s2); the max changed across firmware (500.0 on 2008, 781.25 on - 2013+), so it is version-resolved like `y_speed_range`, not a constant.""" + # === Firmware/variant-derived limits. z_range is set at setup because its max is a hardware + # probe; the Y and dispensing-drive windows are pure functions of fw_version (and the encoder + # resolutions below), so they are exposed as properties rather than stored. === z_range: Tuple[float, float] - """Z-drive position window (mm); FM-STAR extends it.""" - dispensing_drive_range: Tuple[float, float] - """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).""" + """Z-drive position window (mm); FM-STAR extends it. Set at setup: the min is variant-derived, + the max is read from a hardware probe.""" z_speed_range: Tuple[float, float] = (0.25, 100.0) """Z-drive speed window (mm/s); unchanged across the 2008/2013/2025 firmware, unlike the @@ -1432,6 +1424,51 @@ class Head96Information: dispensing_drive_uL_per_increment: float = 0.019340933 squeezer_drive_mm_per_increment: float = 0.0002086672009 + # === Firmware/variant-derived area-of-operation windows (standard units). Pure functions of + # fw_version and the encoder resolutions above, so they are computed on access. === + @property + def y_range(self) -> Tuple[float, float]: + """Y-drive position window (mm); 2013 firmware shifted it from the 2008 range.""" + min_inc, max_inc = (6000, 36000) if self.fw_version.year >= 2010 else (7000, 36200) + return ( + round(min_inc * self.y_drive_mm_per_increment, 2), + round(max_inc * self.y_drive_mm_per_increment, 2), + ) + + @property + def y_speed_range(self) -> Tuple[float, float]: + """Y-drive speed window (mm/s). The pre-2021 max (390.625 = the firmware default, 25000 inc) is + an empirical, deck-tested cap; per firmware version the maxima are 312.5 (2008) and 625 (2013+). + Verify on a pre-2021 head before raising it.""" + return (0.78125, 390.625 if self.fw_version.year <= 2021 else 625.0) + + @property + def y_acceleration_range(self) -> Tuple[float, float]: + """Y-drive acceleration window (mm/s2). The min (5000 inc) is constant; the max rose from 32000 + inc (2008) to 50000 inc (2013+), so it tracks firmware like the Y range / speed.""" + max_inc = 50000 if self.fw_version.year >= 2010 else 32000 + return ( + round(5000 * self.y_drive_mm_per_increment, 2), + round(max_inc * self.y_drive_mm_per_increment, 2), + ) + + @property + def dispensing_drive_range(self) -> Tuple[float, float]: + """Aspirate/dispense piston volume window (uL); applies to both aspirate and dispense. 2013 + firmware widened the max from 62130 inc.""" + max_inc = 64350 if self.fw_version.year >= 2010 else 62130 + return (0.0, round(max_inc * self.dispensing_drive_uL_per_increment, 2)) + + @property + def dispensing_drive_speed_range(self) -> Tuple[float, float]: + """Dispensing-drive speed window (uL/s); 2013 firmware widened the max from 52000 inc.""" + min_inc = 5 # firmware dv minimum (00005 increments/second) + max_inc = 55000 if self.fw_version.year >= 2010 else 52000 + return ( + round(min_inc * self.dispensing_drive_uL_per_increment, 2), + round(max_inc * self.dispensing_drive_uL_per_increment, 2), + ) + # === Per-drive factory default speed / acceleration (standard units). === @property def y_drive_speed_default(self) -> float: @@ -2156,18 +2193,11 @@ async def set_up_core96_head(): stop_disc_type="core_i" if configuration_96head[1] == "0" else "core_ii", instrument_type=instrument_type, head_type=head96_type, - y_range=self._head96_resolve_y_range(fw_version), - y_speed_range=self._head96_resolve_y_speed_range(fw_version), - y_acceleration_range=self._head96_resolve_y_acceleration_range(fw_version), # probing safe max z position also acts a safety retraction of the head96 on every setup call z_range=( self._head96_resolve_z_range(instrument_type)[0], await self._head96_probe_z_max(), ), - dispensing_drive_range=self._head96_resolve_dispensing_drive_range(fw_version), - dispensing_drive_speed_range=self._head96_resolve_dispensing_drive_speed_range( - fw_version - ), ) async def set_up_arm_modules(): @@ -7836,29 +7866,6 @@ async def head96_request_type(self) -> Head96Information.HeadType: resp = await self.send_command(module="H0", command="QG", fmt="qg#") return type_map.get(resp["qg"], "unknown") - def _head96_resolve_y_range(self, fw_version: datetime.date) -> Tuple[float, float]: - """Y-drive position window (mm); 2013 firmware shifted it from the 2008 range.""" - min_inc, max_inc = (6000, 36000) if fw_version.year >= 2010 else (7000, 36200) - return ( - self._head96_y_drive_increment_to_mm(min_inc), - self._head96_y_drive_increment_to_mm(max_inc), - ) - - def _head96_resolve_y_speed_range(self, fw_version: datetime.date) -> Tuple[float, float]: - """Y-drive speed window (mm/s). The pre-2021 max (390.625 = the firmware default, 25000 inc) is - an empirical, deck-tested cap; per firmware version the maxima are 312.5 (2008) and 625 (2013+). - Verify on a pre-2021 head before raising it. Refactored verbatim from head96_move_y.""" - return (0.78125, 390.625 if fw_version.year <= 2021 else 625.0) - - def _head96_resolve_y_acceleration_range(self, fw_version: datetime.date) -> Tuple[float, float]: - """Y-drive acceleration window (mm/s2). The min (5000 inc) is constant; the max rose from 32000 - inc (2008) to 50000 inc (2013+), so it is resolved per firmware like the Y range / speed.""" - max_inc = 50000 if fw_version.year >= 2010 else 32000 - return ( - self._head96_y_drive_increment_to_mm(5000), - self._head96_y_drive_increment_to_mm(max_inc), - ) - def _head96_resolve_z_range(self, instrument_type: str) -> Tuple[float, float]: """Z-drive position window (mm); FM-STAR extends it (za/zb/zh all share this range).""" min_inc, max_inc = (24200, 76200) if instrument_type == "FM-STAR" else (36100, 68500) @@ -7867,24 +7874,6 @@ def _head96_resolve_z_range(self, instrument_type: str) -> Tuple[float, float]: self._head96_z_drive_increment_to_mm(max_inc), ) - def _head96_resolve_dispensing_drive_range( - self, fw_version: datetime.date - ) -> Tuple[float, float]: - """Aspirate/dispense piston volume window (uL); 2013 firmware widened the max from 62130 inc.""" - max_inc = 64350 if fw_version.year >= 2010 else 62130 - return (0.0, self._head96_dispensing_drive_increment_to_uL(max_inc)) - - def _head96_resolve_dispensing_drive_speed_range( - self, fw_version: datetime.date - ) -> Tuple[float, float]: - """Dispensing-drive speed window (uL/s); 2013 firmware widened the max from 52000 inc.""" - min_inc = 5 # firmware dv minimum (00005 increments/second) - max_inc = 55000 if fw_version.year >= 2010 else 52000 - return ( - self._head96_dispensing_drive_increment_to_uL(min_inc), - self._head96_dispensing_drive_increment_to_uL(max_inc), - ) - # -------------- 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 89de75e8baa..73f7bf33d5d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -167,12 +167,7 @@ async def setup( stop_disc_type="core_ii", instrument_type=instrument_type, head_type="96 head II", - y_range=self._head96_resolve_y_range(fw_version), - y_speed_range=self._head96_resolve_y_speed_range(fw_version), - y_acceleration_range=self._head96_resolve_y_acceleration_range(fw_version), 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), ) # Seed the user-overridable drive defaults from the frozen factory facts (mirrors STARBackend). self._head96_y_drive_speed_default = self._head96_information.y_drive_speed_default diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 56b91de246c..8573c31bbc3 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -482,12 +482,7 @@ def info(fw_version: datetime.date) -> Head96Information: stop_disc_type="core_ii", instrument_type="FM-STAR", head_type="96 head II", - y_range=(0.0, 0.0), - y_speed_range=(0.0, 0.0), - y_acceleration_range=(0.0, 0.0), z_range=(0.0, 0.0), - dispensing_drive_range=(0.0, 0.0), - dispensing_drive_speed_range=(0.0, 0.0), ) self.assertAlmostEqual(info(datetime.date(2008, 11, 11)).y_drive_speed_default, 312.5, places=2) From c4380440e8ed59483c8e4a631bc01b19b3570ff1 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 18 Jun 2026 23:32:17 -0700 Subject: [PATCH 15/16] refactor(STAR): load 96-head Y/Z drive defaults from the machine at setup The Y/Z drive speed/acceleration defaults were computed as frozen Head96Information properties from fw_version. Move them to mutable STARBackend attributes seeded from the machine's registers at setup (via head96_request_*), so a run can override them through the existing range-checked setters. The backend getters drop the frozen fallback; the chatterbox overrides the four register reads with canned 2013+ factory values. Dispensing/squeezer factory defaults stay on the frozen record (no machine read yet; wired by the dispense work). Also rewrites the crash-recovery tests to use mock sequencing/introspection instead of hand-rolled call counters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 90 ++++++++---------- .../backends/hamilton/STAR_chatterbox.py | 29 ++++-- .../backends/hamilton/STAR_tests.py | 91 ++++++++----------- 3 files changed, 93 insertions(+), 117 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index e0930eb8d5e..dfc5816a12b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1469,29 +1469,9 @@ def dispensing_drive_speed_range(self) -> Tuple[float, float]: round(max_inc * self.dispensing_drive_uL_per_increment, 2), ) - # === Per-drive factory default speed / acceleration (standard units). === - @property - def y_drive_speed_default(self) -> float: - """Y-drive default speed (mm/s); 2013 firmware raised it.""" - increments = 25000 if self.fw_version.year >= 2010 else 20000 - return round(increments * self.y_drive_mm_per_increment, 2) - - @property - def y_drive_acceleration_default(self) -> float: - """Y-drive default acceleration (mm/s2); 2013 firmware raised it.""" - increments = 35000 if self.fw_version.year >= 2010 else 32000 - return round(increments * self.y_drive_mm_per_increment, 2) - - @property - def z_drive_speed_default(self) -> float: - """Z-drive default speed (mm/s); constant across firmware.""" - return 85.0 - - @property - def z_drive_acceleration_default(self) -> float: - """Z-drive default acceleration (mm/s2); constant across firmware.""" - return 400.0 - + # === Per-drive factory default speed / acceleration (standard units). The Y/Z-drive defaults are + # deliberately not kept here: STARBackend reads them from the machine at setup into mutable + # attributes (head96_{y,z}_drive_{speed,acceleration}_default) so a run can override them. === @property def dispensing_drive_speed_default(self) -> float: """Dispensing-drive default speed (uL/s); constant across firmware.""" @@ -1694,9 +1674,9 @@ def __init__( # `set_up_iswap` from firmware/EEPROM. See `iswap_information` property # for guarded access. None pre-setup; immutable post-setup. self._iswap_information: Optional[iSWAPInformation] = None - # Optional user overrides for the 96-head Y/Z drive speed/acceleration defaults, exposed via - # @property with a range check in the setter. None means "use the firmware-resolved factory - # default from Head96Information"; a move falls back to that default when no value is passed. + # Mutable 96-head Y/Z drive speed/acceleration defaults, seeded from the machine's registers at + # setup and overridable via the @property setters (range-checked). None until setup() loads them; + # a move uses the current default when no explicit value is passed. self._head96_y_drive_speed_default: Optional[float] = None self._head96_y_drive_acceleration_default: Optional[float] = None self._head96_z_drive_speed_default: Optional[float] = None @@ -2199,6 +2179,12 @@ async def set_up_core96_head(): await self._head96_probe_z_max(), ), ) + # Seed the mutable Y/Z drive speed/acceleration defaults from the machine's current + # registers; a run can override them via the head96_*_drive_*_default setters afterwards. + self._head96_y_drive_speed_default = await self.head96_request_y_speed() + self._head96_y_drive_acceleration_default = await self.head96_request_y_acceleration() + self._head96_z_drive_speed_default = await self.head96_request_z_speed() + self._head96_z_drive_acceleration_default = await self.head96_request_z_acceleration() async def set_up_arm_modules(): await set_up_pip() @@ -8043,14 +8029,12 @@ def _head96_squeezer_drive_increment_to_mm(self, value_increments: int) -> float def head96_y_drive_speed_default(self) -> float: """Default 96-head Y-drive speed (mm/s) used when a Y move is called without an explicit speed. - Falls back to the firmware-resolved `Head96Information.y_drive_speed_default`; assign your own and - it is validated against `y_speed_range` before taking effect. A move does not persist this to the - drive: it snapshots the live register and restores it afterwards. + Seeded from the machine at setup; assign your own and it is validated against `y_speed_range` + before taking effect. A move does not persist this to the drive: it snapshots the live register + and restores it afterwards. """ - assert self._head96_information is not None, "96-head information not loaded; run setup()" - if self._head96_y_drive_speed_default is not None: - return self._head96_y_drive_speed_default - return self._head96_information.y_drive_speed_default + assert self._head96_y_drive_speed_default is not None, "96-head defaults not loaded; run setup()" + return self._head96_y_drive_speed_default @head96_y_drive_speed_default.setter def head96_y_drive_speed_default(self, value: float): @@ -8064,14 +8048,14 @@ def head96_y_drive_speed_default(self, value: float): def head96_y_drive_acceleration_default(self) -> float: """Default 96-head Y-drive acceleration (mm/s2) used when a Y move is called without one. - Falls back to the firmware-resolved `Head96Information.y_drive_acceleration_default`; assign your - own and it is validated against `y_acceleration_range` before taking effect. A move does not - persist this to the drive: it snapshots the live register and restores it afterwards. + Seeded from the machine at setup; assign your own and it is validated against + `y_acceleration_range` before taking effect. A move does not persist this to the drive: it + snapshots the live register and restores it afterwards. """ - assert self._head96_information is not None, "96-head information not loaded; run setup()" - if self._head96_y_drive_acceleration_default is not None: - return self._head96_y_drive_acceleration_default - return self._head96_information.y_drive_acceleration_default + assert ( + self._head96_y_drive_acceleration_default is not None + ), "96-head defaults not loaded; run setup()" + return self._head96_y_drive_acceleration_default @head96_y_drive_acceleration_default.setter def head96_y_drive_acceleration_default(self, value: float): @@ -8085,14 +8069,12 @@ def head96_y_drive_acceleration_default(self, value: float): def head96_z_drive_speed_default(self) -> float: """Default 96-head Z-drive speed (mm/s) used when a Z move is called without an explicit speed. - Falls back to the firmware-resolved `Head96Information.z_drive_speed_default`; assign your own and - it is validated against `z_speed_range` before taking effect. A move does not persist this to the - drive: it snapshots the live register and restores it afterwards. + Seeded from the machine at setup; assign your own and it is validated against `z_speed_range` + before taking effect. A move does not persist this to the drive: it snapshots the live register + and restores it afterwards. """ - assert self._head96_information is not None, "96-head information not loaded; run setup()" - if self._head96_z_drive_speed_default is not None: - return self._head96_z_drive_speed_default - return self._head96_information.z_drive_speed_default + assert self._head96_z_drive_speed_default is not None, "96-head defaults not loaded; run setup()" + return self._head96_z_drive_speed_default @head96_z_drive_speed_default.setter def head96_z_drive_speed_default(self, value: float): @@ -8106,14 +8088,14 @@ def head96_z_drive_speed_default(self, value: float): def head96_z_drive_acceleration_default(self) -> float: """Default 96-head Z-drive acceleration (mm/s2) used when a Z move is called without one. - Falls back to the firmware-resolved `Head96Information.z_drive_acceleration_default`; assign your - own and it is validated against `z_acceleration_range` before taking effect. A move does not - persist this to the drive: it snapshots the live register and restores it afterwards. + Seeded from the machine at setup; assign your own and it is validated against + `z_acceleration_range` before taking effect. A move does not persist this to the drive: it + snapshots the live register and restores it afterwards. """ - assert self._head96_information is not None, "96-head information not loaded; run setup()" - if self._head96_z_drive_acceleration_default is not None: - return self._head96_z_drive_acceleration_default - return self._head96_information.z_drive_acceleration_default + assert ( + self._head96_z_drive_acceleration_default is not None + ), "96-head defaults not loaded; run setup()" + return self._head96_z_drive_acceleration_default @head96_z_drive_acceleration_default.setter def head96_z_drive_acceleration_default(self, value: float): diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 73f7bf33d5d..96e9e0e7404 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -169,15 +169,12 @@ async def setup( head_type="96 head II", z_range=self._head96_resolve_z_range(instrument_type), ) - # Seed the user-overridable drive defaults from the frozen factory facts (mirrors STARBackend). - self._head96_y_drive_speed_default = self._head96_information.y_drive_speed_default - self._head96_y_drive_acceleration_default = ( - self._head96_information.y_drive_acceleration_default - ) - self._head96_z_drive_speed_default = self._head96_information.z_drive_speed_default - self._head96_z_drive_acceleration_default = ( - self._head96_information.z_drive_acceleration_default - ) + # Seed the mutable drive defaults from the machine (mirrors STARBackend); the head96_request_* + # overrides below return the canned 2013+ factory registers. + self._head96_y_drive_speed_default = await self.head96_request_y_speed() + self._head96_y_drive_acceleration_default = await self.head96_request_y_acceleration() + self._head96_z_drive_speed_default = await self.head96_request_z_speed() + self._head96_z_drive_acceleration_default = await self.head96_request_z_acceleration() else: self._head96_information = None @@ -318,6 +315,20 @@ async def head96_request_firmware_version(self) -> datetime.date: """Return mock 96-head firmware version.""" return datetime.date(2023, 1, 1) + # The Y/Z drive speed/acceleration registers a 2013+ (2023 mock) head reports at setup, returned + # through the real unit conversions so the seeded defaults match a live machine's factory values. + async def head96_request_y_speed(self) -> float: + return self._head96_y_drive_increment_to_mm(25000) + + async def head96_request_y_acceleration(self) -> float: + return self._head96_y_drive_increment_to_mm(35000) + + async def head96_request_z_speed(self) -> float: + return 85.0 + + async def head96_request_z_acceleration(self) -> float: + return 400.0 + # # # # # # # # Extension: iSWAP # # # # # # # # async def request_iswap_initialization_status(self) -> bool: diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 8573c31bbc3..855ddf98588 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -2,7 +2,6 @@ import contextlib import copy -import datetime import unittest import unittest.mock from typing import Literal, cast @@ -38,7 +37,6 @@ CommandSyntaxError, HamiltonNoTipError, HardwareError, - Head96Information, PipChannelInformation, STARBackend, STARFirmwareError, @@ -450,43 +448,40 @@ async def test_skipped_when_iswap_not_installed(self): 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.""" + """The Y/Z drive speed/acceleration defaults are read from the machine into mutable STARBackend + attributes at setup (and are user-overridable); the dispensing/squeezer factory facts stay on the + frozen Head96Information record.""" - async def test_setup_resolves_all_drive_defaults(self): - """setup() populates all eight per-drive defaults with the 2013+ firmware values.""" + async def _setup_cb(self) -> STARChatterboxBackend: cb = STARChatterboxBackend() # mocks a 2023 (2013+) head cb.set_deck(STARLetDeck()) await cb.setup() + return cb + + async def test_setup_seeds_yz_defaults_from_machine(self): + """setup() seeds the mutable Y/Z defaults from the machine registers; dispensing/squeezer stay + on the frozen record with their 2013+ firmware values.""" + cb = await self._setup_cb() 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) + # Y/Z defaults: read from the machine into mutable backend attributes. + self.assertAlmostEqual(cb.head96_y_drive_speed_default, 390.62, places=2) + self.assertAlmostEqual(cb.head96_y_drive_acceleration_default, 546.88, places=2) + self.assertEqual(cb.head96_z_drive_speed_default, 85.0) + self.assertEqual(cb.head96_z_drive_acceleration_default, 400.0) + # Dispensing/squeezer factory defaults still live on the frozen record. 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).""" - - def info(fw_version: datetime.date) -> Head96Information: - # Only fw_version drives the computed default; the rest are placeholder facts. - return Head96Information( - fw_version=fw_version, - x_offset=0.0, - supports_clot_monitoring_clld=False, - stop_disc_type="core_ii", - instrument_type="FM-STAR", - head_type="96 head II", - z_range=(0.0, 0.0), - ) - - self.assertAlmostEqual(info(datetime.date(2008, 11, 11)).y_drive_speed_default, 312.5, places=2) - self.assertAlmostEqual(info(datetime.date(2013, 9, 2)).y_drive_speed_default, 390.62, places=2) + async def test_yz_default_is_user_overridable_and_range_checked(self): + """A machine-seeded Y/Z default can be reassigned; an out-of-range value is rejected.""" + cb = await self._setup_cb() + cb.head96_y_drive_speed_default = 100.0 + self.assertEqual(cb.head96_y_drive_speed_default, 100.0) + with self.assertRaises(ValueError): + cb.head96_y_drive_speed_default = 10_000.0 # outside y_speed_range class TestHead96CrashRecovery(unittest.IsolatedAsyncioTestCase): @@ -500,11 +495,16 @@ async def asyncSetUp(self): assert self.cb._head96_information is not None z_min, z_max = self.cb._head96_information.z_range self.z_target = round((z_min + z_max) / 2, 1) + self.move_za = f"{self.cb._head96_z_drive_mm_to_increment(self.z_target):05}" self.z_safety_za = f"{self.cb._head96_z_drive_mm_to_increment(z_max):05}" # head96_move_stop_disk_z snapshots the current Z speed/accel (to restore after the move); these - # tests only exercise the ZA crash-retract path, so stub the reads with in-range values. + # tests only exercise the ZA crash-retract path, so stub the reads with in-range values and the + # restore writes (AA) so that send_command receives only the ZA moves under test. self.cb.head96_request_z_speed = unittest.mock.AsyncMock(return_value=85.0) self.cb.head96_request_z_acceleration = unittest.mock.AsyncMock(return_value=400.0) + self.cb._head96_set_z_speed = unittest.mock.AsyncMock() + self.cb._head96_set_z_acceleration = unittest.mock.AsyncMock() + self.cb.send_command = unittest.mock.AsyncMock() def _crash(self, message): return STARFirmwareError( @@ -520,46 +520,29 @@ async def test_crash_retracts_to_z_safety_then_reraises(self): """A ZA firmware error retracts the head to z_range[1] (a second ZA) before the original error propagates.""" original = self._crash("z drive movement error") - za_targets = [] + # ZA #1 is the move (crashes); ZA #2 is the safety retract (succeeds). + self.cb.send_command.side_effect = [original, {}] - async def fake_send(module, command, **kwargs): - if command == "ZA": - za_targets.append(kwargs["za"]) - if len(za_targets) == 1: - raise original # the move crashes - return {} # the safety retract succeeds - return {} # AA restore etc. - - self.cb.send_command = unittest.mock.AsyncMock(side_effect=fake_send) with self.assertRaises(STARFirmwareError) as ctx: await self.cb.head96_move_stop_disk_z(self.z_target) self.assertIs(ctx.exception, original) - move_za = f"{self.cb._head96_z_drive_mm_to_increment(self.z_target):05}" - self.assertEqual(za_targets, [move_za, self.z_safety_za]) + za_targets = [call.kwargs["za"] for call in self.cb.send_command.await_args_list] + self.assertEqual(za_targets, [self.move_za, self.z_safety_za]) async def test_retract_that_also_crashes_does_not_recurse(self): """If the safety retract itself errors, exactly two ZA moves are sent (no recursion) and the ORIGINAL error re-raises, not the retract's.""" original = self._crash("original crash") retract_err = self._crash("retract crash") - za_count = 0 - - async def fake_send(module, command, **kwargs): - nonlocal za_count - if command == "ZA": - za_count += 1 - if za_count == 1: - raise original - raise retract_err - return {} - - self.cb.send_command = unittest.mock.AsyncMock(side_effect=fake_send) + # ZA #1 (move) and ZA #2 (retract) both crash; the retract must not recurse into a third ZA. + self.cb.send_command.side_effect = [original, retract_err] + with self.assertRaises(STARFirmwareError) as ctx: await self.cb.head96_move_stop_disk_z(self.z_target) self.assertIs(ctx.exception, original) - self.assertEqual(za_count, 2) + self.assertEqual(self.cb.send_command.await_count, 2) class TestiSWAPYMaxBootstrap(unittest.IsolatedAsyncioTestCase): From 6226beaa51ea1c7aa047c4d52b4cfe521697c5df Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 19 Jun 2026 08:50:31 +0100 Subject: [PATCH 16/16] style(STAR): reflow 96-head default assertions for ruff 0.15.4 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index dfc5816a12b..e5f885f952d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -8033,7 +8033,9 @@ def head96_y_drive_speed_default(self) -> float: before taking effect. A move does not persist this to the drive: it snapshots the live register and restores it afterwards. """ - assert self._head96_y_drive_speed_default is not None, "96-head defaults not loaded; run setup()" + assert self._head96_y_drive_speed_default is not None, ( + "96-head defaults not loaded; run setup()" + ) return self._head96_y_drive_speed_default @head96_y_drive_speed_default.setter @@ -8052,9 +8054,9 @@ def head96_y_drive_acceleration_default(self) -> float: `y_acceleration_range` before taking effect. A move does not persist this to the drive: it snapshots the live register and restores it afterwards. """ - assert ( - self._head96_y_drive_acceleration_default is not None - ), "96-head defaults not loaded; run setup()" + assert self._head96_y_drive_acceleration_default is not None, ( + "96-head defaults not loaded; run setup()" + ) return self._head96_y_drive_acceleration_default @head96_y_drive_acceleration_default.setter @@ -8073,7 +8075,9 @@ def head96_z_drive_speed_default(self) -> float: before taking effect. A move does not persist this to the drive: it snapshots the live register and restores it afterwards. """ - assert self._head96_z_drive_speed_default is not None, "96-head defaults not loaded; run setup()" + assert self._head96_z_drive_speed_default is not None, ( + "96-head defaults not loaded; run setup()" + ) return self._head96_z_drive_speed_default @head96_z_drive_speed_default.setter @@ -8092,9 +8096,9 @@ def head96_z_drive_acceleration_default(self) -> float: `z_acceleration_range` before taking effect. A move does not persist this to the drive: it snapshots the live register and restores it afterwards. """ - assert ( - self._head96_z_drive_acceleration_default is not None - ), "96-head defaults not loaded; run setup()" + assert self._head96_z_drive_acceleration_default is not None, ( + "96-head defaults not loaded; run setup()" + ) return self._head96_z_drive_acceleration_default @head96_z_drive_acceleration_default.setter