From acb1c349fba59abf931870b0f95bea42d2556dd2 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 9 Jun 2026 16:06:19 +0100 Subject: [PATCH 1/4] STAR: head96 X-offset read + direct X-drive head96_move_x (mirror iSWAP), keep EM as _OLD Mirror the iSWAP rotation-drive X handling on the 96-head: read the X-arm-carriage <-> channel-A1 X-offset from master EEPROM (_head96_request_x_offset, RA ra=kf - key pending hardware verification), cache it on Head96Information.x_offset (populated in set_up_core96_head like the iSWAP block), and rework head96_move_x to drive the X arm directly to (x - x_offset) via experimental_x_arm_move, with an A1 X-range guard and acceleration/current control. The previous EM-based move is preserved as head96_move_x_OLD for A/B verification; move_iswap_x_OLD recovers the pre-#1053 relative-step iSWAP X move for the same comparison. Both _OLD methods are scaffolding to delete after validation. Chatterbox supplies a canned x_offset. format/lint/mypy clean; STAR tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 83 ++++++++++++++++--- .../backends/hamilton/STAR_chatterbox.py | 1 + 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 0dd6701b2f3..17bc7b8aa3e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1394,6 +1394,9 @@ class Head96Information: HeadType = Literal["Low volume head", "High volume head", "96 head II", "96 head TADM", "unknown"] fw_version: datetime.date + x_offset: float + """Deck X distance from the X-arm carriage center to head channel A1 (mm), read from + master EEPROM at setup. Mirrors iSWAPInformation.rotation_drive_x_offset.""" supports_clot_monitoring_clld: bool stop_disc_type: StopDiscType instrument_type: InstrumentType @@ -2060,6 +2063,7 @@ async def set_up_core96_head(): self._head96_information = Head96Information( fw_version=fw_version, + x_offset=await self._head96_request_x_offset(), supports_clot_monitoring_clld=bool(int(configuration_96head[0])), stop_disc_type="core_i" if configuration_96head[1] == "0" else "core_ii", instrument_type="legacy" if configuration_96head[2] == "0" else "FM-STAR", @@ -5566,6 +5570,22 @@ async def set_x_offset_x_axis_core_96_head(self, x_offset: int): return await self.send_command(module="C0", command="AF", x_offset=x_offset) + async def _head96_request_x_offset(self) -> float: + """Read the X-offset i.e. X-arm carriage center <-> CoRe 96 head channel A1, in mm. + + Stored in the master EEPROM as parameter `kf` (set via the AF command), read with the + generic master-EEPROM read RA - mirroring the iSWAP rotation-drive x-offset (`kg`). + Required for deriving the head's X-arm carriage X from a target A1 X. Cached on the + backend as `head96_information.x_offset` during setup. + + NOTE: `kf` is the working hypothesis (sibling of the iSWAP `kg`, matching the AF/AG + setter pair); verify against the master EEPROM before relying on it. + """ + if not self.extended_conf.left_x_drive.core_96_head_installed: + raise RuntimeError("96-head is not installed") + resp = await self.send_command(module="C0", command="RA", ra="kf", fmt="kf###") + return cast(int, resp["kf"]) / 10.0 + async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): """Set X-offset X-axis <-> CoRe 96 head @@ -7901,21 +7921,15 @@ async def head96_park( return await self.send_command(module="H0", command="MO") @_requires_head96 - async def head96_move_x(self, x: float): - """Move the 96-head to a specified X-axis coordinate. + async def head96_move_x_OLD(self, x: float): + """Deprecated EM-based X move, kept only for A/B verification against head96_move_x. - Note: Unlike head96_move_y and head96_move_z, the X-axis movement does not have - dedicated speed/acceleration parameters - it uses the EM command which moves - all axes together. + Moves all axes together via the EM/coordinate command, after a position round-trip, so + it exposes no per-axis motion control. Slated for deletion once the direct-drive + head96_move_x is validated on hardware. Args: x: Target X coordinate in mm. Valid range: [-271.0, 974.0] - - Returns: - Response from the hardware command. - - Raises: - RuntimeError: If 96-head is not installed. """ current_pos = await self.head96_request_position() return await self.head96_move_to_coordinate( @@ -7923,6 +7937,42 @@ async def head96_move_x(self, x: float): minimum_height_at_beginning_of_a_command=current_pos.z - 10, ) + @_requires_head96 + async def head96_move_x( + self, + x: float, + acceleration_level: int = 3, + current_protection_limiter: int = 7, + ): + """Move the 96-head to a target channel-A1 X coordinate via the direct X-arm drive. + + Drives the X-arm carriage to ``x - head96_information.x_offset`` so channel A1 lands at + ``x``, mirroring how the iSWAP derives its rotation-drive X from the carriage center. + Unlike the EM-based ``head96_move_x_OLD`` (all axes together, no motion control), this is + the single-axis X-arm drive command and exposes acceleration and current control, like + ``head96_move_y`` / ``head96_move_z``. + + Args: + x: Target A1 X coordinate in mm. Valid range [x_min, 974.0]; x_min is 0.0 with a left + side panel installed, else -271.0. + acceleration_level: X-arm acceleration index (1-5). Default 3. + current_protection_limiter: X-arm motor current limit (0-7). Default 7. + + Raises: + RuntimeError: If the 96-head is not installed. + ValueError: If the target A1 X is outside the legal 96-head X range. + """ + x_min = self.HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL if self.left_side_panel_installed else -271.0 + if not (x_min <= x <= 974.0): + raise ValueError(f"96-head A1 x={x} out of range [{x_min}, 974.0]") + assert self._head96_information is not None, "96-head information not loaded; run setup()" + carriage_x = x - self._head96_information.x_offset + return await self.experimental_x_arm_move( + carriage_x, + acceleration_level=acceleration_level, + current_protection_limiter=current_protection_limiter, + ) + @_requires_head96 async def head96_move_y( self, @@ -10055,6 +10105,17 @@ async def move_iswap_z_relative(self, step_size: float, allow_splitting: bool = module="C0", command="GZ", gz=str(round(abs(step_size) * 10)).zfill(3), zd=direction ) + async def move_iswap_x_OLD(self, x_position: float): + """Deprecated pre-smooth-motion iSWAP X move (pre #1053), kept only for A/B verification + against move_iswap_x. Queries the position then steps X relative by the delta (with + splitting). Slated for deletion once the refactored move_iswap_x is validated on hardware. + """ + loc = await self.request_iswap_position() + await self.move_iswap_x_relative( + step_size=x_position - loc.x, + allow_splitting=True, + ) + async def move_iswap_x( self, x_position: float, # TODO: by convention should be just 'x' in v1 diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index f75933643ea..919925f5b02 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -160,6 +160,7 @@ async def setup( if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: self._head96_information = Head96Information( fw_version=datetime.date(2023, 1, 1), + x_offset=0.0, # canned; real value read from EEPROM (kf) on hardware supports_clot_monitoring_clld=False, stop_disc_type="core_ii", instrument_type="FM-STAR", From 4477921a84d4e08b64365fa229f94742e35f8357 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 9 Jun 2026 16:21:55 +0100 Subject: [PATCH 2/4] STAR: correct head96 X-offset sign and EEPROM field width Hardware A/B (target A1 500 -> OLD EM lands 500.0, NEW direct-drive landed 94.8) exposed two bugs in head96_move_x: - Sign: A1 sits left of (below) the X-arm carriage center, so deck-A1 = carriage - offset; the carriage target is therefore A1 + offset (inverse of iswap_rotation_drive_request_x). The move subtracted instead of added. - Field width: the head96 offset is ~10x the iSWAP's, so its 0.1 mm EEPROM value is 4 digits (3684); fmt "kf###" truncated it to 368 -> 36.8 mm. Widen to "kf####" -> 368.4 mm, which matches the value the hardware geometry implies. Chatterbox canned x_offset updated 0.0 -> 368.4 to match. lint/mypy clean; STAR tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 12 ++++++++---- .../backends/hamilton/STAR_chatterbox.py | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 17bc7b8aa3e..ef59ab9fe3a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -5583,7 +5583,9 @@ async def _head96_request_x_offset(self) -> float: """ if not self.extended_conf.left_x_drive.core_96_head_installed: raise RuntimeError("96-head is not installed") - resp = await self.send_command(module="C0", command="RA", ra="kf", fmt="kf###") + # 4-digit field: the head96 offset is ~10x the iSWAP's (~368 mm vs ~34 mm), so it exceeds + # 3 digits in 0.1 mm units - "kf###" silently truncates 3684 -> 368. + resp = await self.send_command(module="C0", command="RA", ra="kf", fmt="kf####") return cast(int, resp["kf"]) / 10.0 async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): @@ -7946,8 +7948,10 @@ async def head96_move_x( ): """Move the 96-head to a target channel-A1 X coordinate via the direct X-arm drive. - Drives the X-arm carriage to ``x - head96_information.x_offset`` so channel A1 lands at - ``x``, mirroring how the iSWAP derives its rotation-drive X from the carriage center. + Drives the X-arm carriage to ``x + head96_information.x_offset`` so channel A1 lands at + ``x``: A1 sits left of (below) the carriage center, so deck-A1 = carriage - offset and the + carriage target is therefore ``x + offset`` (inverse of the iSWAP rotation-drive derivation, + ``iswap_rotation_drive_request_x``). Unlike the EM-based ``head96_move_x_OLD`` (all axes together, no motion control), this is the single-axis X-arm drive command and exposes acceleration and current control, like ``head96_move_y`` / ``head96_move_z``. @@ -7966,7 +7970,7 @@ async def head96_move_x( if not (x_min <= x <= 974.0): raise ValueError(f"96-head A1 x={x} out of range [{x_min}, 974.0]") assert self._head96_information is not None, "96-head information not loaded; run setup()" - carriage_x = x - self._head96_information.x_offset + carriage_x = x + self._head96_information.x_offset return await self.experimental_x_arm_move( carriage_x, acceleration_level=acceleration_level, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 919925f5b02..74d6b54e172 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -160,7 +160,7 @@ async def setup( if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: self._head96_information = Head96Information( fw_version=datetime.date(2023, 1, 1), - x_offset=0.0, # canned; real value read from EEPROM (kf) on hardware + x_offset=368.4, # representative value; hardware reads the true offset from EEPROM (kf) supports_clot_monitoring_clld=False, stop_disc_type="core_ii", instrument_type="FM-STAR", From c8de4396122848b876880e518e8ead4a09857590 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 9 Jun 2026 16:36:07 +0100 Subject: [PATCH 3/4] STAR: use documented factory default (365.0 mm) for chatterbox head96 x_offset The C0 command set lists the CoRe 96 head X-offset (AF/kf) default as 3650 (0.1 mm) = 365.0 mm. Use that for the chatterbox canned value, mirroring how the iSWAP chatterbox uses its documented 34.0 mm default; real hardware reads its per-machine calibrated value from EEPROM at setup. Co-Authored-By: Claude Opus 4.8 (1M context) --- pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 74d6b54e172..2573ed5c81c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -160,7 +160,7 @@ async def setup( if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: self._head96_information = Head96Information( fw_version=datetime.date(2023, 1, 1), - x_offset=368.4, # representative value; hardware reads the true offset from EEPROM (kf) + x_offset=365.0, # factory default; hardware reads the per-machine value from EEPROM (kf) supports_clot_monitoring_clld=False, stop_disc_type="core_ii", instrument_type="FM-STAR", From 10c5c4cfdff9f285f2f3ae1703c8dbe1f4e8693b Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 9 Jun 2026 16:46:47 +0100 Subject: [PATCH 4/4] STAR: remove head96/iSWAP X A/B scaffolds and confirm head96 kf offset The direct-drive head96_move_x is hardware-validated (lands A1 within 0.1 mm of the EM command, Y-invariant), so drop the head96_move_x_OLD A/B scaffold and its dead docstring reference. Also drop move_iswap_x_OLD, which was only an iSWAP A/B helper and does not belong in a head96 change; it is recoverable from history for the separate iSWAP work. Remove the "kf hypothesis" hedge in the getter docstring now that the EEPROM field is confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/hamilton/STAR_backend.py | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index ef59ab9fe3a..0417c71a51a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -5577,9 +5577,6 @@ async def _head96_request_x_offset(self) -> float: generic master-EEPROM read RA - mirroring the iSWAP rotation-drive x-offset (`kg`). Required for deriving the head's X-arm carriage X from a target A1 X. Cached on the backend as `head96_information.x_offset` during setup. - - NOTE: `kf` is the working hypothesis (sibling of the iSWAP `kg`, matching the AF/AG - setter pair); verify against the master EEPROM before relying on it. """ if not self.extended_conf.left_x_drive.core_96_head_installed: raise RuntimeError("96-head is not installed") @@ -7922,23 +7919,6 @@ async def head96_park( return await self.send_command(module="H0", command="MO") - @_requires_head96 - async def head96_move_x_OLD(self, x: float): - """Deprecated EM-based X move, kept only for A/B verification against head96_move_x. - - Moves all axes together via the EM/coordinate command, after a position round-trip, so - it exposes no per-axis motion control. Slated for deletion once the direct-drive - head96_move_x is validated on hardware. - - Args: - x: Target X coordinate in mm. Valid range: [-271.0, 974.0] - """ - current_pos = await self.head96_request_position() - return await self.head96_move_to_coordinate( - Coordinate(x, current_pos.y, current_pos.z), - minimum_height_at_beginning_of_a_command=current_pos.z - 10, - ) - @_requires_head96 async def head96_move_x( self, @@ -7952,8 +7932,8 @@ async def head96_move_x( ``x``: A1 sits left of (below) the carriage center, so deck-A1 = carriage - offset and the carriage target is therefore ``x + offset`` (inverse of the iSWAP rotation-drive derivation, ``iswap_rotation_drive_request_x``). - Unlike the EM-based ``head96_move_x_OLD`` (all axes together, no motion control), this is - the single-axis X-arm drive command and exposes acceleration and current control, like + Unlike the legacy EM coordinate move (all axes together, no per-axis motion control), this + is the single-axis X-arm drive command and exposes acceleration and current control, like ``head96_move_y`` / ``head96_move_z``. Args: @@ -10109,17 +10089,6 @@ async def move_iswap_z_relative(self, step_size: float, allow_splitting: bool = module="C0", command="GZ", gz=str(round(abs(step_size) * 10)).zfill(3), zd=direction ) - async def move_iswap_x_OLD(self, x_position: float): - """Deprecated pre-smooth-motion iSWAP X move (pre #1053), kept only for A/B verification - against move_iswap_x. Queries the position then steps X relative by the delta (with - splitting). Slated for deletion once the refactored move_iswap_x is validated on hardware. - """ - loc = await self.request_iswap_position() - await self.move_iswap_x_relative( - step_size=x_position - loc.x, - allow_splitting=True, - ) - async def move_iswap_x( self, x_position: float, # TODO: by convention should be just 'x' in v1