From b477d271b53a61ac7ec81548121ec974bd4cff3d Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 25 Mar 2026 10:09:25 +0000 Subject: [PATCH 1/9] Add move_channel_probe_z for direct pip-channel Z movement Bypasses the C0 master module (KZ) by sending ZA directly to the pip channel, enabling Z moves with configurable speed/acceleration even when the firmware's tip-picked-up flag is incorrectly set. Also updates `move_channel_z` docstring. --- .../backends/hamilton/STAR_backend.py | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 6db6691bb75..8749a8a1910 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4530,11 +4530,68 @@ async def move_channel_y(self, channel: int, y: float): ) async def move_channel_z(self, channel: int, z: float): - """Move a channel in the z direction.""" + """Move a channel in the Z direction. + The meaning of this command can change -> it refers to.. + 1.) the bottom of the stop disc when no tip is present, or + 2.) the tip end when a tip is mounted. + """ await self.position_single_pipetting_channel_in_z_direction( pipetting_channel_index=channel + 1, z_position=round(z * 10) ) + async def move_channel_probe_z( + self, + channel: int, + z: float, + speed: float = 125.0, + acceleration: float = 800.0, + current_limit: int = 3, + ): + """Move a channel's probe Z-drive to an absolute position, communicating directly + with the individual channel rather than through the master module. + + "Probe" refers to the lowest point of the stop disc / entire channel assembly + without a tip attached. + + Use this instead of `move_channel_z` when the firmware's internal "tip picked up" + flag has been incorrectly set (e.g. after sleeve-sensing displacement during + tip-presence probing), which causes master-routed Z moves to misbehave. + + Args: + channel: Channel index (0-based, backmost = 0). + z: Target Z position in mm. + speed: Max Z-drive speed in mm/sec. Default 125.0 mm/s. + acceleration: Acceleration in mm/sec². Default 800.0. Valid range: ~53.6 to 1609. + current_limit: Current limit (0-7). Default 3. + """ + + z_increment = STARBackend.mm_to_z_drive_increment(z) + speed_increment = STARBackend.mm_to_z_drive_increment(speed) + acceleration_increment = STARBackend.mm_to_z_drive_increment(acceleration / 1000) + + assert 0 <= channel <= 15, f"channel must be between 0 and 15, got {channel}" + assert 9320 <= z_increment <= 31200, ( + f"z must be between {STARBackend.z_drive_increment_to_mm(9320)} and " + f"{STARBackend.z_drive_increment_to_mm(31200)} mm, got {z} mm" + ) + assert 20 <= speed_increment <= 15000, ( + f"speed must be between {STARBackend.z_drive_increment_to_mm(20)} and " + f"{STARBackend.z_drive_increment_to_mm(15000)} mm/s, got {speed} mm/s" + ) + assert 5 <= acceleration_increment <= 150, ( + f"acceleration must be between ~53.6 and ~1609 mm/s², got {acceleration} mm/s²" + ) + assert 0 <= current_limit <= 7, f"current_limit must be between 0 and 7, got {current_limit}" + + return await self.send_command( + module=STARBackend.channel_id(channel), + command="ZA", + za=f"{z_increment:05}", + zv=f"{speed_increment:05}", + zr=f"{acceleration_increment:03}", + zw=f"{current_limit:01}", + ) + async def move_channel_x_relative(self, channel: int, distance: float): """Move a channel in the x direction by a relative amount.""" current_x = await self.request_x_pos_channel_n(channel) From 96543fb95a5d27147e68ce03c0fffea11a137180 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 25 Mar 2026 10:13:51 +0000 Subject: [PATCH 2/9] make `move_channel_z` more descriptive --- .../no_go_zones_pr_flowchart.html | 186 ++++++++++++++++++ .../backends/hamilton/STAR_backend.py | 6 +- 2 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 docs/user_guide/00_liquid-handling/no_go_zones_pr_flowchart.html diff --git a/docs/user_guide/00_liquid-handling/no_go_zones_pr_flowchart.html b/docs/user_guide/00_liquid-handling/no_go_zones_pr_flowchart.html new file mode 100644 index 00000000000..8579bc1195e --- /dev/null +++ b/docs/user_guide/00_liquid-handling/no_go_zones_pr_flowchart.html @@ -0,0 +1,186 @@ + + + + +No-Go Zones PR Flow - PyLabRobot #953 + + + + +

Container No-Go Zones - Channel Positioning Flow

+

PR #953 - Add container no-go zones with spread-aware channel distribution

+ +
+
New
+
Changed
+
Measured
+
Deprecated
+
+ +
+ + +
+
lh.aspirate() / lh.dispense() CHANGED
+
liquid_handler.py
+
Single-resource multi-channel -> _compute_spread_offsets() -> queries backend -> delegates to unified positioning.
+
spread: Literal["wide", "tight", "custom"] = "wide"
+center_offsets = self._compute_spread_offsets(resource, use_channels, spread)
+
+ +
+
backend.get_channel_spacings() CHANGED
+
backends/backend.py, STAR_backend.py
+
Returns per-channel occupancy diameters (n values). Gap = sum of radii: s[i]/2 + s[j]/2
+
# Uniform:  [9.0, 9.0, 9.0, 9.0]
+# Mixed:    [9.0, 9.0, 18.0, 18.0]
+# STAR firmware uses separate max() model
+
+ +
v
+ + +
+
compute_channel_offsets() NEW
+
liquid_handling/utils.py
+
Single entry point for all channel positioning. "custom" -> zero offsets. Container + no_go_zones -> compartment path. Plain resource -> wide/tight.
+ +
+
Compartment Path (when container has no_go_zones)
+
+
+
1. _get_compartments(container)
+   Split Y at no-go zones, apply 2mm edge clearance
+
+2. _distribute_channels(comps, n, spacings)
+   Proportional to width, largest-remainder
+
+
+
3. Position within each compartment
+   Edge clearance = channel radius
+   Wide: even (i+1)*w/(n+1) or centered block
+   Tight: min gaps, centered
+
+4. Validate cross-compartment gaps
+
+
+
+ +
+
+
WIDE (PLAIN RESOURCE)
+
Classic (i+1)*size/(n+1) or centered block. Edge = channel radius.
+
+
+
TIGHT (PLAIN RESOURCE)
+
Min gaps, centered. Edge = channel radius.
+
+
+
+ + + + + +
+
required_spacing_between() NEW
+
liquid_handling/utils.py
+
Single source of truth for inter-channel distance.
+
Adjacent:     ceil((s[i]/2 + s[j]/2) * 10) / 10
+Non-adjacent: sum of intermediate pairs
+
+ +
+
Container.no_go_zones NEW
+
resources/container.py + all subclasses
+
List[Tuple[Coordinate, Coordinate]] cuboid regions. Validated at construction: bounds, ordering, type safety.
+
+ + +
+
Hamilton Trough No-Go Zones MEASURED
+
resources/hamilton/troughs.py
+
+
+ 60mL - 1 center divider
+ (0, 44.4, 5) -> (19, 45.6, 60.25)
+ ~1.2mm wide, open at bottom +
+
+ 120mL - 3 tapered beams
+ y: 39.7-42.2, 73.5-76.0, 107.3-109.8
+ ~2.5mm base, ~0.8mm top +
+
+ 200mL - 1 center divider
+ (0, 60, 8) -> (37, 61.7, 60)
+ ~1.7mm wide, open at bottom +
+
+
+ + +
+
get_wide_single_resource_liquid_op_offsets() DEPRECATED -> compute_channel_offsets(..., spread="wide")
+
get_tight_single_resource_liquid_op_offsets() DEPRECATED -> compute_channel_offsets(..., spread="tight")
+
+ +
+
Tutorial Notebook NEW
+
docs/user_guide/00_liquid-handling/container_no_go_zones.ipynb
+
Real-world Hamilton trough examples, dimensional breakdowns, cross-section visualizations with distance annotations, wide vs tight comparisons, mixed occupancy diameter demo, end-to-end simulation.
+
+ +
+ + + diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 8749a8a1910..68390f027c6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4532,8 +4532,10 @@ async def move_channel_y(self, channel: int, y: float): async def move_channel_z(self, channel: int, z: float): """Move a channel in the Z direction. The meaning of this command can change -> it refers to.. - 1.) the bottom of the stop disc when no tip is present, or - 2.) the tip end when a tip is mounted. + 1.) the bottom of the stop disc when no tip is present (making + it identical to `move_channel_probe_z`), or + 2.) the tip end when a tip is mounted (making + it different to `move_channel_probe_z`). """ await self.position_single_pipetting_channel_in_z_direction( pipetting_channel_index=channel + 1, z_position=round(z * 10) From 86c29c797de13c6cd3be44afee855857d147748a Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 25 Mar 2026 12:13:09 +0000 Subject: [PATCH 3/9] remove surplus from other PR --- .../no_go_zones_pr_flowchart.html | 186 ------------------ 1 file changed, 186 deletions(-) delete mode 100644 docs/user_guide/00_liquid-handling/no_go_zones_pr_flowchart.html diff --git a/docs/user_guide/00_liquid-handling/no_go_zones_pr_flowchart.html b/docs/user_guide/00_liquid-handling/no_go_zones_pr_flowchart.html deleted file mode 100644 index 8579bc1195e..00000000000 --- a/docs/user_guide/00_liquid-handling/no_go_zones_pr_flowchart.html +++ /dev/null @@ -1,186 +0,0 @@ - - - - -No-Go Zones PR Flow - PyLabRobot #953 - - - - -

Container No-Go Zones - Channel Positioning Flow

-

PR #953 - Add container no-go zones with spread-aware channel distribution

- -
-
New
-
Changed
-
Measured
-
Deprecated
-
- -
- - -
-
lh.aspirate() / lh.dispense() CHANGED
-
liquid_handler.py
-
Single-resource multi-channel -> _compute_spread_offsets() -> queries backend -> delegates to unified positioning.
-
spread: Literal["wide", "tight", "custom"] = "wide"
-center_offsets = self._compute_spread_offsets(resource, use_channels, spread)
-
- -
-
backend.get_channel_spacings() CHANGED
-
backends/backend.py, STAR_backend.py
-
Returns per-channel occupancy diameters (n values). Gap = sum of radii: s[i]/2 + s[j]/2
-
# Uniform:  [9.0, 9.0, 9.0, 9.0]
-# Mixed:    [9.0, 9.0, 18.0, 18.0]
-# STAR firmware uses separate max() model
-
- -
v
- - -
-
compute_channel_offsets() NEW
-
liquid_handling/utils.py
-
Single entry point for all channel positioning. "custom" -> zero offsets. Container + no_go_zones -> compartment path. Plain resource -> wide/tight.
- -
-
Compartment Path (when container has no_go_zones)
-
-
-
1. _get_compartments(container)
-   Split Y at no-go zones, apply 2mm edge clearance
-
-2. _distribute_channels(comps, n, spacings)
-   Proportional to width, largest-remainder
-
-
-
3. Position within each compartment
-   Edge clearance = channel radius
-   Wide: even (i+1)*w/(n+1) or centered block
-   Tight: min gaps, centered
-
-4. Validate cross-compartment gaps
-
-
-
- -
-
-
WIDE (PLAIN RESOURCE)
-
Classic (i+1)*size/(n+1) or centered block. Edge = channel radius.
-
-
-
TIGHT (PLAIN RESOURCE)
-
Min gaps, centered. Edge = channel radius.
-
-
-
- - - - - -
-
required_spacing_between() NEW
-
liquid_handling/utils.py
-
Single source of truth for inter-channel distance.
-
Adjacent:     ceil((s[i]/2 + s[j]/2) * 10) / 10
-Non-adjacent: sum of intermediate pairs
-
- -
-
Container.no_go_zones NEW
-
resources/container.py + all subclasses
-
List[Tuple[Coordinate, Coordinate]] cuboid regions. Validated at construction: bounds, ordering, type safety.
-
- - -
-
Hamilton Trough No-Go Zones MEASURED
-
resources/hamilton/troughs.py
-
-
- 60mL - 1 center divider
- (0, 44.4, 5) -> (19, 45.6, 60.25)
- ~1.2mm wide, open at bottom -
-
- 120mL - 3 tapered beams
- y: 39.7-42.2, 73.5-76.0, 107.3-109.8
- ~2.5mm base, ~0.8mm top -
-
- 200mL - 1 center divider
- (0, 60, 8) -> (37, 61.7, 60)
- ~1.7mm wide, open at bottom -
-
-
- - -
-
get_wide_single_resource_liquid_op_offsets() DEPRECATED -> compute_channel_offsets(..., spread="wide")
-
get_tight_single_resource_liquid_op_offsets() DEPRECATED -> compute_channel_offsets(..., spread="tight")
-
- -
-
Tutorial Notebook NEW
-
docs/user_guide/00_liquid-handling/container_no_go_zones.ipynb
-
Real-world Hamilton trough examples, dimensional breakdowns, cross-section visualizations with distance annotations, wide vs tight comparisons, mixed occupancy diameter demo, end-to-end simulation.
-
- -
- - - From 99d7daf8e91f4d4f8dcb42578d86a1a178063ca7 Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:26:16 +0000 Subject: [PATCH 4/9] Update pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../liquid_handling/backends/hamilton/STAR_backend.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 68390f027c6..ec489c43c01 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4571,7 +4571,13 @@ async def move_channel_probe_z( speed_increment = STARBackend.mm_to_z_drive_increment(speed) acceleration_increment = STARBackend.mm_to_z_drive_increment(acceleration / 1000) - assert 0 <= channel <= 15, f"channel must be between 0 and 15, got {channel}" + if not isinstance(channel, int): + raise ValueError(f"channel must be an int, got {type(channel).__name__}") + if not (0 <= channel < self.num_channels): + raise ValueError( + f"channel index {channel} out of range for instrument with " + f"{self.num_channels} channels" + ) assert 9320 <= z_increment <= 31200, ( f"z must be between {STARBackend.z_drive_increment_to_mm(9320)} and " f"{STARBackend.z_drive_increment_to_mm(31200)} mm, got {z} mm" From 489151e7d1effd776cd5be9beb8abda2014d7717 Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:29:34 +0000 Subject: [PATCH 5/9] Update pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../backends/hamilton/STAR_backend.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index ec489c43c01..de603e1ad33 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4531,11 +4531,18 @@ async def move_channel_y(self, channel: int, y: float): async def move_channel_z(self, channel: int, z: float): """Move a channel in the Z direction. - The meaning of this command can change -> it refers to.. - 1.) the bottom of the stop disc when no tip is present (making - it identical to `move_channel_probe_z`), or - 2.) the tip end when a tip is mounted (making - it different to `move_channel_probe_z`). + + The Hamilton firmware interprets this Z position based on its internal + "tip mounted" state for the specified channel. When the firmware state + indicates that no tip is mounted, the absolute Z position refers to the + bottom of the stop disc. In that case, this command is effectively + equivalent to :meth:`move_channel_probe_z` for the same numeric Z value. + + When the firmware state indicates that a tip is mounted on the channel, + the same Z position instead refers to the physical end of the tip. In + this case, the numeric Z value used with this method may differ from the + probe Z position used with :meth:`move_channel_probe_z` for the same + physical height above the deck. """ await self.position_single_pipetting_channel_in_z_direction( pipetting_channel_index=channel + 1, z_position=round(z * 10) From 1886b52517c4f7cadd5a35c2571eda10100d3e3d Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 25 Mar 2026 13:02:40 +0000 Subject: [PATCH 6/9] `make format` --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index de603e1ad33..991d3ccbf4d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4582,8 +4582,7 @@ async def move_channel_probe_z( raise ValueError(f"channel must be an int, got {type(channel).__name__}") if not (0 <= channel < self.num_channels): raise ValueError( - f"channel index {channel} out of range for instrument with " - f"{self.num_channels} channels" + f"channel index {channel} out of range for instrument with {self.num_channels} channels" ) assert 9320 <= z_increment <= 31200, ( f"z must be between {STARBackend.z_drive_increment_to_mm(9320)} and " From 3449c1cd36156b0abb1bd8fbd7229a93ded408a4 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 26 Mar 2026 23:02:04 +0000 Subject: [PATCH 7/9] create `move_channel_tool_z`, deprecate `move_channel_z` --- .../backends/hamilton/STAR_backend.py | 77 +++++++++++++++++-- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 991d3ccbf4d..30df349dd0a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4532,6 +4532,10 @@ async def move_channel_y(self, channel: int, y: float): async def move_channel_z(self, channel: int, z: float): """Move a channel in the Z direction. + .. deprecated:: + Use :meth:`move_channel_probe_z` for bare-channel moves (stop disc reference) + or :meth:`move_channel_tool_z` when a tip or tool is attached (tip/tool end reference). + The Hamilton firmware interprets this Z position based on its internal "tip mounted" state for the specified channel. When the firmware state indicates that no tip is mounted, the absolute Z position refers to the @@ -4544,13 +4548,19 @@ async def move_channel_z(self, channel: int, z: float): probe Z position used with :meth:`move_channel_probe_z` for the same physical height above the deck. """ + warnings.warn( + "move_channel_z is deprecated. Use move_channel_probe_z() for bare-channel moves " + "or move_channel_tool_z() when a tip/tool is attached.", + DeprecationWarning, + stacklevel=2, + ) await self.position_single_pipetting_channel_in_z_direction( pipetting_channel_index=channel + 1, z_position=round(z * 10) ) async def move_channel_probe_z( self, - channel: int, + channel_idx: int, z: float, speed: float = 125.0, acceleration: float = 800.0, @@ -4567,7 +4577,7 @@ async def move_channel_probe_z( tip-presence probing), which causes master-routed Z moves to misbehave. Args: - channel: Channel index (0-based, backmost = 0). + channel_idxchannel_idx: Channel index (0-based, backmost = 0). z: Target Z position in mm. speed: Max Z-drive speed in mm/sec. Default 125.0 mm/s. acceleration: Acceleration in mm/sec². Default 800.0. Valid range: ~53.6 to 1609. @@ -4578,11 +4588,11 @@ async def move_channel_probe_z( speed_increment = STARBackend.mm_to_z_drive_increment(speed) acceleration_increment = STARBackend.mm_to_z_drive_increment(acceleration / 1000) - if not isinstance(channel, int): - raise ValueError(f"channel must be an int, got {type(channel).__name__}") - if not (0 <= channel < self.num_channels): + if not isinstance(channel_idx, int): + raise ValueError(f"channel must be an int, got {type(channel_idx).__name__}") + if not (0 <= channel_idx < self.num_channels): raise ValueError( - f"channel index {channel} out of range for instrument with {self.num_channels} channels" + f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" ) assert 9320 <= z_increment <= 31200, ( f"z must be between {STARBackend.z_drive_increment_to_mm(9320)} and " @@ -4598,7 +4608,7 @@ async def move_channel_probe_z( assert 0 <= current_limit <= 7, f"current_limit must be between 0 and 7, got {current_limit}" return await self.send_command( - module=STARBackend.channel_id(channel), + module=STARBackend.channel_id(channel_idx), command="ZA", za=f"{z_increment:05}", zv=f"{speed_increment:05}", @@ -4606,6 +4616,59 @@ async def move_channel_probe_z( zw=f"{current_limit:01}", ) + async def move_channel_tool_z(self, channel_idx: int, z: float): + """Move a channel in the Z direction when a tip or tool is attached. + + Unlike :meth:`move_channel_z`, this method first verifies that the firmware + reports a tip (or tool) as present on the channel. The Z position is + interpreted by the firmware as the physical end of the attached tip/tool, + not the stop disc. + + Use :meth:`move_channel_z` or :meth:`move_channel_probe_z` when operating + without a tip attached. + + Args: + channel_idx: Channel index (0-based, backmost = 0). + z: Target Z position in mm (tip/tool end reference). + """ + + if not isinstance(channel_idx, int): + raise ValueError(f"channel_idx must be an int, got {type(channel_idx).__name__}") + if not (0 <= channel_idx < self.num_channels): + raise ValueError( + f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" + ) + + tip_presence = await self.request_tip_presence() + + if not tip_presence[channel_idx]: + raise ValueError( + f"Channel {channel_idx} does not have a tip or tool attached. " + "Use move_channel_z() or move_channel_probe_z() for bare-channel Z moves." + ) + + tip_len = await self.request_tip_len_on_channel(channel_idx) + + # The firmware command operates in "tip space" (Z refers to the tip/tool end). + # Convert the head-space limits to tip-space limits: + # tip_space = head_space - tip_len + fitting_depth + max_tip_z = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + min_tip_z = ( + STARBackend.MINIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + + if not (min_tip_z <= z <= max_tip_z): + raise ValueError( + f"z={z} mm out of safe range [{min_tip_z}, {max_tip_z}] mm " + f"for tip length {tip_len} mm on channel {channel_idx}" + ) + + await self.position_single_pipetting_channel_in_z_direction( + pipetting_channel_index=channel_idx + 1, z_position=round(z * 10) + ) + async def move_channel_x_relative(self, channel: int, distance: float): """Move a channel in the x direction by a relative amount.""" current_x = await self.request_x_pos_channel_n(channel) From b708eef718817e1406541deb4784d66baca1c325 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 26 Mar 2026 23:19:25 +0000 Subject: [PATCH 8/9] rename to `move_channel_stop_disk_z` --- .../backends/hamilton/STAR_backend.py | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 30df349dd0a..4a1d1bc78d4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4533,23 +4533,23 @@ async def move_channel_z(self, channel: int, z: float): """Move a channel in the Z direction. .. deprecated:: - Use :meth:`move_channel_probe_z` for bare-channel moves (stop disc reference) - or :meth:`move_channel_tool_z` when a tip or tool is attached (tip/tool end reference). + Use :meth:`move_channel_stop_disk_z` for moves without a tip attached (stop disk) + or :meth:`move_channel_tool_z` when a tip or tool is attached (tip/tool end). The Hamilton firmware interprets this Z position based on its internal "tip mounted" state for the specified channel. When the firmware state indicates that no tip is mounted, the absolute Z position refers to the - bottom of the stop disc. In that case, this command is effectively - equivalent to :meth:`move_channel_probe_z` for the same numeric Z value. + bottom of the stop disk. In that case, this command is effectively + equivalent to :meth:`move_channel_stop_disk_z` for the same numeric Z value. When the firmware state indicates that a tip is mounted on the channel, the same Z position instead refers to the physical end of the tip. In this case, the numeric Z value used with this method may differ from the - probe Z position used with :meth:`move_channel_probe_z` for the same + stop disk Z position used with :meth:`move_channel_stop_disk_z` for the same physical height above the deck. """ warnings.warn( - "move_channel_z is deprecated. Use move_channel_probe_z() for bare-channel moves " + "move_channel_z is deprecated. Use move_channel_stop_disk_z() for moves without a tip attached " "or move_channel_tool_z() when a tip/tool is attached.", DeprecationWarning, stacklevel=2, @@ -4558,7 +4558,7 @@ async def move_channel_z(self, channel: int, z: float): pipetting_channel_index=channel + 1, z_position=round(z * 10) ) - async def move_channel_probe_z( + async def move_channel_stop_disk_z( self, channel_idx: int, z: float, @@ -4566,19 +4566,14 @@ async def move_channel_probe_z( acceleration: float = 800.0, current_limit: int = 3, ): - """Move a channel's probe Z-drive to an absolute position, communicating directly - with the individual channel rather than through the master module. + """Move a channel's Z-drive to an absolute stop disk position. - "Probe" refers to the lowest point of the stop disc / entire channel assembly - without a tip attached. - - Use this instead of `move_channel_z` when the firmware's internal "tip picked up" - flag has been incorrectly set (e.g. after sleeve-sensing displacement during - tip-presence probing), which causes master-routed Z moves to misbehave. + Communicates directly with the individual channel rather than through the + master module. Args: - channel_idxchannel_idx: Channel index (0-based, backmost = 0). - z: Target Z position in mm. + channel_idx: Channel index (0-based, backmost = 0). + z: Target Z position in mm (stop disk). speed: Max Z-drive speed in mm/sec. Default 125.0 mm/s. acceleration: Acceleration in mm/sec². Default 800.0. Valid range: ~53.6 to 1609. current_limit: Current limit (0-7). Default 3. @@ -4589,7 +4584,7 @@ async def move_channel_probe_z( acceleration_increment = STARBackend.mm_to_z_drive_increment(acceleration / 1000) if not isinstance(channel_idx, int): - raise ValueError(f"channel must be an int, got {type(channel_idx).__name__}") + raise ValueError(f"channel_idx must be an int, got {type(channel_idx).__name__}") if not (0 <= channel_idx < self.num_channels): raise ValueError( f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" @@ -4617,19 +4612,14 @@ async def move_channel_probe_z( ) async def move_channel_tool_z(self, channel_idx: int, z: float): - """Move a channel in the Z direction when a tip or tool is attached. - - Unlike :meth:`move_channel_z`, this method first verifies that the firmware - reports a tip (or tool) as present on the channel. The Z position is - interpreted by the firmware as the physical end of the attached tip/tool, - not the stop disc. + """Move a channel in the Z direction (tip/tool end reference). - Use :meth:`move_channel_z` or :meth:`move_channel_probe_z` when operating - without a tip attached. + Requires a tip or tool to be attached. Use :meth:`move_channel_stop_disk_z` + for moves without a tip. Args: channel_idx: Channel index (0-based, backmost = 0). - z: Target Z position in mm (tip/tool end reference). + z: Target Z position in mm (tip/tool end). """ if not isinstance(channel_idx, int): @@ -4644,7 +4634,7 @@ async def move_channel_tool_z(self, channel_idx: int, z: float): if not tip_presence[channel_idx]: raise ValueError( f"Channel {channel_idx} does not have a tip or tool attached. " - "Use move_channel_z() or move_channel_probe_z() for bare-channel Z moves." + "Use move_channel_stop_disk_z() for Z moves without a tip attached." ) tip_len = await self.request_tip_len_on_channel(channel_idx) @@ -4681,6 +4671,7 @@ async def move_channel_y_relative(self, channel: int, distance: float): async def move_channel_z_relative(self, channel: int, distance: float): """Move a channel in the z direction by a relative amount.""" + # TODO: determine whether this refers to stop disk or tip bottom current_z = await self.request_z_pos_channel_n(channel) await self.move_channel_z(channel, current_z + distance) From a29eaef9b94933533a9079f9f2480a0698d632a1 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 27 Mar 2026 08:35:11 +0000 Subject: [PATCH 9/9] add removal date to deprecation warning + TODO --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 4a1d1bc78d4..eae7f0a50b1 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -4548,8 +4548,10 @@ async def move_channel_z(self, channel: int, z: float): stop disk Z position used with :meth:`move_channel_stop_disk_z` for the same physical height above the deck. """ + # TODO: remove after 2026-09 warnings.warn( - "move_channel_z is deprecated. Use move_channel_stop_disk_z() for moves without a tip attached " + "move_channel_z is deprecated and will be removed after 2026-09 in legacy PLR. " + "Use move_channel_stop_disk_z() for moves without a tip attached " "or move_channel_tool_z() when a tip/tool is attached.", DeprecationWarning, stacklevel=2,