From d8babe30a7e3ef92d088beb609d38c1a16751ef7 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 12 Mar 2026 22:53:58 +0000 Subject: [PATCH 01/39] Encapsulate pipette batch scheduling into dedicated module Replace hamilton/planning.py with pipette_batch_scheduling.py, a self-contained module for channel-batch planning, Y-position computation, and X-group scheduling. Refactor STAR_backend's probe_liquid_heights and execute_batched to use the new API. Add volume-tracker-based probe_liquid_heights mock to chatterbox. Co-Authored-By: Claude Opus 4.6 --- .../backends/hamilton/STAR_backend.py | 647 +++++++++--------- .../backends/hamilton/STAR_chatterbox.py | 129 ++++ .../backends/hamilton/STAR_tests.py | 527 +++++--------- .../backends/hamilton/planning.py | 71 -- .../backends/hamilton/planning_tests.py | 129 ---- .../pipette_batch_scheduling.py | 454 ++++++++++++ .../pipette_batch_scheduling_tests.py | 491 +++++++++++++ 7 files changed, 1564 insertions(+), 884 deletions(-) delete mode 100644 pylabrobot/liquid_handling/backends/hamilton/planning.py delete mode 100644 pylabrobot/liquid_handling/backends/hamilton/planning_tests.py create mode 100644 pylabrobot/liquid_handling/pipette_batch_scheduling.py create mode 100644 pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 553a27608fb..14ecc449ed5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1,4 +1,5 @@ import asyncio +from collections import defaultdict import datetime import enum import functools @@ -12,7 +13,6 @@ from dataclasses import dataclass, field from typing import ( Any, - Awaitable, Callable, Coroutine, Dict, @@ -39,12 +39,19 @@ HamiltonLiquidHandler, ) from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults -from pylabrobot.liquid_handling.backends.hamilton.planning import group_by_x_batch_by_xy from pylabrobot.liquid_handling.errors import ChannelizedError from pylabrobot.liquid_handling.liquid_classes.hamilton import ( HamiltonLiquidClass, get_star_liquid_class, ) +from pylabrobot.liquid_handling.pipette_batch_scheduling import ( + X_GROUPING_TOLERANCE_MM, + ChannelBatch, + compute_positions, + compute_single_container_offsets, + plan_batches, + validate_probing_inputs, +) from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, @@ -63,7 +70,6 @@ SingleChannelDispense, ) from pylabrobot.liquid_handling.utils import ( - MIN_SPACING_EDGE, get_tight_single_resource_liquid_op_offsets, get_wide_single_resource_liquid_op_offsets, ) @@ -1755,21 +1761,14 @@ async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: return self.y_drive_increment_to_mm(resp["yc"][1]) async def channels_request_y_minimum_spacing(self) -> List[float]: - """Query the minimum Y spacing for all channels in parallel. - - Each channel is addressed on its own module (P1, P2, ...), so the queries - can run concurrently. + """Query all channels for their minimum Y spacing in parallel. Returns: - A list of exact (unrounded) minimum Y spacings in mm, one per channel, - indexed by channel number. + A list of minimum Y spacings in mm, one per channel. """ return list( await asyncio.gather( - *( - self.channel_request_y_minimum_spacing(channel_idx=idx) - for idx in range(self.num_channels) - ) + *(self.channel_request_y_minimum_spacing(i) for i in range(self.num_channels)) ) ) @@ -2035,232 +2034,6 @@ class PressureLLDMode(enum.Enum): LIQUID = 0 FOAM = 1 - async def _move_to_traverse_height( - self, channels: Optional[List[int]] = None, traverse_height: Optional[float] = None - ): - """Move channels to a specified traverse height, if given, otherwise move to full Z safety. - - Args: - channels: Channels to move. If None, all channels are moved. - traverse_height: Absolute Z position in mm. If None, move to full Z safety. - """ - if traverse_height is None: - await self.move_all_channels_in_z_safety() - else: - if channels is None: - channels = list(range(self.num_channels)) - await self.position_channels_in_z_direction( - {channel: traverse_height for channel in channels} - ) - - async def _probe_liquid_heights_batch( - self, - containers: List[Container], - use_channels: List[int], - lld_mode: LLDMode = LLDMode.GAMMA, - search_speed: float = 10.0, - n_replicates: int = 1, - ) -> List[float]: - """Helper for probe_liquid_heights that performs a single batch of liquid level detection using a set of channels. - - Assumes channels are moved to the appropriate traverse height before calling, and does not move channels after completion. - """ - - tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] - - detect_func: Callable[..., Any] - if lld_mode == self.LLDMode.GAMMA: - detect_func = self._move_z_drive_to_liquid_surface_using_clld - else: - detect_func = self._search_for_surface_using_plld - - # Compute Z search bounds for this batch - batch_lowest_immers = [ - container.get_absolute_location("c", "c", "cavity_bottom").z - + tip_len - - self.DEFAULT_TIP_FITTING_DEPTH - for container, tip_len in zip(containers, tip_lengths) - ] - batch_start_pos = [ - container.get_absolute_location("c", "c", "t").z - + tip_len - - self.DEFAULT_TIP_FITTING_DEPTH - + 5 - for container, tip_len in zip(containers, tip_lengths) - ] - - absolute_heights_measurements: Dict[int, List[Optional[float]]] = { - idx: [] for idx in range(len(use_channels)) - } - - # Run n_replicates detection loop for this batch - for _ in range(n_replicates): - errors = await asyncio.gather( - *[ - detect_func( - channel_idx=channel, - lowest_immers_pos=lip, - start_pos_search=sps, - channel_speed=search_speed, - ) - for channel, lip, sps in zip(use_channels, batch_lowest_immers, batch_start_pos) - ], - return_exceptions=True, - ) - - # Get heights for ALL channels, handling failures for channels with no liquid - current_absolute_liquid_heights = await self.request_pip_height_last_lld() - for idx, (channel_idx, error) in enumerate(zip(use_channels, errors)): - if isinstance(error, STARFirmwareError): - error_msg = str(error).lower() - if "no liquid level found" in error_msg or "no liquid was present" in error_msg: - height = None - msg = ( - f"Operation {idx} (channel {channel_idx}): No liquid detected. Could be because there is " - f"no liquid in container {containers[idx].name} or liquid level " - f"is too low." - ) - if lld_mode == self.LLDMode.GAMMA: - msg += " Consider using pressure-based LLD if liquid is believed to exist." - logger.warning(msg) - else: - raise error - elif isinstance(error, Exception): - raise error - else: - height = current_absolute_liquid_heights[channel_idx] - absolute_heights_measurements[idx].append(height) - - # Compute liquid heights relative to well bottom - relative_to_well: List[float] = [] - inconsistent_ops: List[str] = [] - - for idx, container in enumerate(containers): - measurements = absolute_heights_measurements[idx] - valid = [m for m in measurements if m is not None] - cavity_bottom = container.get_absolute_location("c", "c", "cavity_bottom").z - - if len(valid) == 0: - relative_to_well.append(0.0) - elif len(valid) == len(measurements): - relative_to_well.append(sum(valid) / len(valid) - cavity_bottom) - else: - inconsistent_ops.append( - f"Operation {idx}: {len(valid)}/{len(measurements)} replicates detected liquid" - ) - - if inconsistent_ops: - raise RuntimeError( - "Inconsistent liquid detection across replicates. " - "This may indicate liquid levels near the detection limit:\n" + "\n".join(inconsistent_ops) - ) - - return relative_to_well - - def _get_maximum_minimum_spacing_between_channels(self, use_channels: List[int]) -> float: - """Get the maximum of the set of minimum spacing requirements between the channels being used""" - sorted_channels = sorted(use_channels) - max_channel_spacing = max( - self._min_spacing_between(hi, lo) for hi, lo in zip(sorted_channels[1:], sorted_channels[:-1]) - ) - return max_channel_spacing - - def _compute_channels_in_resource_locations( - self, - resources: Sequence[Resource], - use_channels: List[int], - offsets: Optional[List[Coordinate]], - ) -> List[Coordinate]: - """Compute absolute locations of resources with given offsets.""" - - # If no offset is provided but we can fit all channels inside a single resource, - # compute the offsets to make that happen using wide spacing. - if offsets is None: - if len(set(resources)) == 1 and len(use_channels) == len(set(use_channels)): - container_size_y = resources[0].get_absolute_size_y() - # For non-consecutive channels (e.g. [0,1,2,5,6,7]), we must account for - # phantom intermediate channels (3,4) that physically exist between them. - # Compute offsets for the full channel range (min to max), then pick only - # the offsets corresponding to the actual channels being used. - max_channel_spacing = self._get_maximum_minimum_spacing_between_channels(use_channels) - num_channels_in_span = max(use_channels) - min(use_channels) + 1 - min_required = MIN_SPACING_EDGE * 2 + (num_channels_in_span - 1) * max_channel_spacing - if container_size_y >= min_required: - all_offsets = get_wide_single_resource_liquid_op_offsets( - resource=resources[0], - num_channels=num_channels_in_span, - min_spacing=max_channel_spacing, - ) - min_ch = min(use_channels) - offsets = [all_offsets[ch - min_ch] for ch in use_channels] - - if num_channels_in_span % 2 != 0: - y_offset = 5.5 - offsets = [offset + Coordinate(0, y_offset, 0) for offset in offsets] - # else: container too small to fit all channels — fall back to center offsets. - # Y sub-batching will serialize channels that can't coexist. - - offsets = offsets or [Coordinate.zero()] * len(resources) - - # Compute positions for all resources - resource_locations = [ - resource.get_location_wrt(self.deck, x="c", y="c", z="b") + offset - for resource, offset in zip(resources, offsets) - ] - - return resource_locations - - async def execute_batched( # TODO: any hamilton liquid handler - self, - func: Callable[[List[int]], Awaitable[None]], - resources: List[Container], - use_channels: Optional[List[int]] = None, - resource_offsets: Optional[List[Coordinate]] = None, - min_traverse_height_during_command: Optional[float] = None, - ): - if use_channels is None: - use_channels = list(range(len(resources))) - - # precompute locations and batches - locations = self._compute_channels_in_resource_locations( - resources, use_channels, resource_offsets - ) - x_batches = group_by_x_batch_by_xy( - locations=locations, - use_channels=use_channels, - min_spacing_between_channels=self._min_spacing_between, - ) - - # loop over batches. keep track of channels used in previous batch to ensure they are raised to traverse height before next batch - prev_channels: Optional[List[int]] = None - - try: - for x_value, x_batch in x_batches.items(): - if prev_channels is not None: - await self._move_to_traverse_height( - channels=prev_channels, traverse_height=min_traverse_height_during_command - ) - await self.move_channel_x(0, x_value) - - for y_batch in x_batch: - if prev_channels is not None: - await self._move_to_traverse_height( - channels=prev_channels, traverse_height=min_traverse_height_during_command - ) - await self.position_channels_in_y_direction( - {use_channels[idx]: locations[idx].y for idx in y_batch}, - ) - - await func(y_batch) - - prev_channels = [use_channels[idx] for idx in y_batch] - except Exception: - await self.move_all_channels_in_z_safety() - raise - except BaseException: - await self.move_all_channels_in_z_safety() - raise - async def probe_liquid_heights( self, containers: List[Container], @@ -2269,12 +2042,40 @@ async def probe_liquid_heights( lld_mode: LLDMode = LLDMode.GAMMA, search_speed: float = 10.0, n_replicates: int = 1, + move_to_z_safety_after: bool = True, # Traverse height parameters (None = full Z safety, float = absolute Z position in mm) min_traverse_height_at_beginning_of_command: Optional[float] = None, min_traverse_height_during_command: Optional[float] = None, z_position_at_end_of_command: Optional[float] = None, - # Deprecated - move_to_z_safety_after: Optional[bool] = None, + # Shared detection parameters + channel_acceleration: float = 800.0, + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 0.0, + # cLLD-specific parameters (used when lld_mode=GAMMA) + detection_edge: int = 10, + detection_drop: int = 2, + # pLLD-specific parameters (used when lld_mode=PRESSURE) + channel_speed_above_start_pos_search: float = 120.0, + z_drive_current_limit: int = 3, + tip_has_filter: bool = False, + dispense_drive_speed: float = 5.0, + dispense_drive_acceleration: float = 0.2, + dispense_drive_max_speed: float = 14.5, + dispense_drive_current_limit: int = 3, + plld_detection_edge: int = 30, + plld_detection_drop: int = 10, + clld_verification: bool = False, + clld_detection_edge: int = 10, + clld_detection_drop: int = 2, + max_delta_plld_clld: float = 5.0, + plld_mode: Optional[PressureLLDMode] = None, # defaults to PressureLLDMode.LIQUID + plld_foam_detection_drop: int = 30, + plld_foam_detection_edge_tolerance: int = 30, + plld_foam_ad_values: int = 30, + plld_foam_search_speed: float = 10.0, + dispense_back_plld_volume: Optional[float] = None, + # X grouping tolerance (mm) — containers within this distance share an X group + x_grouping_tolerance: float = X_GROUPING_TOLERANCE_MM, ) -> List[float]: """Probe liquid surface heights in containers using liquid level detection. @@ -2282,6 +2083,12 @@ async def probe_liquid_heights( container positions and sensing the liquid surface. Heights are measured from the bottom of each container's cavity. + Automatically handles any channel/container configuration: + - Containers at different X positions are grouped and probed sequentially + - Channels are partitioned into parallel-compatible Y batches respecting per-channel + minimum spacing (supports mixed 1mL + 5mL channel configurations) + - Phantom channels between non-consecutive batch members are positioned automatically + Args: containers: List of Container objects to probe, one per channel. use_channels: Channel indices to use for probing (0-indexed). @@ -2291,98 +2098,274 @@ async def probe_liquid_heights( Defaults to capacitive. search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. n_replicates: Number of measurements per channel. Default 1. + move_to_z_safety_after: Whether to move channels to safe Z height after probing. + Default True. min_traverse_height_at_beginning_of_command: Absolute Z height (mm) to move involved channels to before the first batch. None (default) uses full Z safety. min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to - between batches (X groups and Y sub-batches). None (default) uses full Z safety. + between batches. None (default) uses full Z safety. z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after - probing. None (default) uses full Z safety. + probing (only used when move_to_z_safety_after is True). None (default) uses full Z safety. + channel_acceleration: Search acceleration in mm/s^2. Default 800.0. + post_detection_trajectory: Post-detection move mode (0 or 1). Default 1. + post_detection_dist: Distance in mm to move up after detection. Default 0.0. + detection_edge: cLLD edge steepness threshold (0-1023). Default 10. + detection_drop: cLLD offset after edge detection (0-1023). Default 2. + channel_speed_above_start_pos_search: pLLD speed above search start in mm/s. Default 120.0. + z_drive_current_limit: pLLD Z-drive current limit. Default 3. + tip_has_filter: Whether tip has a filter. Default False. + dispense_drive_speed: pLLD dispense drive speed in mm/s. Default 5.0. + dispense_drive_acceleration: pLLD dispense drive acceleration in mm/s^2. Default 0.2. + dispense_drive_max_speed: pLLD dispense drive max speed in mm/s. Default 14.5. + dispense_drive_current_limit: pLLD dispense drive current limit. Default 3. + plld_detection_edge: pLLD edge detection threshold. Default 30. + plld_detection_drop: pLLD detection drop. Default 10. + clld_verification: Enable cLLD verification in pLLD mode. Default False. + clld_detection_edge: cLLD verification edge threshold. Default 10. + clld_detection_drop: cLLD verification drop. Default 2. + max_delta_plld_clld: Max allowed delta between pLLD and cLLD in mm. Default 5.0. + plld_mode: Pressure LLD mode. Defaults to PressureLLDMode.LIQUID for pLLD. + plld_foam_detection_drop: Foam detection drop. Default 30. + plld_foam_detection_edge_tolerance: Foam detection edge tolerance. Default 30. + plld_foam_ad_values: Foam AD values. Default 30. + plld_foam_search_speed: Foam search speed in mm/s. Default 10.0. + dispense_back_plld_volume: Volume to dispense back after pLLD in uL. Default None. + x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed + together. Default 0.1 mm. Returns: Mean of measured liquid heights for each container (mm from cavity bottom). Raises: - RuntimeError: If channels lack tips. - - Notes: - - All specified channels must have tips attached - - Containers at different X positions are probed in sequential groups (single X carriage) - - For single containers with odd channel counts, Y-offsets are applied to avoid - center dividers (Hamilton 1000 uL spacing: 9mm, offset: 5.5mm) + ValueError: If ``use_channels`` is empty, contains out-of-range indices, contains + duplicates, or if input list lengths don't match. + RuntimeError: If any specified channel lacks a tip. """ - if move_to_z_safety_after is not None: - warnings.warn( - "The 'move_to_z_safety_after' parameter is deprecated and will be removed in a future release. " - "Use 'z_position_at_end_of_command' with an appropriate Z height instead. If not set, " - "the default behavior will be to move to full Z safety after the command.", - DeprecationWarning, - ) - - # Validate parameters. - if use_channels is None: - use_channels = list(range(len(containers))) - if len(use_channels) == 0: - raise ValueError("use_channels must not be empty.") - if not all(0 <= ch < self.num_channels for ch in use_channels): - raise ValueError( - f"All use_channels must be integers in range [0, {self.num_channels - 1}], " - f"got {use_channels}." - ) - + if n_replicates < 1: + raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") if lld_mode not in {self.LLDMode.GAMMA, self.LLDMode.PRESSURE}: raise ValueError(f"LLDMode must be 1 (capacitive) or 2 (pressure-based), is {lld_mode}") - if not len(containers) == len(use_channels): - raise ValueError( - "Length of containers and use_channels must match, " - f"got lengths {len(containers)}, {len(use_channels)}." - ) + use_channels = validate_probing_inputs( + containers=containers, + use_channels=use_channels, + num_channels=self.num_channels, + ) - # Validate resource_offsets length (if provided) to avoid silent truncation in downstream zips. if resource_offsets is not None and len(resource_offsets) != len(containers): raise ValueError( "Length of resource_offsets must match the length of containers and use_channels, " f"got lengths {len(resource_offsets)} (resource_offsets) and " f"{len(containers)} (containers/use_channels)." ) - # Make sure we have tips on all channels and know their lengths + + if resource_offsets is None: + resource_offsets = [Coordinate.zero()] * len(containers) + container_groups: Dict[int, List[int]] = defaultdict(list) + for idx, c in enumerate(containers): + container_groups[id(c)].append(idx) + for indices in container_groups.values(): + if len(indices) < 2: + continue + group_channels = [use_channels[i] for i in indices] + offsets = compute_single_container_offsets( + container=containers[indices[0]], + use_channels=group_channels, + channel_spacings=self._channels_minimum_y_spacing, + ) + if offsets is not None: + for i, idx_val in enumerate(indices): + resource_offsets[idx_val] = offsets[i] + + # Verify tips and query tip lengths tip_presence = await self.request_tip_presence() if not all(tip_presence[idx] for idx in use_channels): raise RuntimeError("All specified channels must have tips attached.") + tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] - # Move channels to traverse height - await self._move_to_traverse_height( - channels=use_channels, traverse_height=min_traverse_height_at_beginning_of_command - ) - - result_by_operation: Dict[int, float] = {} - - async def func(batch: List[int]): - liquid_heights = await self._probe_liquid_heights_batch( - containers=[containers[idx] for idx in batch], - use_channels=[use_channels[idx] for idx in batch], - lld_mode=lld_mode, - search_speed=search_speed, - n_replicates=n_replicates, + # Initial Z raise + await self.move_all_channels_in_z_safety() + if min_traverse_height_at_beginning_of_command is not None: + await self.position_channels_in_z_direction( + {ch: min_traverse_height_at_beginning_of_command for ch in use_channels} ) - for idx, height in zip(batch, liquid_heights): - result_by_operation[idx] = height - await self.execute_batched( - func=func, - resources=containers, + # Compute target positions + x_pos, y_pos = compute_positions(containers, resource_offsets, self.deck) + z_cavity_bottom: List[float] = [] + z_top: List[float] = [] + for resource in containers: + z_cavity_bottom.append(resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z) + z_top.append(resource.get_location_wrt(self.deck, "c", "c", "t").z) + + batches = plan_batches( use_channels=use_channels, - resource_offsets=resource_offsets, - min_traverse_height_during_command=min_traverse_height_during_command, + x_pos=x_pos, + y_pos=y_pos, + channel_spacings=self._channels_minimum_y_spacing, + x_tolerance=x_grouping_tolerance, + num_channels=self.num_channels, + max_y=self.extended_conf.pip_maximal_y_position, + min_y=self.extended_conf.left_arm_min_y_position, ) - await self._move_to_traverse_height( - channels=use_channels, - traverse_height=z_position_at_end_of_command, - ) + # Select detection function and kwargs + detect_func: Callable[..., Any] + if lld_mode == self.LLDMode.GAMMA: + detect_func = self._move_z_drive_to_liquid_surface_using_clld + extra_kwargs: dict = { + "detection_edge": detection_edge, + "detection_drop": detection_drop, + } + else: + detect_func = self._search_for_surface_using_plld + extra_kwargs = { + "channel_speed_above_start_pos_search": channel_speed_above_start_pos_search, + "z_drive_current_limit": z_drive_current_limit, + "tip_has_filter": tip_has_filter, + "dispense_drive_speed": dispense_drive_speed, + "dispense_drive_acceleration": dispense_drive_acceleration, + "dispense_drive_max_speed": dispense_drive_max_speed, + "dispense_drive_current_limit": dispense_drive_current_limit, + "plld_detection_edge": plld_detection_edge, + "plld_detection_drop": plld_detection_drop, + "clld_verification": clld_verification, + "clld_detection_edge": clld_detection_edge, + "clld_detection_drop": clld_detection_drop, + "max_delta_plld_clld": max_delta_plld_clld, + "plld_mode": plld_mode if plld_mode is not None else self.PressureLLDMode.LIQUID, + "plld_foam_detection_drop": plld_foam_detection_drop, + "plld_foam_detection_edge_tolerance": plld_foam_detection_edge_tolerance, + "plld_foam_ad_values": plld_foam_ad_values, + "plld_foam_search_speed": plld_foam_search_speed, + "dispense_back_plld_volume": dispense_back_plld_volume, + } - return [result_by_operation[idx] for idx in range(len(containers))] + # Execute batches + absolute_heights_measurements: Dict[int, List[Optional[float]]] = { + ch: [] for ch in use_channels + } + + try: + prev_batch: Optional[ChannelBatch] = None + for batch in batches: + # Raise previous batch's channels before repositioning + if prev_batch is not None: + if min_traverse_height_during_command is None: + await self.move_all_channels_in_z_safety() + else: + await self.position_channels_in_z_direction( + {ch: min_traverse_height_during_command for ch in prev_batch.channels} + ) + + # Move X carriage if needed (new X group or first batch) + if ( + prev_batch is None or abs(batch.x_position - prev_batch.x_position) > x_grouping_tolerance + ): + await self.move_channel_x(0, batch.x_position) + + # Position channels in Y (includes phantom channels from plan_batches) + await self.position_channels_in_y_direction(batch.y_positions) + + # Z search bounds from precomputed container positions + batch_lowest_immers = [ + z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH + for i in batch.indices + ] + batch_start_pos = [ + z_top[i] + + tip_lengths[i] + - self.DEFAULT_TIP_FITTING_DEPTH + + self.SEARCH_START_CLEARANCE_MM + for i in batch.indices + ] + + # Run detection n_replicates times + for _ in range(n_replicates): + results = await asyncio.gather( + *[ + detect_func( + channel_idx=channel, + lowest_immers_pos=lip, + start_pos_search=sps, + channel_speed=search_speed, + channel_acceleration=channel_acceleration, + post_detection_trajectory=post_detection_trajectory, + post_detection_dist=post_detection_dist, + **extra_kwargs, + ) + for channel, lip, sps in zip(batch.channels, batch_lowest_immers, batch_start_pos) + ], + return_exceptions=True, + ) + + current_absolute_liquid_heights = await self.request_pip_height_last_lld() + for local_idx, (ch_idx, result) in enumerate(zip(batch.channels, results)): + orig_idx = batch.indices[local_idx] + if isinstance(result, STARFirmwareError): + error_msg = str(result).lower() + if "no liquid level found" in error_msg or "no liquid was present" in error_msg: + height = None + msg = ( + f"Channel {ch_idx}: No liquid detected. Could be because there is " + f"no liquid in container {containers[orig_idx].name} or liquid level " + f"is too low." + ) + if lld_mode == self.LLDMode.GAMMA: + msg += " Consider using pressure-based LLD if liquid is believed to exist." + logger.warning(msg) + else: + raise result + elif isinstance(result, Exception): + raise result + else: + height = current_absolute_liquid_heights[ch_idx] + absolute_heights_measurements[ch_idx].append(height) + + prev_batch = batch + + except Exception: + await self.move_all_channels_in_z_safety() + raise + except BaseException: + await self.move_all_channels_in_z_safety() + raise + + # Compute liquid heights relative to well bottom + relative_to_well: List[float] = [] + inconsistent_channels: List[str] = [] + + for idx, (ch, container) in enumerate(zip(use_channels, containers)): + measurements = absolute_heights_measurements[ch] + valid = [m for m in measurements if m is not None] + cavity_bottom = z_cavity_bottom[idx] + + if len(valid) == 0: + relative_to_well.append(0.0) + elif len(valid) == len(measurements): + relative_to_well.append(sum(valid) / len(valid) - cavity_bottom) + else: + inconsistent_channels.append( + f"Channel {ch}: {len(valid)}/{len(measurements)} replicates detected liquid" + ) + + if inconsistent_channels: + raise RuntimeError( + "Inconsistent liquid detection across replicates. " + "This may indicate liquid levels near the detection limit:\n" + + "\n".join(inconsistent_channels) + ) + + if move_to_z_safety_after: + if z_position_at_end_of_command is None: + await self.move_all_channels_in_z_safety() + else: + await self.position_channels_in_z_direction( + {ch: z_position_at_end_of_command for ch in use_channels} + ) + + return relative_to_well async def probe_liquid_volumes( self, @@ -10484,7 +10467,8 @@ async def clld_probe_y_position_using_channel( # Machine-compatibility check of calculated parameters assert 0 <= max_y_search_pos_increments <= 13_714, ( "Maximum y search position must be between \n0 and" - + f"{STARBackend.y_drive_increment_to_mm(13_714) + 9} mm, is {max_y_search_pos_increments} mm" + + f"{STARBackend.y_drive_increment_to_mm(13_714) + self._channels_minimum_y_spacing[0]} mm," + + f" is {max_y_search_pos_increments} mm" ) assert 20 <= channel_speed_increments <= 8_000, ( f"LLD search speed must be between \n{STARBackend.y_drive_increment_to_mm(20)}" @@ -11216,6 +11200,7 @@ async def request_tip_len_on_channel(self, channel_idx: int) -> float: MAXIMUM_CHANNEL_Z_POSITION = 334.7 # mm (= z-drive increment 31_200) MINIMUM_CHANNEL_Z_POSITION = 99.98 # mm (= z-drive increment 9_320) DEFAULT_TIP_FITTING_DEPTH = 8 # mm, for 10, 50, 300, 1000 ul Hamilton tips + SEARCH_START_CLEARANCE_MM = 5 # mm above container top for LLD search start position async def ztouch_probe_z_height_using_channel( self, @@ -11407,10 +11392,9 @@ async def get_channels_y_positions(self) -> Dict[int, float]: y_positions = [round(y / 10, 2) for y in resp["ry"]] # sometimes there is (likely) a floating point error and channels are reported to be - # less than their minimum spacing apart (typically 9 mm). (When you set channels using - # position_channels_in_y_direction, it will raise an error.) The minimum y is 6mm, - # so we fix that first (in case that value is misreported). Then, we traverse the - # list in reverse and enforce pairwise minimum spacing. + # less than 9mm apart. (When you set channels using position_channels_in_y_direction, + # it will raise an error.) The minimum y is 6mm, so we fix that first (in case that + # value is misreported). Then, we traverse the list in reverse and set the min_diff. min_y = self.extended_conf.left_arm_min_y_position if y_positions[-1] < min_y - 0.2: raise RuntimeError( @@ -11423,9 +11407,9 @@ async def get_channels_y_positions(self) -> Dict[int, float]: y_positions[-1] = min_y for i in range(len(y_positions) - 2, -1, -1): - spacing = self._min_spacing_between(i, i + 1) - if y_positions[i] - y_positions[i + 1] < spacing: - y_positions[i] = y_positions[i + 1] + spacing + min_diff = self._min_spacing_between(i, i + 1) + if y_positions[i] - y_positions[i + 1] < min_diff: + y_positions[i] = y_positions[i + 1] + min_diff return {channel_idx: y for channel_idx, y in enumerate(y_positions)} @@ -11451,37 +11435,32 @@ async def position_channels_in_y_direction(self, ys: Dict[int, float], make_spac channel_locations[channel_idx] = y if make_space: + # For the channels to the back of `back_channel`, make sure the space between them + # meets the per-pair minimum. We start with the channel closest to `back_channel`, and + # make sure the channel behind it is spaced correctly, updating if needed. use_channels = list(ys.keys()) back_channel = min(use_channels) - front_channel = max(use_channels) + for channel_idx in range(back_channel, 0, -1): + pair_spacing = self._min_spacing_between(channel_idx - 1, channel_idx) + if (channel_locations[channel_idx - 1] - channel_locations[channel_idx]) < pair_spacing: + channel_locations[channel_idx - 1] = channel_locations[channel_idx] + pair_spacing - # Position channels in between used channels + # Position intermediate channels between back_channel and front_channel. + front_channel = max(use_channels) for intermediate_ch in range(back_channel + 1, front_channel): if intermediate_ch not in ys: - channel_locations[intermediate_ch] = channel_locations[ - intermediate_ch - 1 - ] - self._min_spacing_between(intermediate_ch - 1, intermediate_ch) - - # For the channels to the back of `back_channel`, make sure the space between them is - # >=9mm. We start with the channel closest to `back_channel`, and make sure the - # channel behind it is at least 9mm, updating if needed. Iterating from the front (closest - # to `back_channel`) to the back (channel 0), all channels are put at the correct location. - # This order matters because the channel in front of any channel may have been moved in the - # previous iteration. - # Note that if a channel is already spaced at >=9mm, it is not moved. - for channel_idx in range(back_channel, 0, -1): - spacing = self._min_spacing_between(channel_idx - 1, channel_idx) - if (channel_locations[channel_idx - 1] - channel_locations[channel_idx]) < spacing: - channel_locations[channel_idx - 1] = channel_locations[channel_idx] + spacing + pair_spacing = self._min_spacing_between(intermediate_ch - 1, intermediate_ch) + channel_locations[intermediate_ch] = ( + channel_locations[intermediate_ch - 1] - pair_spacing + ) # Similarly for the channels to the front of `front_channel`, make sure they are all - # spaced >= channel_minimum_y_spacing (usually 9mm) apart. This time, we iterate from - # back (closest to `front_channel`) to the front (lh.backend.num_channels - 1), and - # put each channel >= channel_minimum_y_spacing before the one behind it. + # spaced by the per-pair minimum. This time, we iterate from back (closest to + # `front_channel`) to the front (lh.backend.num_channels - 1). for channel_idx in range(front_channel, self.num_channels - 1): - spacing = self._min_spacing_between(channel_idx, channel_idx + 1) - if (channel_locations[channel_idx] - channel_locations[channel_idx + 1]) < spacing: - channel_locations[channel_idx + 1] = channel_locations[channel_idx] - spacing + pair_spacing = self._min_spacing_between(channel_idx, channel_idx + 1) + if (channel_locations[channel_idx] - channel_locations[channel_idx + 1]) < pair_spacing: + channel_locations[channel_idx + 1] = channel_locations[channel_idx] - pair_spacing # Quick checks before movement. if channel_locations[0] > 650: @@ -11567,7 +11546,7 @@ async def pierce_foil( offsets = get_wide_single_resource_liquid_op_offsets( resource=well, num_channels=len(piercing_channels), - min_spacing=self._get_maximum_minimum_spacing_between_channels(piercing_channels), + min_spacing=max(self._channels_minimum_y_spacing), ) else: offsets = get_tight_single_resource_liquid_op_offsets( diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index fc642de8b33..c5d5a776333 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -12,8 +12,18 @@ MachineConfiguration, STARBackend, ) +from pylabrobot.liquid_handling.pipette_batch_scheduling import ( + X_GROUPING_TOLERANCE_MM, + validate_probing_inputs, +) +from pylabrobot.resources.container import Container +from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.well import Well +# Type aliases for nested enums (for cleaner signatures) +LLDMode = STARBackend.LLDMode +PressureLLDMode = STARBackend.PressureLLDMode + _DEFAULT_MACHINE_CONFIGURATION = MachineConfiguration( pip_type_1000ul=True, kb_iswap_installed=True, @@ -213,6 +223,10 @@ async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: ) return self._channels_minimum_y_spacing[channel_idx] + async def channels_request_y_minimum_spacing(self) -> List[float]: + """Return mock per-channel minimum Y spacings for all channels.""" + return list(self._channels_minimum_y_spacing) + async def move_channel_y(self, channel: int, y: float): print(f"moving channel {channel} to y: {y}") @@ -316,3 +330,118 @@ async def position_channels_in_y_direction(self, ys, make_space=True): async def request_pip_height_last_lld(self): return list(range(12)) + + async def probe_liquid_heights( + self, + containers: List[Container], + use_channels: Optional[List[int]] = None, + resource_offsets: Optional[List[Coordinate]] = None, + lld_mode: LLDMode = LLDMode.GAMMA, + search_speed: float = 10.0, + n_replicates: int = 1, + move_to_z_safety_after: bool = True, + min_traverse_height_at_beginning_of_command: Optional[float] = None, + min_traverse_height_during_command: Optional[float] = None, + z_position_at_end_of_command: Optional[float] = None, + channel_acceleration: float = 800.0, + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 0.0, + detection_edge: int = 10, + detection_drop: int = 2, + channel_speed_above_start_pos_search: float = 120.0, + z_drive_current_limit: int = 3, + tip_has_filter: bool = False, + dispense_drive_speed: float = 5.0, + dispense_drive_acceleration: float = 0.2, + dispense_drive_max_speed: float = 14.5, + dispense_drive_current_limit: int = 3, + plld_detection_edge: int = 30, + plld_detection_drop: int = 10, + clld_verification: bool = False, + clld_detection_edge: int = 10, + clld_detection_drop: int = 2, + max_delta_plld_clld: float = 5.0, + plld_mode: Optional[PressureLLDMode] = None, + plld_foam_detection_drop: int = 30, + plld_foam_detection_edge_tolerance: int = 30, + plld_foam_ad_values: int = 30, + plld_foam_search_speed: float = 10.0, + dispense_back_plld_volume: Optional[float] = None, + x_grouping_tolerance: float = X_GROUPING_TOLERANCE_MM, + ) -> List[float]: + """Probe liquid heights by computing from tracked container volumes. + + Instead of simulating hardware LLD, this mock computes liquid heights directly from + each container's volume tracker using ``container.compute_height_from_volume()``. + + Args: + containers: List of Container objects to probe, one per channel. + use_channels: Channel indices to use (0-indexed). Defaults to ``[0, ..., len(containers)-1]``. + resource_offsets: Accepted for API compatibility but unused in mock. + All other parameters: Accepted for API compatibility but unused in mock. + + Returns: + Liquid heights in mm from cavity bottom for each container, computed from tracked volumes. + + Raises: + ValueError: If ``use_channels`` is empty, contains out-of-range indices, or if + ``containers`` and ``use_channels`` have different lengths. + NoTipError: If any specified channel lacks a tip. + """ + # Unused parameters kept for signature compatibility: + _ = ( + lld_mode, + search_speed, + n_replicates, + move_to_z_safety_after, + min_traverse_height_at_beginning_of_command, + min_traverse_height_during_command, + z_position_at_end_of_command, + channel_acceleration, + post_detection_trajectory, + post_detection_dist, + detection_edge, + detection_drop, + channel_speed_above_start_pos_search, + z_drive_current_limit, + tip_has_filter, + dispense_drive_speed, + dispense_drive_acceleration, + dispense_drive_max_speed, + dispense_drive_current_limit, + plld_detection_edge, + plld_detection_drop, + clld_verification, + clld_detection_edge, + clld_detection_drop, + max_delta_plld_clld, + plld_mode, + plld_foam_detection_drop, + plld_foam_detection_edge_tolerance, + plld_foam_ad_values, + plld_foam_search_speed, + dispense_back_plld_volume, + x_grouping_tolerance, + ) + use_channels = validate_probing_inputs( + containers=containers, + use_channels=use_channels, + num_channels=self.num_channels, + ) + + # Validate tip presence using tip tracker + for ch in use_channels: + self.head[ch].get_tip() # Raises NoTipError if no tip + + heights: List[float] = [] + for container in containers: + volume = container.tracker.get_used_volume() + if volume == 0: + heights.append(0.0) + else: + height = container.compute_height_from_volume(volume) + heights.append(height) + + print(f"probe_liquid_heights: {[f'{h:.2f}' for h in heights]} mm") + return heights + diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index b478ff7b637..529eb17b470 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -1,5 +1,6 @@ # mypy: disable-error-code="attr-defined,method-assign" +import contextlib import unittest import unittest.mock from typing import Literal, cast @@ -39,7 +40,11 @@ UnknownHamiltonError, parse_star_fw_string, ) -from .STAR_chatterbox import _DEFAULT_EXTENDED_CONFIGURATION, _DEFAULT_MACHINE_CONFIGURATION +from .STAR_chatterbox import ( + STARChatterboxBackend, + _DEFAULT_EXTENDED_CONFIGURATION, + _DEFAULT_MACHINE_CONFIGURATION, +) class TestSTARResponseParsing(unittest.TestCase): @@ -1530,110 +1535,8 @@ async def test_1000uL_tips(self): tip_rack.unassign() -class TestChannelsMinimumYSpacing(unittest.IsolatedAsyncioTestCase): - """Test that different channel spacing configurations produce different behavior. - - Real firmware VY responses captured from hardware (GitHub issue #822): - - 4-channel 18mm single-rail: PVYidyc194 388 1 (yc[1]=388 → 18.0mm) - - 8-channel 9mm standard: PVYidyc000 194 0 (yc[1]=194 → 9.0mm) - """ - - # -- can_reach_position: reachability shrinks with wider spacing ---------------- - - async def test_can_reach_4ch_18mm_rejects_position_reachable_at_9mm(self): - """A position reachable by channel 0 at 9mm spacing is unreachable at 18mm spacing. - - Channel 0 (backmost) min_y = left_arm_min_y_position + sum(spacings[1..3]) - At 9mm: 6 + 9*3 = 33 → y=33 reachable - At 18mm: 6 + 18*3 = 60 → y=33 unreachable - """ - backend = STARBackend() - backend._num_channels = 4 - backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION - - backend._channels_minimum_y_spacing = [9.0] * 4 - self.assertTrue(backend.can_reach_position(0, Coordinate(100, 33, 100))) - - backend._channels_minimum_y_spacing = [18.0] * 4 - self.assertFalse(backend.can_reach_position(0, Coordinate(100, 33, 100))) - - async def test_can_reach_4ch_18mm_rejects_back_channel_too_far_back(self): - """At 18mm spacing, the backmost channel has a lower max_y than at 9mm. - - Channel 3 (frontmost) max_y = pip_maximal_y_position - sum(spacings[0..2]) - At 9mm: 606.5 - 9*3 = 579.5 → y=574 reachable - At 18mm: 606.5 - 18*3 = 552.5 → y=574 unreachable - """ - backend = STARBackend() - backend._num_channels = 4 - backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION - - backend._channels_minimum_y_spacing = [9.0] * 4 - self.assertTrue(backend.can_reach_position(3, Coordinate(100, 574, 100))) - - backend._channels_minimum_y_spacing = [18.0] * 4 - self.assertFalse(backend.can_reach_position(3, Coordinate(100, 574, 100))) - - # -- position_channels_in_y_direction: validation rejects tight positions ------- - - def _make_star_backend(self, num_channels, spacings): - """Helper: create a STARBackend with given channel count and spacings, mocking I/O.""" - backend = STARBackend() - backend._num_channels = num_channels - backend._channels_minimum_y_spacing = list(spacings) - backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION - backend.id_ = 0 - backend._write_and_read_command = unittest.mock.AsyncMock() - backend.get_channels_y_positions = unittest.mock.AsyncMock() - return backend - - async def test_position_channels_rejects_9mm_gap_when_spacing_is_18mm(self): - """With make_space=False, channels 9mm apart pass validation at 9mm but are rejected at 18mm.""" - spread_positions = {0: 100.0, 1: 91.0, 2: 82.0, 3: 73.0} - - # At 9mm: channels spaced 9mm apart → valid, JY command is sent. - backend_9 = self._make_star_backend(4, [9.0] * 4) - backend_9.get_channels_y_positions.return_value = dict(spread_positions) - await backend_9.position_channels_in_y_direction(spread_positions, make_space=False) - self.assertTrue(backend_9._write_and_read_command.called) - - # At 18mm: same positions → rejected. - backend_18 = self._make_star_backend(4, [18.0] * 4) - backend_18.get_channels_y_positions.return_value = dict(spread_positions) - with self.assertRaises(ValueError): - await backend_18.position_channels_in_y_direction(spread_positions, make_space=False) - - async def test_position_channels_make_space_spreads_wider_at_18mm(self): - """make_space=True pushes non-target channels further apart at 18mm than at 9mm. - - Move only channel 2 to y=40. make_space adjusts channels 3 (in front of channel 2) - to respect minimum spacing. At 9mm it pushes channel 3 to 31, at 18mm to 22. - """ - current = {0: 300.0, 1: 200.0, 2: 100.0, 3: 50.0} - requested = {2: 40.0} - - # At 9mm: channel 3 must be ≤ 40 - 9 = 31. - backend_9 = self._make_star_backend(4, [9.0] * 4) - backend_9.get_channels_y_positions.return_value = dict(current) - await backend_9.position_channels_in_y_direction(dict(requested), make_space=True) - cmd_9mm = backend_9._write_and_read_command.call_args.kwargs["cmd"] - # Channel 3 pushed to 31.0 → 310 increments. - self.assertIn("0310", cmd_9mm) - - # At 18mm: channel 3 must be ≤ 40 - 18 = 22. - backend_18 = self._make_star_backend(4, [18.0] * 4) - backend_18.get_channels_y_positions.return_value = dict(current) - await backend_18.position_channels_in_y_direction(dict(requested), make_space=True) - cmd_18mm = backend_18._write_and_read_command.call_args.kwargs["cmd"] - # Channel 3 pushed to 22.0 → 220 increments. - self.assertIn("0220", cmd_18mm) - - # The JY commands must differ. - self.assertNotEqual(cmd_9mm, cmd_18mm) - - -class STARTestBase(unittest.IsolatedAsyncioTestCase): - """Shared setup for probe/batch/helper tests.""" +class TestProbeLiquidHeights(unittest.IsolatedAsyncioTestCase): + """Tests for probe_liquid_heights: detection dispatch, replicates, error handling.""" async def asyncSetUp(self): self.STAR = STARBackend(read_timeout=1) @@ -1671,179 +1574,59 @@ def _put_tips_on_channels(self, channels): tip = self.tip_rack.get_tip("A1") self.lh.update_head_state({ch: tip for ch in channels}) + def _standard_mocks(self, detect_side_effect=None): + """Return a context manager stack with standard mocks for probe_liquid_heights.""" + mocks = {} -class TestMoveToTraverseHeight(STARTestBase): - async def test_none_calls_z_safety(self): - with unittest.mock.patch.object( - self.STAR, "move_all_channels_in_z_safety", new_callable=unittest.mock.AsyncMock - ) as mock_z_safety: - await self.STAR._move_to_traverse_height(channels=[0, 1], traverse_height=None) - mock_z_safety.assert_awaited_once() - - async def test_float_calls_position_z(self): - with unittest.mock.patch.object( - self.STAR, "position_channels_in_z_direction", new_callable=unittest.mock.AsyncMock - ) as mock_pos_z: - await self.STAR._move_to_traverse_height(channels=[0, 2], traverse_height=245.0) - mock_pos_z.assert_awaited_once_with({0: 245.0, 2: 245.0}) - - -class TestComputeChannelsInResourceLocations(STARTestBase): - async def test_explicit_offsets(self): - wells = [self.plate.get_item("A1"), self.plate.get_item("B1")] - offsets = [Coordinate(0, 0, 0), Coordinate(0, 0, 0)] - locs = self.STAR._compute_channels_in_resource_locations( - resources=wells, use_channels=[0, 1], offsets=offsets - ) - self.assertEqual(len(locs), 2) - self.assertAlmostEqual(locs[0].x, 298.3, places=1) - self.assertAlmostEqual(locs[0].y, 145.7, places=1) - self.assertAlmostEqual(locs[1].x, 298.3, places=1) - self.assertAlmostEqual(locs[1].y, 136.7, places=1) - - async def test_none_offsets_single_resource(self): - """Same resource twice: both channels get the well center (offset auto-calc falls back to zero for small wells).""" - well = self.plate.get_item("A1") - locs = self.STAR._compute_channels_in_resource_locations( - resources=[well, well], use_channels=[0, 1], offsets=None - ) - self.assertEqual(len(locs), 2) - self.assertAlmostEqual(locs[0].x, 298.3, places=1) - self.assertAlmostEqual(locs[0].y, 145.7, places=1) - self.assertAlmostEqual(locs[1].x, 298.3, places=1) - self.assertAlmostEqual(locs[1].y, 145.7, places=1) - - async def test_none_offsets_different_resources(self): - """Different resources with no offsets: each gets its own center.""" - well_a1 = self.plate.get_item("A1") - well_b1 = self.plate.get_item("B1") - locs = self.STAR._compute_channels_in_resource_locations( - resources=[well_a1, well_b1], use_channels=[0, 1], offsets=None - ) - self.assertEqual(len(locs), 2) - self.assertAlmostEqual(locs[0].x, 298.3, places=1) - self.assertAlmostEqual(locs[0].y, 145.7, places=1) - self.assertAlmostEqual(locs[1].x, 298.3, places=1) - self.assertAlmostEqual(locs[1].y, 136.7, places=1) - - -class TestExecuteBatched(STARTestBase): - async def test_single_batch(self): - well = self.plate.get_item("A1") - calls = [] - - async def func(batch): - calls.append(batch) - - self._put_tips_on_channels([0, 1]) - - with ( - unittest.mock.patch.object(self.STAR, "move_channel_x", new_callable=unittest.mock.AsyncMock), - unittest.mock.patch.object( - self.STAR, "position_channels_in_y_direction", new_callable=unittest.mock.AsyncMock - ), - unittest.mock.patch.object( - self.STAR, "move_all_channels_in_z_safety", new_callable=unittest.mock.AsyncMock - ), - ): - await self.STAR.execute_batched( - func=func, - resources=[well, well], - use_channels=[0, 1], - ) - - all_indices = [i for call in calls for i in call] - self.assertEqual(sorted(all_indices), [0, 1]) - - async def test_different_x_groups(self): - well_a1 = self.plate.get_item("A1") - well_a2 = self.plate.get_item("A2") - calls = [] - - async def func(batch): - calls.append(list(batch)) - - self._put_tips_on_channels([0, 1]) - - with ( - unittest.mock.patch.object(self.STAR, "move_channel_x", new_callable=unittest.mock.AsyncMock), - unittest.mock.patch.object( - self.STAR, "position_channels_in_y_direction", new_callable=unittest.mock.AsyncMock - ), - unittest.mock.patch.object( - self.STAR, "move_all_channels_in_z_safety", new_callable=unittest.mock.AsyncMock - ), - ): - await self.STAR.execute_batched( - func=func, - resources=[well_a1, well_a2], - use_channels=[0, 1], - resource_offsets=[Coordinate.zero(), Coordinate.zero()], - ) - - all_indices = [i for call in calls for i in call] - self.assertEqual(sorted(all_indices), [0, 1]) - - async def test_traverse_height(self): - well_a1 = self.plate.get_item("A1") - well_a2 = self.plate.get_item("A2") - - async def func(batch): - pass - - self._put_tips_on_channels([0, 1]) - - with ( - unittest.mock.patch.object(self.STAR, "move_channel_x", new_callable=unittest.mock.AsyncMock), - unittest.mock.patch.object( - self.STAR, "position_channels_in_y_direction", new_callable=unittest.mock.AsyncMock - ), - unittest.mock.patch.object( - self.STAR, "_move_to_traverse_height", new_callable=unittest.mock.AsyncMock - ) as mock_traverse, - ): - await self.STAR.execute_batched( - func=func, - resources=[well_a1, well_a2], - use_channels=[0, 1], - resource_offsets=[Coordinate.zero(), Coordinate.zero()], - min_traverse_height_during_command=200.0, - ) - - if mock_traverse.await_count > 0: - for call in mock_traverse.call_args_list: - self.assertEqual(call.kwargs.get("traverse_height"), 200.0) - + if detect_side_effect is None: + detect_side_effect = unittest.mock.AsyncMock(return_value=None) + mocks["detect"] = unittest.mock.patch.object( + self.STAR, "_move_z_drive_to_liquid_surface_using_clld", detect_side_effect + ) + mocks["plld"] = unittest.mock.patch.object( + self.STAR, "_search_for_surface_using_plld", + new_callable=unittest.mock.AsyncMock, return_value=None, + ) + mocks["pip_height"] = unittest.mock.patch.object( + self.STAR, "request_pip_height_last_lld", + new_callable=unittest.mock.AsyncMock, return_value=list(range(12)), + ) + mocks["tip_len"] = unittest.mock.patch.object( + self.STAR, "request_tip_len_on_channel", + new_callable=unittest.mock.AsyncMock, return_value=59.9, + ) + mocks["tip_presence"] = unittest.mock.patch.object( + self.STAR, "request_tip_presence", + new_callable=unittest.mock.AsyncMock, return_value={i: True for i in range(8)}, + ) + mocks["z_safety"] = unittest.mock.patch.object( + self.STAR, "move_all_channels_in_z_safety", + new_callable=unittest.mock.AsyncMock, + ) + mocks["move_x"] = unittest.mock.patch.object( + self.STAR, "move_channel_x", + new_callable=unittest.mock.AsyncMock, + ) + mocks["pos_y"] = unittest.mock.patch.object( + self.STAR, "position_channels_in_y_direction", + new_callable=unittest.mock.AsyncMock, + ) + mocks["backmost_y"] = unittest.mock.patch.object( + self.STAR.extended_conf, "pip_maximal_y_position", 606.5, + ) + return mocks -class TestProbeLiquidHeightsBatch(STARTestBase): async def test_single_well_returns_height(self): well = self.plate.get_item("A1") self._put_tips_on_channels([0]) - with ( - unittest.mock.patch.object( - self.STAR, - "_move_z_drive_to_liquid_surface_using_clld", - new_callable=unittest.mock.AsyncMock, - return_value=None, - ), - unittest.mock.patch.object( - self.STAR, - "request_pip_height_last_lld", - new_callable=unittest.mock.AsyncMock, - return_value=list(range(12)), - ), - unittest.mock.patch.object( - self.STAR, - "request_tip_len_on_channel", - new_callable=unittest.mock.AsyncMock, - return_value=59.9, - ), - ): - result = await self.STAR._probe_liquid_heights_batch(containers=[well], use_channels=[0]) + mocks = self._standard_mocks() + with contextlib.ExitStack() as stack: + entered = {k: stack.enter_context(v) for k, v in mocks.items()} + result = await self.STAR.probe_liquid_heights(containers=[well], use_channels=[0]) # request_pip_height_last_lld returns list(range(12)), so channel 0 gets height 0. - # relative = 0 - cavity_bottom_z = 0 - 186.65 = -186.65 + # relative = 0 - cavity_bottom_z self.assertEqual(len(result), 1) self.assertAlmostEqual(result[0], 0 - well.get_absolute_location("c", "c", "cavity_bottom").z) @@ -1852,24 +1635,10 @@ async def test_n_replicates(self): self._put_tips_on_channels([0]) mock_detect = unittest.mock.AsyncMock(return_value=None) - with ( - unittest.mock.patch.object( - self.STAR, "_move_z_drive_to_liquid_surface_using_clld", mock_detect - ), - unittest.mock.patch.object( - self.STAR, - "request_pip_height_last_lld", - new_callable=unittest.mock.AsyncMock, - return_value=list(range(12)), - ), - unittest.mock.patch.object( - self.STAR, - "request_tip_len_on_channel", - new_callable=unittest.mock.AsyncMock, - return_value=59.9, - ), - ): - await self.STAR._probe_liquid_heights_batch( + mocks = self._standard_mocks(detect_side_effect=mock_detect) + with contextlib.ExitStack() as stack: + entered = {k: stack.enter_context(v) for k, v in mocks.items()} + await self.STAR.probe_liquid_heights( containers=[well], use_channels=[0], n_replicates=3 ) @@ -1894,26 +1663,12 @@ async def test_no_liquid_detected_returns_zero(self): async def raise_error(**kwargs): raise error - with ( - unittest.mock.patch.object( - self.STAR, - "_move_z_drive_to_liquid_surface_using_clld", - unittest.mock.AsyncMock(side_effect=raise_error), - ), - unittest.mock.patch.object( - self.STAR, - "request_pip_height_last_lld", - new_callable=unittest.mock.AsyncMock, - return_value=list(range(12)), - ), - unittest.mock.patch.object( - self.STAR, - "request_tip_len_on_channel", - new_callable=unittest.mock.AsyncMock, - return_value=59.9, - ), - ): - result = await self.STAR._probe_liquid_heights_batch(containers=[well], use_channels=[0]) + mocks = self._standard_mocks( + detect_side_effect=unittest.mock.AsyncMock(side_effect=raise_error) + ) + with contextlib.ExitStack() as stack: + entered = {k: stack.enter_context(v) for k, v in mocks.items()} + result = await self.STAR.probe_liquid_heights(containers=[well], use_channels=[0]) self.assertEqual(result[0], 0.0) @@ -1942,27 +1697,13 @@ async def side_effect(**kwargs): return None raise error - with ( - unittest.mock.patch.object( - self.STAR, - "_move_z_drive_to_liquid_surface_using_clld", - unittest.mock.AsyncMock(side_effect=side_effect), - ), - unittest.mock.patch.object( - self.STAR, - "request_pip_height_last_lld", - new_callable=unittest.mock.AsyncMock, - return_value=list(range(12)), - ), - unittest.mock.patch.object( - self.STAR, - "request_tip_len_on_channel", - new_callable=unittest.mock.AsyncMock, - return_value=59.9, - ), - ): + mocks = self._standard_mocks( + detect_side_effect=unittest.mock.AsyncMock(side_effect=side_effect) + ) + with contextlib.ExitStack() as stack: + entered = {k: stack.enter_context(v) for k, v in mocks.items()} with self.assertRaises(RuntimeError): - await self.STAR._probe_liquid_heights_batch( + await self.STAR.probe_liquid_heights( containers=[well], use_channels=[0], n_replicates=2 ) @@ -1970,30 +1711,116 @@ async def test_pressure_lld_mode(self): well = self.plate.get_item("A1") self._put_tips_on_channels([0]) - with ( - unittest.mock.patch.object( - self.STAR, - "_search_for_surface_using_plld", - new_callable=unittest.mock.AsyncMock, - return_value=None, - ) as mock_plld, - unittest.mock.patch.object( - self.STAR, - "request_pip_height_last_lld", - new_callable=unittest.mock.AsyncMock, - return_value=list(range(12)), - ), - unittest.mock.patch.object( - self.STAR, - "request_tip_len_on_channel", - new_callable=unittest.mock.AsyncMock, - return_value=59.9, - ), - ): - await self.STAR._probe_liquid_heights_batch( + mocks = self._standard_mocks() + with contextlib.ExitStack() as stack: + entered = {k: stack.enter_context(v) for k, v in mocks.items()} + await self.STAR.probe_liquid_heights( containers=[well], use_channels=[0], lld_mode=self.STAR.LLDMode.PRESSURE, ) - mock_plld.assert_awaited_once() + entered["plld"].assert_awaited_once() + + +class TestChannelsMinimumYSpacing(unittest.IsolatedAsyncioTestCase): + """Test that different channel spacing configurations produce different behavior. + + Real firmware VY responses captured from hardware (GitHub issue #822): + - 4-channel 18mm single-rail: PVYidyc194 388 1 (yc[1]=388 → 18.0mm) + - 8-channel 9mm standard: PVYidyc000 194 0 (yc[1]=194 → 9.0mm) + """ + + # -- can_reach_position: reachability shrinks with wider spacing ---------------- + + async def test_can_reach_4ch_18mm_rejects_position_reachable_at_9mm(self): + """A position reachable by channel 0 at 9mm spacing is unreachable at 18mm spacing. + + Channel 0 (backmost) max_y = 601.6 - sum(spacings[0..0]) = 601.6 - 0 = 601.6 (same) + Channel 0 (backmost) min_y = 6 + sum(spacings[1..3]) + At 9mm: 6 + 9*3 = 33 → y=33 reachable + At 18mm: 6 + 18*3 = 60 → y=33 unreachable + """ + backend = STARBackend() + backend._num_channels = 4 + backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + + backend._channels_minimum_y_spacing = [9.0] * 4 + self.assertTrue(backend.can_reach_position(0, Coordinate(100, 33, 100))) + + backend._channels_minimum_y_spacing = [18.0] * 4 + self.assertFalse(backend.can_reach_position(0, Coordinate(100, 33, 100))) + + async def test_can_reach_4ch_18mm_rejects_back_channel_too_far_back(self): + """At 18mm spacing, the backmost channel has a lower max_y than at 9mm. + + Channel 3 (frontmost) max_y = pip_maximal_y_position - sum(spacings[0..2]) + At 9mm: 606.5 - 9*3 = 579.5 → y=574 reachable + At 18mm: 606.5 - 18*3 = 552.5 → y=574 unreachable + """ + backend = STARBackend() + backend._num_channels = 4 + backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + + backend._channels_minimum_y_spacing = [9.0] * 4 + self.assertTrue(backend.can_reach_position(3, Coordinate(100, 574, 100))) + + backend._channels_minimum_y_spacing = [18.0] * 4 + self.assertFalse(backend.can_reach_position(3, Coordinate(100, 574, 100))) + + # -- position_channels_in_y_direction: validation rejects tight positions ------- + + def _make_star_backend(self, num_channels, spacings): + """Helper: create a STARBackend with given channel count and spacings, mocking I/O.""" + backend = STARBackend() + backend._num_channels = num_channels + backend._channels_minimum_y_spacing = list(spacings) + backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + backend.id_ = 0 + backend._write_and_read_command = unittest.mock.AsyncMock() + backend.get_channels_y_positions = unittest.mock.AsyncMock() + return backend + + async def test_position_channels_rejects_9mm_gap_when_spacing_is_18mm(self): + """With make_space=False, channels 9mm apart pass validation at 9mm but are rejected at 18mm.""" + spread_positions = {0: 100.0, 1: 91.0, 2: 82.0, 3: 73.0} + + # At 9mm: channels spaced 9mm apart → valid, JY command is sent. + backend_9 = self._make_star_backend(4, [9.0] * 4) + backend_9.get_channels_y_positions.return_value = dict(spread_positions) + await backend_9.position_channels_in_y_direction(spread_positions, make_space=False) + self.assertTrue(backend_9._write_and_read_command.called) + + # At 18mm: same positions → rejected. + backend_18 = self._make_star_backend(4, [18.0] * 4) + backend_18.get_channels_y_positions.return_value = dict(spread_positions) + with self.assertRaises(ValueError): + await backend_18.position_channels_in_y_direction(spread_positions, make_space=False) + + async def test_position_channels_make_space_spreads_wider_at_18mm(self): + """make_space=True pushes non-target channels further apart at 18mm than at 9mm. + + Move only channel 2 to y=40. make_space adjusts channels 3 (in front of channel 2) + to respect minimum spacing. At 9mm it pushes channel 3 to 31, at 18mm to 22. + """ + current = {0: 300.0, 1: 200.0, 2: 100.0, 3: 50.0} + requested = {2: 40.0} + + # At 9mm: channel 3 must be ≤ 40 - 9 = 31. + backend_9 = self._make_star_backend(4, [9.0] * 4) + backend_9.get_channels_y_positions.return_value = dict(current) + await backend_9.position_channels_in_y_direction(dict(requested), make_space=True) + cmd_9mm = backend_9._write_and_read_command.call_args.kwargs["cmd"] + # Channel 3 pushed to 31.0 → 310 increments. + self.assertIn("0310", cmd_9mm) + + # At 18mm: channel 3 must be ≤ 40 - 18 = 22. + backend_18 = self._make_star_backend(4, [18.0] * 4) + backend_18.get_channels_y_positions.return_value = dict(current) + await backend_18.position_channels_in_y_direction(dict(requested), make_space=True) + cmd_18mm = backend_18._write_and_read_command.call_args.kwargs["cmd"] + # Channel 3 pushed to 22.0 → 220 increments. + self.assertIn("0220", cmd_18mm) + + # The JY commands must differ. + self.assertNotEqual(cmd_9mm, cmd_18mm) diff --git a/pylabrobot/liquid_handling/backends/hamilton/planning.py b/pylabrobot/liquid_handling/backends/hamilton/planning.py deleted file mode 100644 index c050114bfc6..00000000000 --- a/pylabrobot/liquid_handling/backends/hamilton/planning.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import Callable, Dict, List - -from pylabrobot.liquid_handling.utils import MIN_SPACING_BETWEEN_CHANNELS -from pylabrobot.resources import Coordinate - - -def _default_min_spacing_between(channel1: int, channel2: int) -> float: - """Default minimum spacing between two channels, based on their indices.""" - return MIN_SPACING_BETWEEN_CHANNELS * abs(channel1 - channel2) - - -def group_by_x_batch_by_xy( - locations: List[Coordinate], - use_channels: List[int], - min_spacing_between_channels: Callable[[int, int], float] = _default_min_spacing_between, -) -> Dict[float, List[List[int]]]: - if len(use_channels) == 0: - raise ValueError("use_channels must not be empty.") - if len(locations) == 0: - raise ValueError("locations must not be empty.") - if len(locations) != len(use_channels): - raise ValueError("locations and use_channels must have the same length.") - - # Move channels to traverse height - x_pos, y_pos = zip(*[(loc.x, loc.y) for loc in locations]) - - # Start with a list of indices for each operation. The order is the order of operations as given in the input parameters. - # We will then turn this list of indices into batches of indices that can be executed together, based on their X and Y positions and channel numbers. - indices = list(range(len(locations))) - - # Sort indices by x position. - indices = sorted(indices, key=lambda i: x_pos[i]) - - # Group indices by x position (rounding to 0.1mm to avoid floating point splitting of same-position containers) - # Note that since the indices were already sorted by x position, the groups will also be sorted by x position. - x_groups: Dict[float, List[int]] = {} - for i in indices: - x_rounded = round(x_pos[i], 1) - x_groups.setdefault(x_rounded, []).append(i) - - # Within each x group, sort channels from back (lowest channel index) to front (highest channel index) - for x_group_indices in x_groups.values(): - x_group_indices.sort(key=lambda i: use_channels[i]) - - # Within each x group, batch by y position while respecting minimum y spacing constraint - y_batches: dict[float, List[List[int]]] = {} # x position (group) -> list of batches of indices - for x_group, x_group_indices in x_groups.items(): - y_batches_for_this_x: List[List[int]] = [] # batches of indices for this x group - for i in x_group_indices: - y = y_pos[i] - - # find the first batch that this index can be added to without violating the minimum y spacing constraint - # if no batch is found, create a new batch with this index - for batch in y_batches_for_this_x: - index_min_y = min(batch, key=lambda i: y_pos[i]) - # check min spacing - if y_pos[index_min_y] - y < min_spacing_between_channels( - use_channels[i], use_channels[index_min_y] - ): - continue - # check if channel is already used in this batch - if use_channels[i] in [use_channels[j] for j in batch]: - continue - batch.append(i) - break - else: - y_batches_for_this_x.append([i]) - - y_batches[x_group] = y_batches_for_this_x - - return y_batches diff --git a/pylabrobot/liquid_handling/backends/hamilton/planning_tests.py b/pylabrobot/liquid_handling/backends/hamilton/planning_tests.py deleted file mode 100644 index d13304655c0..00000000000 --- a/pylabrobot/liquid_handling/backends/hamilton/planning_tests.py +++ /dev/null @@ -1,129 +0,0 @@ -import unittest - -from pylabrobot.resources import Coordinate - -from .planning import group_by_x_batch_by_xy - - -class TestGroupByXBatchByXY(unittest.TestCase): - """Tests for group_by_x_batch_by_xy.""" - - def test_single_location(self): - locations = [Coordinate(100.0, 200.0, 0)] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0]) - self.assertEqual(result, {100.0: [[0]]}) - - def test_same_x_different_y_fits_in_one_batch(self): - locations = [ - Coordinate(100.0, 200.0, 0), - Coordinate(100.0, 180.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 1]) - # y diff = 20 >= 9*1, fits in one batch - self.assertEqual(result, {100.0: [[0, 1]]}) - - def test_same_x_too_close_y_splits_batches(self): - locations = [ - Coordinate(100.0, 200.0, 0), - Coordinate(100.0, 195.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 1]) - # y diff = 5 < 9*1, separate batches - self.assertEqual(result, {100.0: [[0], [1]]}) - - def test_different_x_groups(self): - locations = [ - Coordinate(100.0, 200.0, 0), - Coordinate(200.0, 200.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 1]) - self.assertEqual(result, {100.0: [[0]], 200.0: [[1]]}) - - def test_x_rounding(self): - locations = [ - Coordinate(100.04, 200.0, 0), - Coordinate(100.02, 180.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 1]) - # Both round to 100.0 - self.assertEqual(result, {100.0: [[0, 1]]}) - - def test_two_channels_same_x(self): - locations = [Coordinate(100.0, 200.0, 0), Coordinate(100.0, 180.0, 0)] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 1]) - self.assertEqual(result, {100.0: [[0, 1]]}) - - def test_empty_use_channels_raises(self): - with self.assertRaises(ValueError): - group_by_x_batch_by_xy( - locations=[Coordinate(100.0, 200.0, 0)], - use_channels=[], - ) - - def test_non_adjacent_channels(self): - locations = [ - Coordinate(100.0, 300.0, 0), - Coordinate(100.0, 200.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 5]) - # y diff = 100 >= 9*(5-0) = 45, fits in one batch - self.assertEqual(result, {100.0: [[0, 1]]}) - - def test_non_adjacent_channels_too_close(self): - locations = [ - Coordinate(100.0, 240.0, 0), - Coordinate(100.0, 200.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 5]) - # y diff = 40 < 9*(5-0) = 45, separate batches - self.assertEqual(result, {100.0: [[0], [1]]}) - - def test_sorted_by_x(self): - locations = [ - Coordinate(300.0, 200.0, 0), - Coordinate(100.0, 200.0, 0), - Coordinate(200.0, 200.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 1, 2]) - self.assertEqual(result, {100.0: [[1]], 200.0: [[2]], 300.0: [[0]]}) - - def test_multiple_batches_in_one_x_group(self): - locations = [ - Coordinate(100.0, 200.0, 0), - Coordinate(100.0, 197.0, 0), - Coordinate(100.0, 194.0, 0), - Coordinate(100.0, 191.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 1, 2, 3]) - # Each consecutive pair has y diff = 3 < 9, so each in its own batch - self.assertEqual(result, {100.0: [[0], [1], [2], [3]]}) - - def test_duplicate_channels_split_into_separate_batches(self): - locations = [ - Coordinate(100.0, 200.0, 0), - Coordinate(100.0, 180.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 0]) - self.assertEqual(result, {100.0: [[0], [1]]}) - - def test_duplicate_channels_three_ops(self): - locations = [ - Coordinate(100.0, 200.0, 0), - Coordinate(100.0, 180.0, 0), - Coordinate(100.0, 160.0, 0), - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[0, 0, 0]) - self.assertEqual(result, {100.0: [[0], [1], [2]]}) - - def test_channels_sorted_by_channel_index_within_x_group(self): - locations = [ - Coordinate(100.0, 180.0, 0), # channel 2 - Coordinate(100.0, 200.0, 0), # channel 0 - ] - result = group_by_x_batch_by_xy(locations=locations, use_channels=[2, 0]) - # Channel 0 (index 1) sorted before channel 2 (index 0) - self.assertEqual(result, {100.0: [[1, 0]]}) - - -if __name__ == "__main__": - unittest.main() diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py new file mode 100644 index 00000000000..c4a14c173c8 --- /dev/null +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -0,0 +1,454 @@ +"""Pipette orchestration: partition channel–target pairs into executable batches. + +Multi-channel liquid handlers have physical constraints (single X carriage, minimum +Y spacing, descending Y order by channel index) that limit which channels can act +simultaneously. + + batches = plan_batches( + use_channels, x_pos, y_pos, channel_spacings=[9.0]*8, + num_channels=8, max_y=635.0, min_y=6.0, + ) + for batch in batches: + backend.position_channels_in_y_direction(batch.y_positions) + ... +""" + +import math +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple, Union + +from pylabrobot.liquid_handling.utils import ( + MIN_SPACING_EDGE, + get_wide_single_resource_liquid_op_offsets, +) +from pylabrobot.resources.container import Container +from pylabrobot.resources.coordinate import Coordinate + +X_GROUPING_TOLERANCE_MM = 0.1 + + +# --- Data types --- + + +@dataclass +class ChannelBatch: + """A group of channels that can operate simultaneously. + + After transition optimization, ``y_positions`` contains entries for all instrument + channels (not just active and phantom ones). + """ + + x_position: float + indices: List[int] + channels: List[int] + y_positions: Dict[int, float] = field(default_factory=dict) # includes phantoms + + +# --- Spacing helpers --- + + +def _effective_spacing(spacings: List[float], ch_lo: int, ch_hi: int) -> float: + """Max of per-channel spacings across ch_lo..ch_hi (inclusive). + + Used by ``compute_single_container_offsets`` to determine a single uniform spacing + for spreading channels across a wide container. + """ + return max(spacings[ch_lo : ch_hi + 1]) + + +def _span_required(spacings: List[float], ch_lo: int, ch_hi: int) -> float: + """Minimum total Y distance required between channels ch_lo and ch_hi. + + Sums the actual pairwise spacing for each adjacent pair in the range, where each + pair's spacing is ``max(spacings[k], spacings[k+1])``. This is tighter than + ``(ch_hi - ch_lo) * max(spacings[ch_lo:ch_hi+1])`` when spacings are non-uniform. + """ + return sum(max(spacings[ch], spacings[ch + 1]) for ch in range(ch_lo, ch_hi)) + + +def _min_spacing_between(spacings: List[float], i: int, j: int) -> float: + """Minimum Y spacing between adjacent channels *i* and *j*. + + Takes the larger of the two channels' spacings, then rounds up to 0.1 mm: + ``math.ceil(max(spacings[i], spacings[j]) * 10) / 10``. + + Mirrors ``STARBackend._min_spacing_between`` (which operates on + ``self._channels_minimum_y_spacing`` instead of an explicit list). + """ + return math.ceil(max(spacings[i], spacings[j]) * 10) / 10 + + +# --- Batch partitioning --- + + +@dataclass +class _BatchAccumulator: + """Mutable working state for a batch being built up during partitioning.""" + + indices: List[int] + lo_ch: int + hi_ch: int + lo_y: float + hi_y: float + + +def _channel_fits_batch( + batch: _BatchAccumulator, channel: int, y: float, spacings: List[float] +) -> bool: + """Check whether *channel* at *y* can be added to *batch* without violating spacing. + + Two checks suffice because channels are processed in ascending order, so the candidate + is always the new high end. The (lo → candidate) check covers the full span; the + (hi → candidate) check catches the local gap. + """ + if batch.hi_y - y < _span_required(spacings, batch.hi_ch, channel) - 1e-9: + return False + if batch.lo_y - y < _span_required(spacings, batch.lo_ch, channel) - 1e-9: + return False + return True + + +def _interpolate_phantoms( + channels: List[int], y_positions: Dict[int, float], spacings: List[float] +) -> None: + """Fill in Y positions for phantom channels between non-consecutive batch members. + + Each phantom is placed at its actual pairwise spacing from the previous channel, + so non-uniform spacings are respected (e.g. a wide channel only widens its own gaps). + """ + sorted_chs = sorted(channels) + for k in range(len(sorted_chs) - 1): + ch_lo, ch_hi = sorted_chs[k], sorted_chs[k + 1] + cumulative = 0.0 + for phantom in range(ch_lo + 1, ch_hi): + cumulative += max(spacings[phantom - 1], spacings[phantom]) + if phantom not in y_positions: + y_positions[phantom] = y_positions[ch_lo] - cumulative + + +def _partition_into_y_batches( + indices: List[int], + use_channels: List[int], + y_pos: List[float], + spacings: List[float], + x_position: float, +) -> List[ChannelBatch]: + """Partition channels within an X group into minimum parallel-compatible batches. + + Uses greedy first-fit: processes channels in ascending order and assigns each to + the first batch where it fits, or creates a new batch. + """ + + channels_by_index = sorted(indices, key=lambda i: use_channels[i]) + batches: List[_BatchAccumulator] = [] + + for idx in channels_by_index: + channel = use_channels[idx] + y = y_pos[idx] + + assigned = False + for batch in batches: + if _channel_fits_batch(batch, channel, y, spacings): + batch.indices.append(idx) + batch.hi_ch = channel + batch.hi_y = y + assigned = True + break + + if not assigned: + batches.append(_BatchAccumulator(indices=[idx], lo_ch=channel, hi_ch=channel, lo_y=y, hi_y=y)) + + result: List[ChannelBatch] = [] + for batch in batches: + batch_channels = [use_channels[i] for i in batch.indices] + y_positions: Dict[int, float] = {use_channels[i]: y_pos[i] for i in batch.indices} + _interpolate_phantoms(batch_channels, y_positions, spacings) + result.append( + ChannelBatch( + x_position=x_position, + indices=batch.indices, + channels=batch_channels, + y_positions=y_positions, + ) + ) + + return result + + +# --- Batch transition optimization --- + + +def _find_next_y_target( + channel: int, start_batch: int, batches: List[ChannelBatch] +) -> Optional[float]: + """Return the Y position where *channel* is next needed. + + Searches ``batches[start_batch:]`` for the first batch whose ``y_positions`` + contains *channel* (active or phantom). Returns ``None`` if not found. + """ + for batch in batches[start_batch:]: + if channel in batch.y_positions: + return batch.y_positions[channel] + return None + + +def _optimize_batch_transitions( + batches: List[ChannelBatch], + num_channels: int, + spacings: List[float], + max_y: float, + min_y: float, +) -> None: + """Pre-position idle channels toward their next-needed Y coordinate. + + Mutates each batch's ``y_positions`` in-place so it contains keys for ALL + ``num_channels`` channels, ensuring every channel has a defined position + for every batch. + + Args: + batches: List of ChannelBatch — modified in place. + num_channels: Total number of channels on the instrument. + spacings: Per-channel minimum Y spacing list (length >= num_channels). + max_y: Maximum Y position reachable by channel 0 (mm). + min_y: Minimum Y position reachable by channel N-1 (mm). + """ + + for batch_idx, batch in enumerate(batches): + positions = batch.y_positions + fixed = set(batch.channels) # only active channels are immovable, not phantoms + + # 1. Assign targets: idle channels get their next-needed Y position. + for ch in range(num_channels): + if ch in fixed: + continue + target = _find_next_y_target(ch, batch_idx + 1, batches) + if target is not None: + positions[ch] = target + + # 2. Fill gaps: channels with no current or future use stay where they were + # in the previous batch. For batch 0 (no previous), pack at min spacing + # from the nearest already-positioned neighbor. + prev_positions = batches[batch_idx - 1].y_positions if batch_idx > 0 else None + for ch in range(num_channels): + if ch in positions: + continue + if prev_positions is not None and ch in prev_positions: + positions[ch] = prev_positions[ch] + elif ch == 0: + # First batch, ch0 has no reference — pack above ch1 + spacing = _min_spacing_between(spacings, 0, 1) + positions[ch] = positions.get(1, max_y) + spacing + else: + spacing = _min_spacing_between(spacings, ch - 1, ch) + positions[ch] = positions[ch - 1] - spacing + + # 3. Forward sweep (ch 1 → N-1): enforce spacing, only move free channels. + for ch in range(1, num_channels): + if ch in fixed: + continue + spacing = _min_spacing_between(spacings, ch - 1, ch) + if positions[ch - 1] - positions[ch] < spacing - 1e-9: + positions[ch] = positions[ch - 1] - spacing + + # 4. Backward sweep (ch N-2 → 0): enforce spacing, only move free channels. + for ch in range(num_channels - 2, -1, -1): + if ch in fixed: + continue + spacing = _min_spacing_between(spacings, ch, ch + 1) + if positions[ch] - positions[ch + 1] < spacing - 1e-9: + positions[ch] = positions[ch + 1] + spacing + + # 5. Bounds clamp (free channels only). + for ch in range(num_channels): + if ch in fixed: + continue + if positions[ch] > max_y: + positions[ch] = max_y + if positions[ch] < min_y: + positions[ch] = min_y + + # Re-run forward sweep to propagate clamped bounds. + for ch in range(1, num_channels): + if ch in fixed: + continue + spacing = _min_spacing_between(spacings, ch - 1, ch) + if positions[ch - 1] - positions[ch] < spacing - 1e-9: + positions[ch] = positions[ch - 1] - spacing + + # Re-run backward sweep to propagate clamped bounds upward. + for ch in range(num_channels - 2, -1, -1): + if ch in fixed: + continue + spacing = _min_spacing_between(spacings, ch, ch + 1) + if positions[ch] - positions[ch + 1] < spacing - 1e-9: + positions[ch] = positions[ch + 1] + spacing + + +# --- Input validation and position computation --- + + +def compute_single_container_offsets( + container: Container, + use_channels: List[int], + channel_spacings: Union[float, List[float]], +) -> Optional[List[Coordinate]]: + """Compute spread Y offsets for multiple channels targeting the same container. + + Returns None if the container is too small — caller should fall back to center + offsets and let plan_batches serialize. + """ + + if len(use_channels) == 0: + return [] + + ch_lo, ch_hi = min(use_channels), max(use_channels) + if isinstance(channel_spacings, (int, float)): + spacing = float(channel_spacings) + else: + spacing = _effective_spacing(channel_spacings, ch_lo, ch_hi) + + num_physical = ch_hi - ch_lo + 1 + min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing + + if container.get_absolute_size_y() < min_required: + return None + + all_offsets = get_wide_single_resource_liquid_op_offsets( + resource=container, + num_channels=num_physical, + min_spacing=spacing, + ) + offsets = [all_offsets[ch - ch_lo] for ch in use_channels] + + # Shift odd channel spans +5.5mm to avoid container center dividers + if num_physical > 1 and num_physical % 2 != 0: + offsets = [o + Coordinate(0, 5.5, 0) for o in offsets] + + return offsets + + +def validate_probing_inputs( + containers: List[Container], + use_channels: Optional[List[int]], + num_channels: int, +) -> List[int]: + """Validate and normalize channel selection for liquid height probing. + + If *use_channels* is ``None``, defaults to ``[0, 1, ..., len(containers)-1]``. + + Returns: + Validated list of channel indices. + + Raises: + ValueError: If channels are empty, out of range, or contain duplicates. + """ + if use_channels is None: + use_channels = list(range(len(containers))) + if len(use_channels) == 0: + raise ValueError("use_channels must not be empty.") + if not all(0 <= ch < num_channels for ch in use_channels): + raise ValueError( + f"All use_channels must be integers in range [0, {num_channels - 1}], got {use_channels}." + ) + if len(use_channels) != len(set(use_channels)): + raise ValueError("use_channels must not contain duplicates.") + if len(containers) != len(use_channels): + raise ValueError( + f"Length of containers and use_channels must match, " + f"got {len(containers)} and {len(use_channels)}." + ) + return use_channels + + +def compute_positions( + containers: List[Container], + resource_offsets: List[Coordinate], + deck: "Deck", # noqa: F821 +) -> Tuple[List[float], List[float]]: + """Convert containers and offsets into absolute X/Y machine coordinates. + + Returns: + (x_positions, y_positions) — parallel lists of absolute coordinates in mm, + one entry per container. + """ + x_pos: List[float] = [] + y_pos: List[float] = [] + for resource, offset in zip(containers, resource_offsets): + loc = resource.get_location_wrt(deck, x="c", y="c", z="b") + x_pos.append(loc.x + offset.x) + y_pos.append(loc.y + offset.y) + return x_pos, y_pos + + +# --- Public API --- + + +def plan_batches( + use_channels: List[int], + x_pos: List[float], + y_pos: List[float], + channel_spacings: Union[float, List[float]], + x_tolerance: float = X_GROUPING_TOLERANCE_MM, + num_channels: Optional[int] = None, + max_y: Optional[float] = None, + min_y: Optional[float] = None, +) -> List[ChannelBatch]: + """Partition channel–position pairs into executable batches. + + Groups by X position (within *x_tolerance*), then within each X group partitions + into Y sub-batches respecting per-channel minimum spacing. Computes phantom channel + positions for intermediate channels between non-consecutive batch members. + + When *num_channels*, *max_y*, and *min_y* are all provided, idle channels are + pre-positioned toward their next-needed Y coordinate to minimize travel between + batch transitions. + + Args: + use_channels: Channel indices being used (e.g. [0, 1, 2, 5, 6, 7]). + x_pos: Absolute X position for each entry in *use_channels*. + y_pos: Absolute Y position for each entry in *use_channels*. + channel_spacings: Minimum Y spacing per channel (mm). Scalar for uniform, + or a list with one entry per channel on the instrument. + x_tolerance: Positions within this tolerance share an X group. + num_channels: Total number of channels on the instrument. Required for + transition optimization. + max_y: Maximum Y position reachable by channel 0 (mm). Required for + transition optimization. + min_y: Minimum Y position reachable by channel N-1 (mm). Required for + transition optimization. + + Returns: + Flat list of ChannelBatch sorted by ascending X position. + """ + + if not (len(use_channels) == len(x_pos) == len(y_pos)): + raise ValueError( + f"use_channels, x_pos, and y_pos must have the same length, " + f"got {len(use_channels)}, {len(x_pos)}, {len(y_pos)}." + ) + if len(use_channels) == 0: + raise ValueError("use_channels must not be empty.") + + # Normalize scalar spacing to per-channel list + max_ch = max(use_channels) + if isinstance(channel_spacings, (int, float)): + spacings: List[float] = [float(channel_spacings)] * (max_ch + 1) + else: + spacings = channel_spacings + + # Group indices by X position (preserving first-appearance order). + # Uses floor-based bucketing to avoid Python's banker's rounding at boundaries. + x_groups: Dict[float, List[int]] = {} + for i, x in enumerate(x_pos): + x_bucket = math.floor(x / x_tolerance) * x_tolerance + x_groups.setdefault(x_bucket, []).append(i) + + result: List[ChannelBatch] = [] + for _, indices in sorted(x_groups.items()): + group_x = x_pos[indices[0]] + result.extend(_partition_into_y_batches(indices, use_channels, y_pos, spacings, group_x)) + + if num_channels is not None and max_y is not None and min_y is not None: + _optimize_batch_transitions(result, num_channels, spacings, max_y, min_y) + + return result diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py new file mode 100644 index 00000000000..36d1ed165be --- /dev/null +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -0,0 +1,491 @@ +"""Tests for pipette_batch_scheduling module.""" + +import unittest +from unittest.mock import MagicMock, patch + +from pylabrobot.liquid_handling.pipette_batch_scheduling import ( + ChannelBatch, + _effective_spacing, + _find_next_y_target, + _optimize_batch_transitions, + _min_spacing_between, + compute_single_container_offsets, + plan_batches, +) +from pylabrobot.resources.coordinate import Coordinate + + +class TestEffectiveSpacing(unittest.TestCase): + def test_uniform(self): + self.assertAlmostEqual(_effective_spacing([9.0, 9.0, 9.0, 9.0], 0, 3), 9.0) + + def test_mixed_takes_max(self): + spacings = [9.0, 9.0, 18.0, 18.0] + self.assertAlmostEqual(_effective_spacing(spacings, 0, 3), 18.0) + self.assertAlmostEqual(_effective_spacing(spacings, 0, 1), 9.0) + self.assertAlmostEqual(_effective_spacing(spacings, 1, 2), 18.0) + + def test_single_channel(self): + self.assertAlmostEqual(_effective_spacing([9.0, 18.0], 0, 0), 9.0) + self.assertAlmostEqual(_effective_spacing([9.0, 18.0], 1, 1), 18.0) + + +class TestPlanBatchesUniformSpacing(unittest.TestCase): + S = 9.0 + + # --- X grouping --- + + def test_single_x_group(self): + batches = plan_batches([0, 1, 2], [100.0] * 3, [270.0, 261.0, 252.0], self.S) + self.assertEqual(len(batches), 1) + self.assertAlmostEqual(batches[0].x_position, 100.0) + + def test_two_x_groups(self): + batches = plan_batches( + [0, 1, 2, 3], [100.0, 100.0, 200.0, 200.0], [270.0, 261.0, 270.0, 261.0], self.S + ) + x_positions = [b.x_position for b in batches] + self.assertAlmostEqual(x_positions[0], 100.0) + self.assertAlmostEqual(x_positions[-1], 200.0) + + def test_x_groups_sorted_by_ascending_x(self): + batches = plan_batches([0, 1, 2], [300.0, 100.0, 200.0], [270.0] * 3, self.S) + x_positions = [b.x_position for b in batches] + self.assertAlmostEqual(x_positions[0], 100.0) + self.assertAlmostEqual(x_positions[1], 200.0) + self.assertAlmostEqual(x_positions[2], 300.0) + + def test_x_positions_within_tolerance_grouped(self): + batches = plan_batches([0, 1], [100.0, 100.05], [270.0, 261.0], self.S) + self.assertEqual(len(batches), 1) + + def test_x_positions_outside_tolerance_split(self): + batches = plan_batches([0, 1], [100.0, 100.2], [270.0, 270.0], self.S) + self.assertEqual(len(batches), 2) + + # --- Y batching --- + + def test_consecutive_channels_single_batch(self): + batches = plan_batches([0, 1, 2], [100.0] * 3, [270.0, 261.0, 252.0], self.S) + self.assertEqual(len(batches), 1) + self.assertEqual(sorted(batches[0].channels), [0, 1, 2]) + + def test_same_y_forces_serialization(self): + batches = plan_batches([0, 1, 2], [100.0] * 3, [200.0] * 3, self.S) + self.assertEqual(len(batches), 3) + + def test_barely_fitting_spacing(self): + batches = plan_batches([0, 1], [100.0] * 2, [209.0, 200.0], self.S) + self.assertEqual(len(batches), 1) + + def test_barely_insufficient_spacing(self): + batches = plan_batches([0, 1], [100.0] * 2, [208.9, 200.0], self.S) + self.assertEqual(len(batches), 2) + + def test_reversed_y_order_splits(self): + batches = plan_batches([0, 1], [100.0] * 2, [200.0, 220.0], self.S) + self.assertEqual(len(batches), 2) + + # --- Non-consecutive channels --- + + def test_non_consecutive_channels_fit(self): + batches = plan_batches( + [0, 1, 2, 5, 6, 7], + [100.0] * 6, + [300.0, 291.0, 282.0, 255.0, 246.0, 237.0], + self.S, + ) + self.assertEqual(len(batches), 1) + self.assertEqual(sorted(batches[0].channels), [0, 1, 2, 5, 6, 7]) + + def test_phantom_channels_interpolated(self): + batches = plan_batches([0, 3], [100.0] * 2, [300.0, 273.0], self.S) + self.assertEqual(len(batches), 1) + y = batches[0].y_positions + self.assertAlmostEqual(y[0], 300.0) + self.assertAlmostEqual(y[1], 291.0) + self.assertAlmostEqual(y[2], 282.0) + self.assertAlmostEqual(y[3], 273.0) + + def test_phantoms_only_within_batch(self): + batches = plan_batches([0, 3], [100.0] * 2, [200.0, 250.0], self.S) + self.assertEqual(len(batches), 2) + for batch in batches: + self.assertEqual(len(batch.y_positions), 1) + + # --- Mixed X and Y --- + + def test_mixed_complexity(self): + batches = plan_batches( + [0, 1, 2, 3], + [100.0, 100.0, 200.0, 200.0], + [200.0, 200.0, 270.0, 261.0], + self.S, + ) + x100 = [b for b in batches if abs(b.x_position - 100.0) < 0.01] + x200 = [b for b in batches if abs(b.x_position - 200.0) < 0.01] + self.assertEqual(len(x100), 2) + self.assertEqual(len(x200), 1) + + # --- Validation --- + + def test_mismatched_lengths(self): + with self.assertRaises(ValueError): + plan_batches([0, 1], [100.0], [200.0, 200.0], self.S) + + def test_empty(self): + with self.assertRaises(ValueError): + plan_batches([], [], [], self.S) + + # --- Index correctness --- + + def test_indices_map_back_correctly(self): + use_channels = [3, 7, 0] + batches = plan_batches(use_channels, [100.0] * 3, [261.0, 237.0, 270.0], self.S) + all_indices = [idx for b in batches for idx in b.indices] + self.assertEqual(sorted(all_indices), [0, 1, 2]) + for batch in batches: + for idx, ch in zip(batch.indices, batch.channels): + self.assertEqual(use_channels[idx], ch) + + # --- Realistic --- + + def test_8_channels_trough(self): + batches = plan_batches(list(range(8)), [100.0] * 8, [300.0 - i * 9.0 for i in range(8)], self.S) + self.assertEqual(len(batches), 1) + self.assertEqual(len(batches[0].channels), 8) + + def test_8_channels_narrow_well(self): + batches = plan_batches(list(range(8)), [100.0] * 8, [200.0] * 8, self.S) + self.assertEqual(len(batches), 8) + + def test_channels_0_1_2_5_6_7_phantoms(self): + batches = plan_batches( + [0, 1, 2, 5, 6, 7], + [100.0] * 6, + [300.0, 291.0, 282.0, 255.0, 246.0, 237.0], + self.S, + ) + self.assertEqual(len(batches), 1) + y = batches[0].y_positions + self.assertIn(3, y) + self.assertIn(4, y) + self.assertAlmostEqual(y[3], 282.0 - 9.0) + self.assertAlmostEqual(y[4], 282.0 - 18.0) + + +class TestPlanBatchesMixedSpacing(unittest.TestCase): + """Tests for mixed-channel instruments (e.g. 1mL + 5mL).""" + + # Channels 0,1 are 1mL (8.98mm), channels 2,3 are 5mL (17.96mm) + SPACINGS = [8.98, 8.98, 17.96, 17.96] + + def test_two_1ml_channels_fit_at_9mm(self): + batches = plan_batches([0, 1], [100.0] * 2, [208.98, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 1) + + def test_1ml_and_5ml_need_wider_spacing(self): + batches = plan_batches([1, 2], [100.0] * 2, [209.0, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 2) + + def test_1ml_and_5ml_fit_at_wide_spacing(self): + batches = plan_batches([1, 2], [100.0] * 2, [217.96, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 1) + + def test_5ml_channels_fit_at_wide_spacing(self): + batches = plan_batches([2, 3], [100.0] * 2, [217.96, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 1) + + def test_5ml_channels_too_close(self): + batches = plan_batches([2, 3], [100.0] * 2, [209.0, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 2) + + def test_span_across_1ml_and_5ml(self): + # Pairwise sum: max(8.98,8.98) + max(8.98,17.96) + max(17.96,17.96) = 44.9 + batches = plan_batches([0, 3], [100.0] * 2, [244.9, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 1) + batches = plan_batches([0, 3], [100.0] * 2, [244.0, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 2) + + def test_phantom_channels_use_pairwise_spacing(self): + # ch0→ch1: max(8.98, 8.98) = 8.98, ch1→ch2: max(8.98, 17.96) = 17.96 + batches = plan_batches([0, 3], [100.0] * 2, [244.9, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 1) + y = batches[0].y_positions + self.assertAlmostEqual(y[1], 244.9 - 8.98) + self.assertAlmostEqual(y[2], 244.9 - 8.98 - 17.96) + + def test_mixed_all_four_channels_spaced_wide(self): + s = 17.96 + batches = plan_batches( + [0, 1, 2, 3], + [100.0] * 4, + [300.0, 300.0 - s, 300.0 - 2 * s, 300.0 - 3 * s], + self.SPACINGS, + ) + self.assertEqual(len(batches), 1) + + def test_pairwise_sum_avoids_unnecessary_split(self): + # With spacings [8.98, 8.98, 17.96, 17.96], spanning ch0→ch3 requires + # 8.98 + 17.96 + 17.96 = 44.9mm (pairwise sum), NOT 3 * 17.96 = 53.88mm. + # A gap of 50mm should fit in one batch (pairwise) even though it's less than 53.88. + batches = plan_batches([0, 3], [100.0] * 2, [250.0, 200.0], self.SPACINGS) + self.assertEqual(len(batches), 1) + + def test_mixed_channels_at_1ml_spacing_forces_serialization(self): + batches = plan_batches( + [0, 1, 2, 3], + [100.0] * 4, + [300.0, 291.0, 282.0, 273.0], + self.SPACINGS, + ) + self.assertGreater(len(batches), 1) + + +class TestComputeSingleContainerOffsets(unittest.TestCase): + S = 9.0 + + def _mock_container(self, size_y: float): + c = MagicMock(spec=["get_absolute_size_y"]) + c.get_absolute_size_y.return_value = size_y + return c + + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" + ) + def test_even_span_no_center_offset(self, mock_offsets): + mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] + result = compute_single_container_offsets(self._mock_container(50.0), [0, 1], self.S) + self.assertAlmostEqual(result[0].y, 4.5) + self.assertAlmostEqual(result[1].y, -4.5) + + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" + ) + def test_single_channel_no_center_offset(self, mock_offsets): + mock_offsets.return_value = [Coordinate(0, 0.0, 0)] + result = compute_single_container_offsets(self._mock_container(50.0), [0], self.S) + self.assertAlmostEqual(result[0].y, 0.0) + + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" + ) + def test_odd_span_applies_center_offset(self, mock_offsets): + mock_offsets.return_value = [ + Coordinate(0, 9.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -9.0, 0), + ] + result = compute_single_container_offsets(self._mock_container(50.0), [0, 1, 2], self.S) + self.assertAlmostEqual(result[0].y, 9.0 + 5.5) + self.assertAlmostEqual(result[1].y, 0.0 + 5.5) + self.assertAlmostEqual(result[2].y, -9.0 + 5.5) + + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" + ) + def test_non_consecutive_selects_correct_offsets(self, mock_offsets): + mock_offsets.return_value = [ + Coordinate(0, 10.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -10.0, 0), + ] + result = compute_single_container_offsets(self._mock_container(50.0), [0, 2], self.S) + self.assertEqual(len(result), 2) + mock_offsets.assert_called_once_with( + resource=unittest.mock.ANY, num_channels=3, min_spacing=self.S + ) + + def test_container_too_small_returns_none(self): + self.assertIsNone(compute_single_container_offsets(self._mock_container(10.0), [0, 1], self.S)) + + def test_empty_channels(self): + self.assertEqual(compute_single_container_offsets(self._mock_container(50.0), [], self.S), []) + + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" + ) + def test_mixed_spacing_uses_effective(self, mock_offsets): + mock_offsets.return_value = [ + Coordinate(0, 18.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -18.0, 0), + ] + spacings = [9.0, 9.0, 18.0] + result = compute_single_container_offsets(self._mock_container(100.0), [0, 2], spacings) + self.assertIsNotNone(result) + mock_offsets.assert_called_once_with( + resource=unittest.mock.ANY, num_channels=3, min_spacing=18.0 + ) + + +class TestPairwiseMinSpacing(unittest.TestCase): + def test_uniform_spacing(self): + spacings = [9.0] * 8 + self.assertAlmostEqual(_min_spacing_between(spacings, 0, 1), 9.0) + self.assertAlmostEqual(_min_spacing_between(spacings, 5, 6), 9.0) + + def test_mixed_spacing(self): + spacings = [8.98, 8.98, 17.96, 17.96] + # max(8.98, 8.98) = 8.98 → ceil(89.8)/10 = 9.0 + self.assertAlmostEqual(_min_spacing_between(spacings, 0, 1), 9.0) + # max(8.98, 17.96) = 17.96 → ceil(179.6)/10 = 18.0 + self.assertAlmostEqual(_min_spacing_between(spacings, 1, 2), 18.0) + # max(17.96, 17.96) = 17.96 → ceil(179.6)/10 = 18.0 + self.assertAlmostEqual(_min_spacing_between(spacings, 2, 3), 18.0) + + +class TestFindNextYTarget(unittest.TestCase): + def _batch(self, y_positions): + return ChannelBatch(x_position=100.0, indices=[], channels=[], y_positions=y_positions) + + def test_found_in_immediate_next_batch(self): + batches = [self._batch({0: 400}), self._batch({0: 300, 1: 291})] + self.assertAlmostEqual(_find_next_y_target(0, 1, batches), 300.0) + + def test_found_in_later_batch(self): + batches = [ + self._batch({0: 400}), + self._batch({2: 300}), + self._batch({0: 200}), + ] + # start_batch=1, channel 0 not in batch[1], found in batch[2] + self.assertAlmostEqual(_find_next_y_target(0, 1, batches), 200.0) + + def test_not_found_returns_none(self): + batches = [self._batch({0: 400}), self._batch({1: 300})] + self.assertIsNone(_find_next_y_target(2, 0, batches)) + + def test_phantom_position_used_as_target(self): + # Channel 1 is a phantom in batch 1 (between active 0 and 2) + batches = [self._batch({0: 400}), self._batch({0: 300, 1: 291, 2: 282})] + self.assertAlmostEqual(_find_next_y_target(1, 1, batches), 291.0) + + +class TestForwardPlan(unittest.TestCase): + S = [9.0] * 8 + N = 8 + MAX_Y = 650.0 + MIN_Y = 6.0 + + def _batch(self, y_positions): + return ChannelBatch(x_position=100.0, indices=[], channels=[], y_positions=dict(y_positions)) + + def _optimize_batch_transitions( + self, batches, spacings=None, num_channels=None, max_y=None, min_y=None + ): + _optimize_batch_transitions( + batches, + num_channels or self.N, + spacings or self.S, + max_y=max_y if max_y is not None else self.MAX_Y, + min_y=min_y if min_y is not None else self.MIN_Y, + ) + + def _check_spacing(self, positions, spacings, num_channels): + """Assert all adjacent channels satisfy minimum spacing.""" + for ch in range(num_channels - 1): + spacing = _min_spacing_between(spacings, ch, ch + 1) + diff = positions[ch] - positions[ch + 1] + self.assertGreaterEqual( + diff + 1e-9, spacing, f"channels {ch}-{ch + 1}: diff={diff:.2f} < spacing={spacing:.2f}" + ) + + def test_single_batch_fills_all_channels(self): + batches = [self._batch({0: 400, 1: 391})] + self._optimize_batch_transitions(batches) + self.assertEqual(set(batches[0].y_positions.keys()), set(range(self.N))) + + def test_idle_channels_move_toward_future_batch(self): + batches = [ + self._batch({0: 400, 1: 391}), + self._batch({6: 200, 7: 191}), + ] + self._optimize_batch_transitions(batches) + # Channels 6, 7 should be at or near their batch-1 targets + self.assertAlmostEqual(batches[0].y_positions[6], 200.0) + self.assertAlmostEqual(batches[0].y_positions[7], 191.0) + + def test_fixed_channels_not_modified(self): + batches = [ + self._batch({0: 400, 1: 391}), + self._batch({6: 200, 7: 191}), + ] + self._optimize_batch_transitions(batches) + self.assertAlmostEqual(batches[0].y_positions[0], 400.0) + self.assertAlmostEqual(batches[0].y_positions[1], 391.0) + self.assertAlmostEqual(batches[1].y_positions[6], 200.0) + self.assertAlmostEqual(batches[1].y_positions[7], 191.0) + + def test_spacing_constraints_satisfied(self): + batches = [ + self._batch({0: 400, 1: 391}), + self._batch({6: 200, 7: 191}), + ] + self._optimize_batch_transitions(batches) + for batch in batches: + self._check_spacing(batch.y_positions, self.S, self.N) + + def test_bounds_respected(self): + batches = [self._batch({3: 300})] + self._optimize_batch_transitions(batches) + self.assertLessEqual(batches[0].y_positions[0], self.MAX_Y) + self.assertGreaterEqual(batches[0].y_positions[self.N - 1], self.MIN_Y) + + def test_custom_bounds(self): + batches = [self._batch({3: 300})] + self._optimize_batch_transitions(batches, max_y=500.0, min_y=50.0) + self.assertLessEqual(batches[0].y_positions[0], 500.0) + self.assertGreaterEqual(batches[0].y_positions[self.N - 1], 50.0) + + def test_no_future_use_channels_packed_tightly(self): + # Only one batch, channels 0,1 active. Channels 2-7 have no future use. + batches = [self._batch({0: 400, 1: 391})] + self._optimize_batch_transitions(batches) + # Channels 2-7 should be packed at minimum spacing below channel 1 + for ch in range(2, self.N): + spacing = _min_spacing_between(self.S, ch - 1, ch) + expected = batches[0].y_positions[ch - 1] - spacing + self.assertAlmostEqual( + batches[0].y_positions[ch], expected, places=5, msg=f"channel {ch} not tightly packed" + ) + + def test_mixed_spacing(self): + spacings = [8.98, 8.98, 17.96, 17.96, 9.0, 9.0, 9.0, 9.0] + batches = [self._batch({0: 500, 1: 491})] + self._optimize_batch_transitions(batches, spacings=spacings) + self.assertEqual(set(batches[0].y_positions.keys()), set(range(self.N))) + self._check_spacing(batches[0].y_positions, spacings, self.N) + + def test_three_batches_progressive_prepositioning(self): + batches = [ + self._batch({0: 500, 1: 491}), + self._batch({4: 350, 5: 341}), + self._batch({6: 200, 7: 191}), + ] + self._optimize_batch_transitions(batches) + # Batch 0: channels 4,5 should target their batch-1 positions + self.assertAlmostEqual(batches[0].y_positions[4], 350.0) + self.assertAlmostEqual(batches[0].y_positions[5], 341.0) + # Batch 0: channels 6,7 should target their batch-2 positions + self.assertAlmostEqual(batches[0].y_positions[6], 200.0) + self.assertAlmostEqual(batches[0].y_positions[7], 191.0) + # All batches satisfy spacing + for batch in batches: + self._check_spacing(batch.y_positions, self.S, self.N) + + def test_target_constrained_by_fixed_channels(self): + # Channel 2 wants to be at 390 (future target), but channel 1 is fixed at 391. + # Spacing constraint forces channel 2 down to 391 - 9 = 382. + batches = [ + self._batch({1: 391}), + self._batch({2: 390}), + ] + self._optimize_batch_transitions(batches) + self.assertAlmostEqual(batches[0].y_positions[1], 391.0) + self.assertLessEqual(batches[0].y_positions[2], 391.0 - 9.0 + 1e-9) + self._check_spacing(batches[0].y_positions, self.S, self.N) + + +if __name__ == "__main__": + unittest.main() From 8eece6fe5bd1aabe98886ace824077f0b9bf95a0 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 13 Mar 2026 11:05:29 +0000 Subject: [PATCH 02/39] make x_grouping_tolerance required argument --- .../backends/hamilton/STAR_backend.py | 9 ++- .../backends/hamilton/STAR_chatterbox.py | 3 +- .../pipette_batch_scheduling.py | 4 +- .../pipette_batch_scheduling_tests.py | 62 +++++++++---------- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 14ecc449ed5..c16385b7451 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -45,7 +45,6 @@ get_star_liquid_class, ) from pylabrobot.liquid_handling.pipette_batch_scheduling import ( - X_GROUPING_TOLERANCE_MM, ChannelBatch, compute_positions, compute_single_container_offsets, @@ -1353,6 +1352,7 @@ def __init__( self._iswap_version: Optional[str] = None # loaded lazily self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" + self._x_grouping_tolerance_mm: float = 0.1 self._setup_done = False @@ -2075,7 +2075,7 @@ async def probe_liquid_heights( plld_foam_search_speed: float = 10.0, dispense_back_plld_volume: Optional[float] = None, # X grouping tolerance (mm) — containers within this distance share an X group - x_grouping_tolerance: float = X_GROUPING_TOLERANCE_MM, + x_grouping_tolerance: Optional[float] = None, ) -> List[float]: """Probe liquid surface heights in containers using liquid level detection. @@ -2131,7 +2131,7 @@ async def probe_liquid_heights( plld_foam_search_speed: Foam search speed in mm/s. Default 10.0. dispense_back_plld_volume: Volume to dispense back after pLLD in uL. Default None. x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed - together. Default 0.1 mm. + together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). Returns: Mean of measured liquid heights for each container (mm from cavity bottom). @@ -2142,6 +2142,9 @@ async def probe_liquid_heights( RuntimeError: If any specified channel lacks a tip. """ + if x_grouping_tolerance is None: + x_grouping_tolerance = self._x_grouping_tolerance_mm + if n_replicates < 1: raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") if lld_mode not in {self.LLDMode.GAMMA, self.LLDMode.PRESSURE}: diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index c5d5a776333..f72f0e021c5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -13,7 +13,6 @@ STARBackend, ) from pylabrobot.liquid_handling.pipette_batch_scheduling import ( - X_GROUPING_TOLERANCE_MM, validate_probing_inputs, ) from pylabrobot.resources.container import Container @@ -367,7 +366,7 @@ async def probe_liquid_heights( plld_foam_ad_values: int = 30, plld_foam_search_speed: float = 10.0, dispense_back_plld_volume: Optional[float] = None, - x_grouping_tolerance: float = X_GROUPING_TOLERANCE_MM, + x_grouping_tolerance: Optional[float] = None, ) -> List[float]: """Probe liquid heights by computing from tracked container volumes. diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index c4a14c173c8..47e163bb422 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -24,8 +24,6 @@ from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate -X_GROUPING_TOLERANCE_MM = 0.1 - # --- Data types --- @@ -388,7 +386,7 @@ def plan_batches( x_pos: List[float], y_pos: List[float], channel_spacings: Union[float, List[float]], - x_tolerance: float = X_GROUPING_TOLERANCE_MM, + x_tolerance: float, num_channels: Optional[int] = None, max_y: Optional[float] = None, min_y: Optional[float] = None, diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 36d1ed165be..743634a0884 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -36,54 +36,54 @@ class TestPlanBatchesUniformSpacing(unittest.TestCase): # --- X grouping --- def test_single_x_group(self): - batches = plan_batches([0, 1, 2], [100.0] * 3, [270.0, 261.0, 252.0], self.S) + batches = plan_batches([0, 1, 2], [100.0] * 3, [270.0, 261.0, 252.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) self.assertAlmostEqual(batches[0].x_position, 100.0) def test_two_x_groups(self): batches = plan_batches( - [0, 1, 2, 3], [100.0, 100.0, 200.0, 200.0], [270.0, 261.0, 270.0, 261.0], self.S + [0, 1, 2, 3], [100.0, 100.0, 200.0, 200.0], [270.0, 261.0, 270.0, 261.0], self.S, x_tolerance=0.1, ) x_positions = [b.x_position for b in batches] self.assertAlmostEqual(x_positions[0], 100.0) self.assertAlmostEqual(x_positions[-1], 200.0) def test_x_groups_sorted_by_ascending_x(self): - batches = plan_batches([0, 1, 2], [300.0, 100.0, 200.0], [270.0] * 3, self.S) + batches = plan_batches([0, 1, 2], [300.0, 100.0, 200.0], [270.0] * 3, self.S, x_tolerance=0.1) x_positions = [b.x_position for b in batches] self.assertAlmostEqual(x_positions[0], 100.0) self.assertAlmostEqual(x_positions[1], 200.0) self.assertAlmostEqual(x_positions[2], 300.0) def test_x_positions_within_tolerance_grouped(self): - batches = plan_batches([0, 1], [100.0, 100.05], [270.0, 261.0], self.S) + batches = plan_batches([0, 1], [100.0, 100.05], [270.0, 261.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) def test_x_positions_outside_tolerance_split(self): - batches = plan_batches([0, 1], [100.0, 100.2], [270.0, 270.0], self.S) + batches = plan_batches([0, 1], [100.0, 100.2], [270.0, 270.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) # --- Y batching --- def test_consecutive_channels_single_batch(self): - batches = plan_batches([0, 1, 2], [100.0] * 3, [270.0, 261.0, 252.0], self.S) + batches = plan_batches([0, 1, 2], [100.0] * 3, [270.0, 261.0, 252.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) self.assertEqual(sorted(batches[0].channels), [0, 1, 2]) def test_same_y_forces_serialization(self): - batches = plan_batches([0, 1, 2], [100.0] * 3, [200.0] * 3, self.S) + batches = plan_batches([0, 1, 2], [100.0] * 3, [200.0] * 3, self.S, x_tolerance=0.1) self.assertEqual(len(batches), 3) def test_barely_fitting_spacing(self): - batches = plan_batches([0, 1], [100.0] * 2, [209.0, 200.0], self.S) + batches = plan_batches([0, 1], [100.0] * 2, [209.0, 200.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) def test_barely_insufficient_spacing(self): - batches = plan_batches([0, 1], [100.0] * 2, [208.9, 200.0], self.S) + batches = plan_batches([0, 1], [100.0] * 2, [208.9, 200.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) def test_reversed_y_order_splits(self): - batches = plan_batches([0, 1], [100.0] * 2, [200.0, 220.0], self.S) + batches = plan_batches([0, 1], [100.0] * 2, [200.0, 220.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) # --- Non-consecutive channels --- @@ -93,13 +93,13 @@ def test_non_consecutive_channels_fit(self): [0, 1, 2, 5, 6, 7], [100.0] * 6, [300.0, 291.0, 282.0, 255.0, 246.0, 237.0], - self.S, + self.S, x_tolerance=0.1, ) self.assertEqual(len(batches), 1) self.assertEqual(sorted(batches[0].channels), [0, 1, 2, 5, 6, 7]) def test_phantom_channels_interpolated(self): - batches = plan_batches([0, 3], [100.0] * 2, [300.0, 273.0], self.S) + batches = plan_batches([0, 3], [100.0] * 2, [300.0, 273.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) y = batches[0].y_positions self.assertAlmostEqual(y[0], 300.0) @@ -108,7 +108,7 @@ def test_phantom_channels_interpolated(self): self.assertAlmostEqual(y[3], 273.0) def test_phantoms_only_within_batch(self): - batches = plan_batches([0, 3], [100.0] * 2, [200.0, 250.0], self.S) + batches = plan_batches([0, 3], [100.0] * 2, [200.0, 250.0], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) for batch in batches: self.assertEqual(len(batch.y_positions), 1) @@ -120,7 +120,7 @@ def test_mixed_complexity(self): [0, 1, 2, 3], [100.0, 100.0, 200.0, 200.0], [200.0, 200.0, 270.0, 261.0], - self.S, + self.S, x_tolerance=0.1, ) x100 = [b for b in batches if abs(b.x_position - 100.0) < 0.01] x200 = [b for b in batches if abs(b.x_position - 200.0) < 0.01] @@ -131,17 +131,17 @@ def test_mixed_complexity(self): def test_mismatched_lengths(self): with self.assertRaises(ValueError): - plan_batches([0, 1], [100.0], [200.0, 200.0], self.S) + plan_batches([0, 1], [100.0], [200.0, 200.0], self.S, x_tolerance=0.1) def test_empty(self): with self.assertRaises(ValueError): - plan_batches([], [], [], self.S) + plan_batches([], [], [], self.S, x_tolerance=0.1) # --- Index correctness --- def test_indices_map_back_correctly(self): use_channels = [3, 7, 0] - batches = plan_batches(use_channels, [100.0] * 3, [261.0, 237.0, 270.0], self.S) + batches = plan_batches(use_channels, [100.0] * 3, [261.0, 237.0, 270.0], self.S, x_tolerance=0.1) all_indices = [idx for b in batches for idx in b.indices] self.assertEqual(sorted(all_indices), [0, 1, 2]) for batch in batches: @@ -151,12 +151,12 @@ def test_indices_map_back_correctly(self): # --- Realistic --- def test_8_channels_trough(self): - batches = plan_batches(list(range(8)), [100.0] * 8, [300.0 - i * 9.0 for i in range(8)], self.S) + batches = plan_batches(list(range(8)), [100.0] * 8, [300.0 - i * 9.0 for i in range(8)], self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) self.assertEqual(len(batches[0].channels), 8) def test_8_channels_narrow_well(self): - batches = plan_batches(list(range(8)), [100.0] * 8, [200.0] * 8, self.S) + batches = plan_batches(list(range(8)), [100.0] * 8, [200.0] * 8, self.S, x_tolerance=0.1) self.assertEqual(len(batches), 8) def test_channels_0_1_2_5_6_7_phantoms(self): @@ -164,7 +164,7 @@ def test_channels_0_1_2_5_6_7_phantoms(self): [0, 1, 2, 5, 6, 7], [100.0] * 6, [300.0, 291.0, 282.0, 255.0, 246.0, 237.0], - self.S, + self.S, x_tolerance=0.1, ) self.assertEqual(len(batches), 1) y = batches[0].y_positions @@ -181,35 +181,35 @@ class TestPlanBatchesMixedSpacing(unittest.TestCase): SPACINGS = [8.98, 8.98, 17.96, 17.96] def test_two_1ml_channels_fit_at_9mm(self): - batches = plan_batches([0, 1], [100.0] * 2, [208.98, 200.0], self.SPACINGS) + batches = plan_batches([0, 1], [100.0] * 2, [208.98, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 1) def test_1ml_and_5ml_need_wider_spacing(self): - batches = plan_batches([1, 2], [100.0] * 2, [209.0, 200.0], self.SPACINGS) + batches = plan_batches([1, 2], [100.0] * 2, [209.0, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 2) def test_1ml_and_5ml_fit_at_wide_spacing(self): - batches = plan_batches([1, 2], [100.0] * 2, [217.96, 200.0], self.SPACINGS) + batches = plan_batches([1, 2], [100.0] * 2, [217.96, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 1) def test_5ml_channels_fit_at_wide_spacing(self): - batches = plan_batches([2, 3], [100.0] * 2, [217.96, 200.0], self.SPACINGS) + batches = plan_batches([2, 3], [100.0] * 2, [217.96, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 1) def test_5ml_channels_too_close(self): - batches = plan_batches([2, 3], [100.0] * 2, [209.0, 200.0], self.SPACINGS) + batches = plan_batches([2, 3], [100.0] * 2, [209.0, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 2) def test_span_across_1ml_and_5ml(self): # Pairwise sum: max(8.98,8.98) + max(8.98,17.96) + max(17.96,17.96) = 44.9 - batches = plan_batches([0, 3], [100.0] * 2, [244.9, 200.0], self.SPACINGS) + batches = plan_batches([0, 3], [100.0] * 2, [244.9, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 1) - batches = plan_batches([0, 3], [100.0] * 2, [244.0, 200.0], self.SPACINGS) + batches = plan_batches([0, 3], [100.0] * 2, [244.0, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 2) def test_phantom_channels_use_pairwise_spacing(self): # ch0→ch1: max(8.98, 8.98) = 8.98, ch1→ch2: max(8.98, 17.96) = 17.96 - batches = plan_batches([0, 3], [100.0] * 2, [244.9, 200.0], self.SPACINGS) + batches = plan_batches([0, 3], [100.0] * 2, [244.9, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 1) y = batches[0].y_positions self.assertAlmostEqual(y[1], 244.9 - 8.98) @@ -221,7 +221,7 @@ def test_mixed_all_four_channels_spaced_wide(self): [0, 1, 2, 3], [100.0] * 4, [300.0, 300.0 - s, 300.0 - 2 * s, 300.0 - 3 * s], - self.SPACINGS, + self.SPACINGS, x_tolerance=0.1, ) self.assertEqual(len(batches), 1) @@ -229,7 +229,7 @@ def test_pairwise_sum_avoids_unnecessary_split(self): # With spacings [8.98, 8.98, 17.96, 17.96], spanning ch0→ch3 requires # 8.98 + 17.96 + 17.96 = 44.9mm (pairwise sum), NOT 3 * 17.96 = 53.88mm. # A gap of 50mm should fit in one batch (pairwise) even though it's less than 53.88. - batches = plan_batches([0, 3], [100.0] * 2, [250.0, 200.0], self.SPACINGS) + batches = plan_batches([0, 3], [100.0] * 2, [250.0, 200.0], self.SPACINGS, x_tolerance=0.1) self.assertEqual(len(batches), 1) def test_mixed_channels_at_1ml_spacing_forces_serialization(self): @@ -237,7 +237,7 @@ def test_mixed_channels_at_1ml_spacing_forces_serialization(self): [0, 1, 2, 3], [100.0] * 4, [300.0, 291.0, 282.0, 273.0], - self.SPACINGS, + self.SPACINGS, x_tolerance=0.1, ) self.assertGreater(len(batches), 1) From a032cd5b05578f1aa1d8a3cfd690b46e97214cba Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 20 Mar 2026 15:30:27 +0000 Subject: [PATCH 03/39] Fix spacings list sizing in plan_batches when num_channels exceeds max(use_channels) --- pylabrobot/liquid_handling/pipette_batch_scheduling.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 47e163bb422..7957ca7f73a 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -427,12 +427,16 @@ def plan_batches( if len(use_channels) == 0: raise ValueError("use_channels must not be empty.") - # Normalize scalar spacing to per-channel list + # Normalize scalar spacing to per-channel list. + # Size must cover all channels up to num_channels (if provided) for transition optimization. max_ch = max(use_channels) + min_len = max(max_ch + 1, num_channels or 0) if isinstance(channel_spacings, (int, float)): - spacings: List[float] = [float(channel_spacings)] * (max_ch + 1) + spacings: List[float] = [float(channel_spacings)] * min_len else: - spacings = channel_spacings + spacings = list(channel_spacings) + if len(spacings) < min_len: + spacings.extend([spacings[-1]] * (min_len - len(spacings))) # Group indices by X position (preserving first-appearance order). # Uses floor-based bucketing to avoid Python's banker's rounding at boundaries. From 431097384ed705128e42a87da1869a7cb5365f5b Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 20 Mar 2026 15:42:53 +0000 Subject: [PATCH 04/39] Remove detection parameter exposure from probe_liquid_heights (defer to follow-up PR) --- .../backends/hamilton/STAR_backend.py | 79 +------------------ .../backends/hamilton/STAR_chatterbox.py | 63 +-------------- 2 files changed, 3 insertions(+), 139 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index c16385b7451..d0c3c72529b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2047,33 +2047,6 @@ async def probe_liquid_heights( min_traverse_height_at_beginning_of_command: Optional[float] = None, min_traverse_height_during_command: Optional[float] = None, z_position_at_end_of_command: Optional[float] = None, - # Shared detection parameters - channel_acceleration: float = 800.0, - post_detection_trajectory: Literal[0, 1] = 1, - post_detection_dist: float = 0.0, - # cLLD-specific parameters (used when lld_mode=GAMMA) - detection_edge: int = 10, - detection_drop: int = 2, - # pLLD-specific parameters (used when lld_mode=PRESSURE) - channel_speed_above_start_pos_search: float = 120.0, - z_drive_current_limit: int = 3, - tip_has_filter: bool = False, - dispense_drive_speed: float = 5.0, - dispense_drive_acceleration: float = 0.2, - dispense_drive_max_speed: float = 14.5, - dispense_drive_current_limit: int = 3, - plld_detection_edge: int = 30, - plld_detection_drop: int = 10, - clld_verification: bool = False, - clld_detection_edge: int = 10, - clld_detection_drop: int = 2, - max_delta_plld_clld: float = 5.0, - plld_mode: Optional[PressureLLDMode] = None, # defaults to PressureLLDMode.LIQUID - plld_foam_detection_drop: int = 30, - plld_foam_detection_edge_tolerance: int = 30, - plld_foam_ad_values: int = 30, - plld_foam_search_speed: float = 10.0, - dispense_back_plld_volume: Optional[float] = None, # X grouping tolerance (mm) — containers within this distance share an X group x_grouping_tolerance: Optional[float] = None, ) -> List[float]: @@ -2106,30 +2079,6 @@ async def probe_liquid_heights( between batches. None (default) uses full Z safety. z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after probing (only used when move_to_z_safety_after is True). None (default) uses full Z safety. - channel_acceleration: Search acceleration in mm/s^2. Default 800.0. - post_detection_trajectory: Post-detection move mode (0 or 1). Default 1. - post_detection_dist: Distance in mm to move up after detection. Default 0.0. - detection_edge: cLLD edge steepness threshold (0-1023). Default 10. - detection_drop: cLLD offset after edge detection (0-1023). Default 2. - channel_speed_above_start_pos_search: pLLD speed above search start in mm/s. Default 120.0. - z_drive_current_limit: pLLD Z-drive current limit. Default 3. - tip_has_filter: Whether tip has a filter. Default False. - dispense_drive_speed: pLLD dispense drive speed in mm/s. Default 5.0. - dispense_drive_acceleration: pLLD dispense drive acceleration in mm/s^2. Default 0.2. - dispense_drive_max_speed: pLLD dispense drive max speed in mm/s. Default 14.5. - dispense_drive_current_limit: pLLD dispense drive current limit. Default 3. - plld_detection_edge: pLLD edge detection threshold. Default 30. - plld_detection_drop: pLLD detection drop. Default 10. - clld_verification: Enable cLLD verification in pLLD mode. Default False. - clld_detection_edge: cLLD verification edge threshold. Default 10. - clld_detection_drop: cLLD verification drop. Default 2. - max_delta_plld_clld: Max allowed delta between pLLD and cLLD in mm. Default 5.0. - plld_mode: Pressure LLD mode. Defaults to PressureLLDMode.LIQUID for pLLD. - plld_foam_detection_drop: Foam detection drop. Default 30. - plld_foam_detection_edge_tolerance: Foam detection edge tolerance. Default 30. - plld_foam_ad_values: Foam AD values. Default 30. - plld_foam_search_speed: Foam search speed in mm/s. Default 10.0. - dispense_back_plld_volume: Volume to dispense back after pLLD in uL. Default None. x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). @@ -2217,32 +2166,11 @@ async def probe_liquid_heights( detect_func: Callable[..., Any] if lld_mode == self.LLDMode.GAMMA: detect_func = self._move_z_drive_to_liquid_surface_using_clld - extra_kwargs: dict = { - "detection_edge": detection_edge, - "detection_drop": detection_drop, - } + extra_kwargs: dict = {} else: detect_func = self._search_for_surface_using_plld extra_kwargs = { - "channel_speed_above_start_pos_search": channel_speed_above_start_pos_search, - "z_drive_current_limit": z_drive_current_limit, - "tip_has_filter": tip_has_filter, - "dispense_drive_speed": dispense_drive_speed, - "dispense_drive_acceleration": dispense_drive_acceleration, - "dispense_drive_max_speed": dispense_drive_max_speed, - "dispense_drive_current_limit": dispense_drive_current_limit, - "plld_detection_edge": plld_detection_edge, - "plld_detection_drop": plld_detection_drop, - "clld_verification": clld_verification, - "clld_detection_edge": clld_detection_edge, - "clld_detection_drop": clld_detection_drop, - "max_delta_plld_clld": max_delta_plld_clld, - "plld_mode": plld_mode if plld_mode is not None else self.PressureLLDMode.LIQUID, - "plld_foam_detection_drop": plld_foam_detection_drop, - "plld_foam_detection_edge_tolerance": plld_foam_detection_edge_tolerance, - "plld_foam_ad_values": plld_foam_ad_values, - "plld_foam_search_speed": plld_foam_search_speed, - "dispense_back_plld_volume": dispense_back_plld_volume, + "plld_mode": self.PressureLLDMode.LIQUID, } # Execute batches @@ -2293,9 +2221,6 @@ async def probe_liquid_heights( lowest_immers_pos=lip, start_pos_search=sps, channel_speed=search_speed, - channel_acceleration=channel_acceleration, - post_detection_trajectory=post_detection_trajectory, - post_detection_dist=post_detection_dist, **extra_kwargs, ) for channel, lip, sps in zip(batch.channels, batch_lowest_immers, batch_start_pos) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index f72f0e021c5..2eb4c25aaf1 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -19,9 +19,8 @@ from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.well import Well -# Type aliases for nested enums (for cleaner signatures) +# Type alias for nested enum (for cleaner signatures) LLDMode = STARBackend.LLDMode -PressureLLDMode = STARBackend.PressureLLDMode _DEFAULT_MACHINE_CONFIGURATION = MachineConfiguration( pip_type_1000ul=True, @@ -342,30 +341,6 @@ async def probe_liquid_heights( min_traverse_height_at_beginning_of_command: Optional[float] = None, min_traverse_height_during_command: Optional[float] = None, z_position_at_end_of_command: Optional[float] = None, - channel_acceleration: float = 800.0, - post_detection_trajectory: Literal[0, 1] = 1, - post_detection_dist: float = 0.0, - detection_edge: int = 10, - detection_drop: int = 2, - channel_speed_above_start_pos_search: float = 120.0, - z_drive_current_limit: int = 3, - tip_has_filter: bool = False, - dispense_drive_speed: float = 5.0, - dispense_drive_acceleration: float = 0.2, - dispense_drive_max_speed: float = 14.5, - dispense_drive_current_limit: int = 3, - plld_detection_edge: int = 30, - plld_detection_drop: int = 10, - clld_verification: bool = False, - clld_detection_edge: int = 10, - clld_detection_drop: int = 2, - max_delta_plld_clld: float = 5.0, - plld_mode: Optional[PressureLLDMode] = None, - plld_foam_detection_drop: int = 30, - plld_foam_detection_edge_tolerance: int = 30, - plld_foam_ad_values: int = 30, - plld_foam_search_speed: float = 10.0, - dispense_back_plld_volume: Optional[float] = None, x_grouping_tolerance: Optional[float] = None, ) -> List[float]: """Probe liquid heights by computing from tracked container volumes. @@ -376,7 +351,6 @@ async def probe_liquid_heights( Args: containers: List of Container objects to probe, one per channel. use_channels: Channel indices to use (0-indexed). Defaults to ``[0, ..., len(containers)-1]``. - resource_offsets: Accepted for API compatibility but unused in mock. All other parameters: Accepted for API compatibility but unused in mock. Returns: @@ -387,41 +361,6 @@ async def probe_liquid_heights( ``containers`` and ``use_channels`` have different lengths. NoTipError: If any specified channel lacks a tip. """ - # Unused parameters kept for signature compatibility: - _ = ( - lld_mode, - search_speed, - n_replicates, - move_to_z_safety_after, - min_traverse_height_at_beginning_of_command, - min_traverse_height_during_command, - z_position_at_end_of_command, - channel_acceleration, - post_detection_trajectory, - post_detection_dist, - detection_edge, - detection_drop, - channel_speed_above_start_pos_search, - z_drive_current_limit, - tip_has_filter, - dispense_drive_speed, - dispense_drive_acceleration, - dispense_drive_max_speed, - dispense_drive_current_limit, - plld_detection_edge, - plld_detection_drop, - clld_verification, - clld_detection_edge, - clld_detection_drop, - max_delta_plld_clld, - plld_mode, - plld_foam_detection_drop, - plld_foam_detection_edge_tolerance, - plld_foam_ad_values, - plld_foam_search_speed, - dispense_back_plld_volume, - x_grouping_tolerance, - ) use_channels = validate_probing_inputs( containers=containers, use_channels=use_channels, From 607565bec7d1c8ac24e3b881b5d09457e44fcc4b Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 20 Mar 2026 16:15:02 +0000 Subject: [PATCH 05/39] explain dual exception handling --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index d0c3c72529b..7a49ee2350d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2253,10 +2253,10 @@ async def probe_liquid_heights( prev_batch = batch - except Exception: + except Exception: # firmware errors, RuntimeError, etc. await self.move_all_channels_in_z_safety() raise - except BaseException: + except BaseException: # KeyboardInterrupt, SystemExit — still must raise channels await self.move_all_channels_in_z_safety() raise From 6d53765e907da2fd4862b3b05b5d4d8a4cf985d8 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sun, 22 Mar 2026 19:44:01 +0000 Subject: [PATCH 06/39] Refactor plan_batches to accept containers, add execute_batched, fix odd-span wall crash - plan_batches now takes targets (Containers or Coordinates) and handles position computation and same-container spreading internally, replacing the external compute_offsets + compute_positions + plan_batches sequence - Restore execute_batched on STARBackend; probe_liquid_heights uses it via _probe_batch_heights closure instead of an inline batch loop - Make +5.5mm odd-span center-avoidance offset conditional on container width to prevent tip-wall collisions on narrow containers - Generalize compute_positions to accept any Resource (wrt_resource), not just Deck - Remove dead code: _optimize_batch_transitions (LATER :), _find_next_y_target - Rename validate_probing_inputs -> validate_channel_selections - Clean up redundant tests, add container-path coverage --- .../backends/hamilton/STAR_backend.py | 252 ++++++++---------- .../backends/hamilton/STAR_chatterbox.py | 25 +- .../backends/hamilton/STAR_tests.py | 47 ++-- 3 files changed, 165 insertions(+), 159 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 7a49ee2350d..d8709dbc066 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1,5 +1,4 @@ import asyncio -from collections import defaultdict import datetime import enum import functools @@ -13,6 +12,7 @@ from dataclasses import dataclass, field from typing import ( Any, + Awaitable, Callable, Coroutine, Dict, @@ -46,10 +46,8 @@ ) from pylabrobot.liquid_handling.pipette_batch_scheduling import ( ChannelBatch, - compute_positions, - compute_single_container_offsets, plan_batches, - validate_probing_inputs, + validate_channel_selections, ) from pylabrobot.liquid_handling.standard import ( Drop, @@ -2034,6 +2032,50 @@ class PressureLLDMode(enum.Enum): LIQUID = 0 FOAM = 1 + async def execute_batched( + self, + func: Callable[[ChannelBatch], Awaitable[None]], + batches: List[ChannelBatch], + min_traverse_height_during_command: Optional[float] = None, + ) -> None: + """Execute a Z-axis callback across pre-planned batches with X/Y positioning. + + Handles inter-batch safety: raises channels between batches, moves X when the + X group changes, and positions Y before calling *func*. On error or + KeyboardInterrupt, channels are moved to Z safety before re-raising. + + Args: + func: Async callback that receives a ``ChannelBatch`` and performs Z-axis work + (e.g. liquid level detection, z-touch probing). Must not move X or Y. + batches: Pre-planned batches from ``plan_batches()``. + min_traverse_height_during_command: Absolute Z height (mm) for inter-batch + channel raises. ``None`` uses full Z safety. + """ + try: + prev_batch: Optional[ChannelBatch] = None + for batch in batches: + if prev_batch is not None: + if min_traverse_height_during_command is None: + await self.move_all_channels_in_z_safety() + else: + await self.position_channels_in_z_direction( + {ch: min_traverse_height_during_command for ch in prev_batch.channels} + ) + + if prev_batch is None or batch.x_position != prev_batch.x_position: + await self.move_channel_x(0, batch.x_position) + + await self.position_channels_in_y_direction(batch.y_positions) + await func(batch) + prev_batch = batch + + except Exception: # firmware errors, RuntimeError, etc. + await self.move_all_channels_in_z_safety() + raise + except BaseException: # KeyboardInterrupt, SystemExit — still must raise channels + await self.move_all_channels_in_z_safety() + raise + async def probe_liquid_heights( self, containers: List[Container], @@ -2056,29 +2098,27 @@ async def probe_liquid_heights( container positions and sensing the liquid surface. Heights are measured from the bottom of each container's cavity. - Automatically handles any channel/container configuration: - - Containers at different X positions are grouped and probed sequentially - - Channels are partitioned into parallel-compatible Y batches respecting per-channel - minimum spacing (supports mixed 1mL + 5mL channel configurations) - - Phantom channels between non-consecutive batch members are positioned automatically + Uses ``plan_batches`` for X/Y partitioning and auto-spreading, then ``execute_batched`` + to iterate batches with Z safety. Args: containers: List of Container objects to probe, one per channel. use_channels: Channel indices to use for probing (0-indexed). - resource_offsets: Optional XYZ offsets from container centers. Auto-calculated for single - containers with odd channel counts to avoid center dividers. Defaults to container centers. + resource_offsets: Optional XYZ offsets from container centers. When not provided, + ``plan_batches`` auto-spreads channels targeting the same container. lld_mode: Detection mode - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. Defaults to capacitive. search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. n_replicates: Number of measurements per channel. Default 1. move_to_z_safety_after: Whether to move channels to safe Z height after probing. - Default True. + Set to False when probing is immediately followed by another Z operation (e.g. + aspirate) to avoid unnecessary Z travel. Default True. min_traverse_height_at_beginning_of_command: Absolute Z height (mm) to move involved channels to before the first batch. None (default) uses full Z safety. min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to between batches. None (default) uses full Z safety. z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after - probing (only used when move_to_z_safety_after is True). None (default) uses full Z safety. + probing. None (default) uses full Z safety. x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). @@ -2099,52 +2139,29 @@ async def probe_liquid_heights( if lld_mode not in {self.LLDMode.GAMMA, self.LLDMode.PRESSURE}: raise ValueError(f"LLDMode must be 1 (capacitive) or 2 (pressure-based), is {lld_mode}") - use_channels = validate_probing_inputs( + use_channels = validate_channel_selections( containers=containers, use_channels=use_channels, num_channels=self.num_channels, ) - if resource_offsets is not None and len(resource_offsets) != len(containers): - raise ValueError( - "Length of resource_offsets must match the length of containers and use_channels, " - f"got lengths {len(resource_offsets)} (resource_offsets) and " - f"{len(containers)} (containers/use_channels)." - ) - - if resource_offsets is None: - resource_offsets = [Coordinate.zero()] * len(containers) - container_groups: Dict[int, List[int]] = defaultdict(list) - for idx, c in enumerate(containers): - container_groups[id(c)].append(idx) - for indices in container_groups.values(): - if len(indices) < 2: - continue - group_channels = [use_channels[i] for i in indices] - offsets = compute_single_container_offsets( - container=containers[indices[0]], - use_channels=group_channels, - channel_spacings=self._channels_minimum_y_spacing, - ) - if offsets is not None: - for i, idx_val in enumerate(indices): - resource_offsets[idx_val] = offsets[i] - # Verify tips and query tip lengths tip_presence = await self.request_tip_presence() if not all(tip_presence[idx] for idx in use_channels): raise RuntimeError("All specified channels must have tips attached.") tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] - # Initial Z raise + # TODO: this raises ALL channels to max Z, then lowers involved channels back down — + # wasteful yoyo motion. Should raise uninvolved to safety and involved to + # min_traverse_height_at_beginning_of_command in one pass. Requires a channel-filtered + # version of move_all_channels_in_z_safety. await self.move_all_channels_in_z_safety() if min_traverse_height_at_beginning_of_command is not None: await self.position_channels_in_z_direction( {ch: min_traverse_height_at_beginning_of_command for ch in use_channels} ) - # Compute target positions - x_pos, y_pos = compute_positions(containers, resource_offsets, self.deck) + # Compute Z positions z_cavity_bottom: List[float] = [] z_top: List[float] = [] for resource in containers: @@ -2153,112 +2170,76 @@ async def probe_liquid_heights( batches = plan_batches( use_channels=use_channels, - x_pos=x_pos, - y_pos=y_pos, + targets=containers, channel_spacings=self._channels_minimum_y_spacing, x_tolerance=x_grouping_tolerance, - num_channels=self.num_channels, - max_y=self.extended_conf.pip_maximal_y_position, - min_y=self.extended_conf.left_arm_min_y_position, + wrt_resource=self.deck, + resource_offsets=resource_offsets, ) # Select detection function and kwargs detect_func: Callable[..., Any] if lld_mode == self.LLDMode.GAMMA: detect_func = self._move_z_drive_to_liquid_surface_using_clld - extra_kwargs: dict = {} else: detect_func = self._search_for_surface_using_plld - extra_kwargs = { - "plld_mode": self.PressureLLDMode.LIQUID, - } # Execute batches absolute_heights_measurements: Dict[int, List[Optional[float]]] = { ch: [] for ch in use_channels } - try: - prev_batch: Optional[ChannelBatch] = None - for batch in batches: - # Raise previous batch's channels before repositioning - if prev_batch is not None: - if min_traverse_height_during_command is None: - await self.move_all_channels_in_z_safety() - else: - await self.position_channels_in_z_direction( - {ch: min_traverse_height_during_command for ch in prev_batch.channels} - ) - - # Move X carriage if needed (new X group or first batch) - if ( - prev_batch is None or abs(batch.x_position - prev_batch.x_position) > x_grouping_tolerance - ): - await self.move_channel_x(0, batch.x_position) - - # Position channels in Y (includes phantom channels from plan_batches) - await self.position_channels_in_y_direction(batch.y_positions) + async def _probe_batch_heights(batch: ChannelBatch) -> None: + batch_lowest_immers = [ + z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH for i in batch.indices + ] + batch_start_pos = [ + z_top[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH + self.SEARCH_START_CLEARANCE_MM + for i in batch.indices + ] - # Z search bounds from precomputed container positions - batch_lowest_immers = [ - z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH - for i in batch.indices - ] - batch_start_pos = [ - z_top[i] - + tip_lengths[i] - - self.DEFAULT_TIP_FITTING_DEPTH - + self.SEARCH_START_CLEARANCE_MM - for i in batch.indices - ] + for _ in range(n_replicates): + results = await asyncio.gather( + *[ + detect_func( + channel_idx=channel, + lowest_immers_pos=lip, + start_pos_search=sps, + channel_speed=search_speed, + ) + for channel, lip, sps in zip(batch.channels, batch_lowest_immers, batch_start_pos) + ], + return_exceptions=True, + ) - # Run detection n_replicates times - for _ in range(n_replicates): - results = await asyncio.gather( - *[ - detect_func( - channel_idx=channel, - lowest_immers_pos=lip, - start_pos_search=sps, - channel_speed=search_speed, - **extra_kwargs, + current_absolute_liquid_heights = await self.request_pip_height_last_lld() + for local_idx, (ch_idx, result) in enumerate(zip(batch.channels, results)): + orig_idx = batch.indices[local_idx] + if isinstance(result, STARFirmwareError): + error_msg = str(result).lower() + if "no liquid level found" in error_msg or "no liquid was present" in error_msg: + height = None + msg = ( + f"Channel {ch_idx}: No liquid detected. Could be because there is " + f"no liquid in container {containers[orig_idx].name} or liquid level " + f"is too low." ) - for channel, lip, sps in zip(batch.channels, batch_lowest_immers, batch_start_pos) - ], - return_exceptions=True, - ) - - current_absolute_liquid_heights = await self.request_pip_height_last_lld() - for local_idx, (ch_idx, result) in enumerate(zip(batch.channels, results)): - orig_idx = batch.indices[local_idx] - if isinstance(result, STARFirmwareError): - error_msg = str(result).lower() - if "no liquid level found" in error_msg or "no liquid was present" in error_msg: - height = None - msg = ( - f"Channel {ch_idx}: No liquid detected. Could be because there is " - f"no liquid in container {containers[orig_idx].name} or liquid level " - f"is too low." - ) - if lld_mode == self.LLDMode.GAMMA: - msg += " Consider using pressure-based LLD if liquid is believed to exist." - logger.warning(msg) - else: - raise result - elif isinstance(result, Exception): - raise result + if lld_mode == self.LLDMode.GAMMA: + msg += " Consider using pressure-based LLD if liquid is believed to exist." + logger.warning(msg) else: - height = current_absolute_liquid_heights[ch_idx] - absolute_heights_measurements[ch_idx].append(height) - - prev_batch = batch + raise result + elif isinstance(result, Exception): + raise result + else: + height = current_absolute_liquid_heights[ch_idx] + absolute_heights_measurements[ch_idx].append(height) - except Exception: # firmware errors, RuntimeError, etc. - await self.move_all_channels_in_z_safety() - raise - except BaseException: # KeyboardInterrupt, SystemExit — still must raise channels - await self.move_all_channels_in_z_safety() - raise + await self.execute_batched( + func=_probe_batch_heights, + batches=batches, + min_traverse_height_during_command=min_traverse_height_during_command, + ) # Compute liquid heights relative to well bottom relative_to_well: List[float] = [] @@ -10394,9 +10375,9 @@ async def clld_probe_y_position_using_channel( # Machine-compatibility check of calculated parameters assert 0 <= max_y_search_pos_increments <= 13_714, ( - "Maximum y search position must be between \n0 and" - + f"{STARBackend.y_drive_increment_to_mm(13_714) + self._channels_minimum_y_spacing[0]} mm," - + f" is {max_y_search_pos_increments} mm" + "Maximum y search position must be between 0 and " + + f"{STARBackend.y_drive_increment_to_mm(13_714) + self._channels_minimum_y_spacing[0]:.1f} mm, " + + f"is {STARBackend.y_drive_increment_to_mm(max_y_search_pos_increments):.1f} mm" ) assert 20 <= channel_speed_increments <= 8_000, ( f"LLD search speed must be between \n{STARBackend.y_drive_increment_to_mm(20)}" @@ -11320,9 +11301,10 @@ async def get_channels_y_positions(self) -> Dict[int, float]: y_positions = [round(y / 10, 2) for y in resp["ry"]] # sometimes there is (likely) a floating point error and channels are reported to be - # less than 9mm apart. (When you set channels using position_channels_in_y_direction, - # it will raise an error.) The minimum y is 6mm, so we fix that first (in case that - # value is misreported). Then, we traverse the list in reverse and set the min_diff. + # closer together than the minimum required spacing. (When you set channels using + # position_channels_in_y_direction, it will raise an error.) We first ensure the last + # channel is not reported in front of the known minimum Y position, then traverse the + # list in reverse and enforce the per-channel minimum spacing. min_y = self.extended_conf.left_arm_min_y_position if y_positions[-1] < min_y - 0.2: raise RuntimeError( @@ -11378,9 +11360,7 @@ async def position_channels_in_y_direction(self, ys: Dict[int, float], make_spac for intermediate_ch in range(back_channel + 1, front_channel): if intermediate_ch not in ys: pair_spacing = self._min_spacing_between(intermediate_ch - 1, intermediate_ch) - channel_locations[intermediate_ch] = ( - channel_locations[intermediate_ch - 1] - pair_spacing - ) + channel_locations[intermediate_ch] = channel_locations[intermediate_ch - 1] - pair_spacing # Similarly for the channels to the front of `front_channel`, make sure they are all # spaced by the per-pair minimum. This time, we iterate from back (closest to diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 2eb4c25aaf1..847b9bfdc32 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -13,7 +13,9 @@ STARBackend, ) from pylabrobot.liquid_handling.pipette_batch_scheduling import ( - validate_probing_inputs, + plan_batches, + print_batches, + validate_channel_selections, ) from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate @@ -351,6 +353,7 @@ async def probe_liquid_heights( Args: containers: List of Container objects to probe, one per channel. use_channels: Channel indices to use (0-indexed). Defaults to ``[0, ..., len(containers)-1]``. + resource_offsets: Passed to ``plan_batches`` for auto-spreading. See ``plan_batches``. All other parameters: Accepted for API compatibility but unused in mock. Returns: @@ -361,7 +364,10 @@ async def probe_liquid_heights( ``containers`` and ``use_channels`` have different lengths. NoTipError: If any specified channel lacks a tip. """ - use_channels = validate_probing_inputs( + if x_grouping_tolerance is None: + x_grouping_tolerance = self._x_grouping_tolerance_mm + + use_channels = validate_channel_selections( containers=containers, use_channels=use_channels, num_channels=self.num_channels, @@ -371,6 +377,18 @@ async def probe_liquid_heights( for ch in use_channels: self.head[ch].get_tip() # Raises NoTipError if no tip + batches = plan_batches( + use_channels=use_channels, + targets=containers, + channel_spacings=self._channels_minimum_y_spacing, + x_tolerance=x_grouping_tolerance, + wrt_resource=self.deck, + resource_offsets=resource_offsets, + ) + + print_batches(batches, use_channels, containers, label="probe_liquid_heights plan") + + # Compute heights from volume trackers heights: List[float] = [] for container in containers: volume = container.tracker.get_used_volume() @@ -380,6 +398,5 @@ async def probe_liquid_heights( height = container.compute_height_from_volume(volume) heights.append(height) - print(f"probe_liquid_heights: {[f'{h:.2f}' for h in heights]} mm") + print(f" heights: {[f'{h:.2f}' for h in heights]} mm") return heights - diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 529eb17b470..74c111666e8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -41,9 +41,9 @@ parse_star_fw_string, ) from .STAR_chatterbox import ( - STARChatterboxBackend, _DEFAULT_EXTENDED_CONFIGURATION, _DEFAULT_MACHINE_CONFIGURATION, + STARChatterboxBackend, ) @@ -1584,35 +1584,48 @@ def _standard_mocks(self, detect_side_effect=None): self.STAR, "_move_z_drive_to_liquid_surface_using_clld", detect_side_effect ) mocks["plld"] = unittest.mock.patch.object( - self.STAR, "_search_for_surface_using_plld", - new_callable=unittest.mock.AsyncMock, return_value=None, + self.STAR, + "_search_for_surface_using_plld", + new_callable=unittest.mock.AsyncMock, + return_value=None, ) mocks["pip_height"] = unittest.mock.patch.object( - self.STAR, "request_pip_height_last_lld", - new_callable=unittest.mock.AsyncMock, return_value=list(range(12)), + self.STAR, + "request_pip_height_last_lld", + new_callable=unittest.mock.AsyncMock, + return_value=list(range(12)), ) mocks["tip_len"] = unittest.mock.patch.object( - self.STAR, "request_tip_len_on_channel", - new_callable=unittest.mock.AsyncMock, return_value=59.9, + self.STAR, + "request_tip_len_on_channel", + new_callable=unittest.mock.AsyncMock, + return_value=59.9, ) mocks["tip_presence"] = unittest.mock.patch.object( - self.STAR, "request_tip_presence", - new_callable=unittest.mock.AsyncMock, return_value={i: True for i in range(8)}, + self.STAR, + "request_tip_presence", + new_callable=unittest.mock.AsyncMock, + return_value={i: True for i in range(8)}, ) mocks["z_safety"] = unittest.mock.patch.object( - self.STAR, "move_all_channels_in_z_safety", + self.STAR, + "move_all_channels_in_z_safety", new_callable=unittest.mock.AsyncMock, ) mocks["move_x"] = unittest.mock.patch.object( - self.STAR, "move_channel_x", + self.STAR, + "move_channel_x", new_callable=unittest.mock.AsyncMock, ) mocks["pos_y"] = unittest.mock.patch.object( - self.STAR, "position_channels_in_y_direction", + self.STAR, + "position_channels_in_y_direction", new_callable=unittest.mock.AsyncMock, ) mocks["backmost_y"] = unittest.mock.patch.object( - self.STAR.extended_conf, "pip_maximal_y_position", 606.5, + self.STAR.extended_conf, + "pip_maximal_y_position", + 606.5, ) return mocks @@ -1638,9 +1651,7 @@ async def test_n_replicates(self): mocks = self._standard_mocks(detect_side_effect=mock_detect) with contextlib.ExitStack() as stack: entered = {k: stack.enter_context(v) for k, v in mocks.items()} - await self.STAR.probe_liquid_heights( - containers=[well], use_channels=[0], n_replicates=3 - ) + await self.STAR.probe_liquid_heights(containers=[well], use_channels=[0], n_replicates=3) self.assertEqual(mock_detect.await_count, 3) @@ -1703,9 +1714,7 @@ async def side_effect(**kwargs): with contextlib.ExitStack() as stack: entered = {k: stack.enter_context(v) for k, v in mocks.items()} with self.assertRaises(RuntimeError): - await self.STAR.probe_liquid_heights( - containers=[well], use_channels=[0], n_replicates=2 - ) + await self.STAR.probe_liquid_heights(containers=[well], use_channels=[0], n_replicates=2) async def test_pressure_lld_mode(self): well = self.plate.get_item("A1") From 564f36a1b9d98acab671633fa1b447c716c697d3 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sun, 22 Mar 2026 21:26:11 +0000 Subject: [PATCH 07/39] create `print_batches` --- .../pipette_batch_scheduling.py | 381 +++++++++-------- .../pipette_batch_scheduling_tests.py | 398 ++++++++---------- 2 files changed, 374 insertions(+), 405 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 7957ca7f73a..749b4a7d9dd 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -5,17 +5,17 @@ simultaneously. batches = plan_batches( - use_channels, x_pos, y_pos, channel_spacings=[9.0]*8, - num_channels=8, max_y=635.0, min_y=6.0, + use_channels, targets=containers, + channel_spacings=[9.0]*8, x_tolerance=0.1, + wrt_resource=deck, ) - for batch in batches: - backend.position_channels_in_y_direction(batch.y_positions) - ... + await backend.execute_batched(func=my_z_callback, batches=batches) """ import math +from collections import defaultdict from dataclasses import dataclass, field -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union, cast from pylabrobot.liquid_handling.utils import ( MIN_SPACING_EDGE, @@ -23,7 +23,7 @@ ) from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate - +from pylabrobot.resources.resource import Resource # --- Data types --- @@ -42,6 +42,54 @@ class ChannelBatch: y_positions: Dict[int, float] = field(default_factory=dict) # includes phantoms +def print_batches( + batches: List[ChannelBatch], + use_channels: List[int], + containers: List["Container"], + label: str = "plan", +) -> None: + """Print a tree view of the batch execution plan. + + Groups batches by X position and shows Y batches nested within each X group. + Active channels are marked with ``*``, phantoms with a space. + + Args: + batches: Output from ``plan_batches()``. + use_channels: Channel indices (parallel with *containers*). + containers: Container objects (parallel with *use_channels*). + label: Header label for the tree. + """ + + ch_to_container = dict(zip(use_channels, containers)) + + x_groups: Dict[float, list] = {} + for b in batches: + x_key = round(b.x_position, 1) + x_groups.setdefault(x_key, []).append(b) + + print(f"{label}:") + xg_keys = list(x_groups.keys()) + for xg_i, x_key in enumerate(xg_keys): + xg_batches = x_groups[x_key] + is_last_xg = xg_i == len(xg_keys) - 1 + xg_branch = "\u2514" if is_last_xg else "\u251c" + xg_cont = " " if is_last_xg else "\u2502" + print(f" {xg_branch}\u2500\u2500 x-group {xg_i + 1} (x={x_key:.1f} mm)") + for yb_i, b in enumerate(xg_batches): + is_last_yb = yb_i == len(xg_batches) - 1 + yb_branch = "\u2514" if is_last_yb else "\u251c" + yb_cont = " " if is_last_yb else "\u2502" + print(f" {xg_cont} {yb_branch}\u2500\u2500 y-batch {yb_i + 1}") + for ch in sorted(b.y_positions.keys()): + is_last_ch = ch == max(b.y_positions.keys()) + ch_branch = "\u2514" if is_last_ch else "\u251c" + active = "*" if ch in b.channels else " " + container_name = f" ({ch_to_container[ch].name})" if ch in ch_to_container else "" + print( + f" {xg_cont} {yb_cont} {ch_branch}\u2500\u2500 {active}ch{ch}: y={b.y_positions[ch]:.1f} mm{container_name}" + ) + + # --- Spacing helpers --- @@ -57,11 +105,10 @@ def _effective_spacing(spacings: List[float], ch_lo: int, ch_hi: int) -> float: def _span_required(spacings: List[float], ch_lo: int, ch_hi: int) -> float: """Minimum total Y distance required between channels ch_lo and ch_hi. - Sums the actual pairwise spacing for each adjacent pair in the range, where each - pair's spacing is ``max(spacings[k], spacings[k+1])``. This is tighter than - ``(ch_hi - ch_lo) * max(spacings[ch_lo:ch_hi+1])`` when spacings are non-uniform. + Sums the rounded pairwise spacing for each adjacent pair in the range via + ``_min_spacing_between``, matching what the firmware enforces. """ - return sum(max(spacings[ch], spacings[ch + 1]) for ch in range(ch_lo, ch_hi)) + return sum(_min_spacing_between(spacings, ch, ch + 1) for ch in range(ch_lo, ch_hi)) def _min_spacing_between(spacings: List[float], i: int, j: int) -> float: @@ -119,7 +166,7 @@ def _interpolate_phantoms( ch_lo, ch_hi = sorted_chs[k], sorted_chs[k + 1] cumulative = 0.0 for phantom in range(ch_lo + 1, ch_hi): - cumulative += max(spacings[phantom - 1], spacings[phantom]) + cumulative += _min_spacing_between(spacings, phantom - 1, phantom) if phantom not in y_positions: y_positions[phantom] = y_positions[ch_lo] - cumulative @@ -173,116 +220,43 @@ def _partition_into_y_batches( return result -# --- Batch transition optimization --- - +# --- Input validation and position computation --- -def _find_next_y_target( - channel: int, start_batch: int, batches: List[ChannelBatch] -) -> Optional[float]: - """Return the Y position where *channel* is next needed. - Searches ``batches[start_batch:]`` for the first batch whose ``y_positions`` - contains *channel* (active or phantom). Returns ``None`` if not found. - """ - for batch in batches[start_batch:]: - if channel in batch.y_positions: - return batch.y_positions[channel] - return None +# Shift applied to odd channel spans to avoid center divider walls in troughs. +# Will be replaced by per-container no_go_zones (see docs/proposals/container_no_go_zones.md). +_ODD_SPAN_CENTER_AVOIDANCE = 5.5 # mm -def _optimize_batch_transitions( - batches: List[ChannelBatch], - num_channels: int, - spacings: List[float], - max_y: float, - min_y: float, -) -> None: - """Pre-position idle channels toward their next-needed Y coordinate. - - Mutates each batch's ``y_positions`` in-place so it contains keys for ALL - ``num_channels`` channels, ensuring every channel has a defined position - for every batch. +def _offsets_for_consecutive_group( + container: Container, + use_channels: List[int], + spacing: float, +) -> Optional[List[Coordinate]]: + """Compute spread offsets for a group of channels whose full physical span fits the container.""" + ch_lo, ch_hi = min(use_channels), max(use_channels) + num_physical = ch_hi - ch_lo + 1 + min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing - Args: - batches: List of ChannelBatch — modified in place. - num_channels: Total number of channels on the instrument. - spacings: Per-channel minimum Y spacing list (length >= num_channels). - max_y: Maximum Y position reachable by channel 0 (mm). - min_y: Minimum Y position reachable by channel N-1 (mm). - """ + if container.get_absolute_size_y() < min_required: + return None - for batch_idx, batch in enumerate(batches): - positions = batch.y_positions - fixed = set(batch.channels) # only active channels are immovable, not phantoms - - # 1. Assign targets: idle channels get their next-needed Y position. - for ch in range(num_channels): - if ch in fixed: - continue - target = _find_next_y_target(ch, batch_idx + 1, batches) - if target is not None: - positions[ch] = target - - # 2. Fill gaps: channels with no current or future use stay where they were - # in the previous batch. For batch 0 (no previous), pack at min spacing - # from the nearest already-positioned neighbor. - prev_positions = batches[batch_idx - 1].y_positions if batch_idx > 0 else None - for ch in range(num_channels): - if ch in positions: - continue - if prev_positions is not None and ch in prev_positions: - positions[ch] = prev_positions[ch] - elif ch == 0: - # First batch, ch0 has no reference — pack above ch1 - spacing = _min_spacing_between(spacings, 0, 1) - positions[ch] = positions.get(1, max_y) + spacing - else: - spacing = _min_spacing_between(spacings, ch - 1, ch) - positions[ch] = positions[ch - 1] - spacing - - # 3. Forward sweep (ch 1 → N-1): enforce spacing, only move free channels. - for ch in range(1, num_channels): - if ch in fixed: - continue - spacing = _min_spacing_between(spacings, ch - 1, ch) - if positions[ch - 1] - positions[ch] < spacing - 1e-9: - positions[ch] = positions[ch - 1] - spacing - - # 4. Backward sweep (ch N-2 → 0): enforce spacing, only move free channels. - for ch in range(num_channels - 2, -1, -1): - if ch in fixed: - continue - spacing = _min_spacing_between(spacings, ch, ch + 1) - if positions[ch] - positions[ch + 1] < spacing - 1e-9: - positions[ch] = positions[ch + 1] + spacing - - # 5. Bounds clamp (free channels only). - for ch in range(num_channels): - if ch in fixed: - continue - if positions[ch] > max_y: - positions[ch] = max_y - if positions[ch] < min_y: - positions[ch] = min_y - - # Re-run forward sweep to propagate clamped bounds. - for ch in range(1, num_channels): - if ch in fixed: - continue - spacing = _min_spacing_between(spacings, ch - 1, ch) - if positions[ch - 1] - positions[ch] < spacing - 1e-9: - positions[ch] = positions[ch - 1] - spacing - - # Re-run backward sweep to propagate clamped bounds upward. - for ch in range(num_channels - 2, -1, -1): - if ch in fixed: - continue - spacing = _min_spacing_between(spacings, ch, ch + 1) - if positions[ch] - positions[ch + 1] < spacing - 1e-9: - positions[ch] = positions[ch + 1] + spacing + all_offsets = get_wide_single_resource_liquid_op_offsets( + resource=container, + num_channels=num_physical, + min_spacing=spacing, + ) + offsets = [all_offsets[ch - ch_lo] for ch in use_channels] + # Shift odd channel spans to avoid center divider walls, but only if the + # outermost channel (center + half-spacing) stays within the container. + if num_physical > 1 and num_physical % 2 != 0: + max_offset_y = max(o.y for o in offsets) + container_half_y = container.get_absolute_size_y() / 2 + if max_offset_y + _ODD_SPAN_CENTER_AVOIDANCE + spacing / 2 <= container_half_y: + offsets = [o + Coordinate(0, _ODD_SPAN_CENTER_AVOIDANCE, 0) for o in offsets] -# --- Input validation and position computation --- + return offsets def compute_single_container_offsets( @@ -292,12 +266,19 @@ def compute_single_container_offsets( ) -> Optional[List[Coordinate]]: """Compute spread Y offsets for multiple channels targeting the same container. - Returns None if the container is too small — caller should fall back to center - offsets and let plan_batches serialize. + Accounts for the full physical span including phantom intermediate channels. + When the full span doesn't fit, splits active channels into consecutive + sub-groups at gaps in the channel sequence and computes offsets per sub-group. + Each sub-group gets centered spread offsets, so plan_batches will naturally + batch sub-groups that can't coexist into separate Y batches. + + Returns None if even a single pair of adjacent active channels can't fit. """ if len(use_channels) == 0: return [] + if len(use_channels) == 1: + return [Coordinate.zero()] ch_lo, ch_hi = min(use_channels), max(use_channels) if isinstance(channel_spacings, (int, float)): @@ -305,32 +286,44 @@ def compute_single_container_offsets( else: spacing = _effective_spacing(channel_spacings, ch_lo, ch_hi) - num_physical = ch_hi - ch_lo + 1 - min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing - - if container.get_absolute_size_y() < min_required: + # Try the full span first (all channels including phantoms fit) + full = _offsets_for_consecutive_group(container, use_channels, spacing) + if full is not None: + return full + + # Full span doesn't fit. Split at gaps in the sorted channel sequence + # into consecutive sub-groups and compute offsets for each independently. + sorted_chs = sorted(use_channels) + groups: List[List[int]] = [[sorted_chs[0]]] + for i in range(1, len(sorted_chs)): + if sorted_chs[i] == sorted_chs[i - 1] + 1: + groups[-1].append(sorted_chs[i]) + else: + groups.append([sorted_chs[i]]) + + # If there's only one consecutive group and it didn't fit above, container is too small + if len(groups) == 1: return None - all_offsets = get_wide_single_resource_liquid_op_offsets( - resource=container, - num_channels=num_physical, - min_spacing=spacing, - ) - offsets = [all_offsets[ch - ch_lo] for ch in use_channels] - - # Shift odd channel spans +5.5mm to avoid container center dividers - if num_physical > 1 and num_physical % 2 != 0: - offsets = [o + Coordinate(0, 5.5, 0) for o in offsets] + # Compute offsets per sub-group + ch_to_offset: Dict[int, Coordinate] = {} + for group in groups: + group_offsets = _offsets_for_consecutive_group(container, group, spacing) + if group_offsets is None: + return None # even a sub-group doesn't fit + for ch, offset in zip(group, group_offsets): + ch_to_offset[ch] = offset - return offsets + # Return in the original use_channels order + return [ch_to_offset[ch] for ch in use_channels] -def validate_probing_inputs( +def validate_channel_selections( containers: List[Container], use_channels: Optional[List[int]], num_channels: int, ) -> List[int]: - """Validate and normalize channel selection for liquid height probing. + """Validate and normalize channel selection. If *use_channels* is ``None``, defaults to ``[0, 1, ..., len(containers)-1]``. @@ -361,18 +354,21 @@ def validate_probing_inputs( def compute_positions( containers: List[Container], resource_offsets: List[Coordinate], - deck: "Deck", # noqa: F821 + wrt_resource: Resource, ) -> Tuple[List[float], List[float]]: - """Convert containers and offsets into absolute X/Y machine coordinates. + """Convert containers and offsets into absolute X/Y positions relative to a resource. + + Each container must be a descendant of *wrt_resource* (checked by + ``Resource.get_location_wrt``, which raises ``ValueError`` if not). Returns: - (x_positions, y_positions) — parallel lists of absolute coordinates in mm, + (x_positions, y_positions) — parallel lists of coordinates in mm, one entry per container. """ x_pos: List[float] = [] y_pos: List[float] = [] for resource, offset in zip(containers, resource_offsets): - loc = resource.get_location_wrt(deck, x="c", y="c", z="b") + loc = resource.get_location_wrt(wrt_resource, x="c", y="c", z="b") x_pos.append(loc.x + offset.x) y_pos.append(loc.y + offset.y) return x_pos, y_pos @@ -383,74 +379,115 @@ def compute_positions( def plan_batches( use_channels: List[int], - x_pos: List[float], - y_pos: List[float], + targets: Union[List[Container], List[Coordinate]], channel_spacings: Union[float, List[float]], x_tolerance: float, - num_channels: Optional[int] = None, - max_y: Optional[float] = None, - min_y: Optional[float] = None, + wrt_resource: Optional[Resource] = None, + resource_offsets: Optional[List[Coordinate]] = None, ) -> List[ChannelBatch]: - """Partition channel–position pairs into executable batches. + """Partition channel–target pairs into executable batches. + + Targets can be either: + + - **Containers** (with *wrt_resource*): computes X/Y positions from containers relative + to a reference resource. When multiple channels target the same container and *resource_offsets* + is not provided, automatically spreads them using ``compute_single_container_offsets``. + Channels that cannot be spread (container too narrow) stay at the container center + and are serialized into separate batches. + + - **Coordinates**: uses absolute X/Y positions directly. No auto-spreading. Groups by X position (within *x_tolerance*), then within each X group partitions into Y sub-batches respecting per-channel minimum spacing. Computes phantom channel positions for intermediate channels between non-consecutive batch members. - When *num_channels*, *max_y*, and *min_y* are all provided, idle channels are - pre-positioned toward their next-needed Y coordinate to minimize travel between - batch transitions. - Args: use_channels: Channel indices being used (e.g. [0, 1, 2, 5, 6, 7]). - x_pos: Absolute X position for each entry in *use_channels*. - y_pos: Absolute Y position for each entry in *use_channels*. + targets: Either Container objects (requires *deck*) or Coordinate objects with + absolute X/Y positions. One per entry in *use_channels*. channel_spacings: Minimum Y spacing per channel (mm). Scalar for uniform, or a list with one entry per channel on the instrument. x_tolerance: Positions within this tolerance share an X group. - num_channels: Total number of channels on the instrument. Required for - transition optimization. - max_y: Maximum Y position reachable by channel 0 (mm). Required for - transition optimization. - min_y: Minimum Y position reachable by channel N-1 (mm). Required for - transition optimization. + wrt_resource: Reference resource for computing positions. All containers must + be descendants of this resource. Required when *targets* are Containers. + resource_offsets: Optional XYZ offsets from container centers. When provided, + auto-spreading is disabled and these offsets are used directly. Only valid + when *targets* are Containers. Returns: Flat list of ChannelBatch sorted by ascending X position. """ - if not (len(use_channels) == len(x_pos) == len(y_pos)): + if len(use_channels) != len(targets): raise ValueError( - f"use_channels, x_pos, and y_pos must have the same length, " - f"got {len(use_channels)}, {len(x_pos)}, {len(y_pos)}." + f"use_channels and targets must have the same length, " + f"got {len(use_channels)} and {len(targets)}." ) if len(use_channels) == 0: raise ValueError("use_channels must not be empty.") + if wrt_resource is not None: + containers = cast(List[Container], targets) + if resource_offsets is not None: + if len(resource_offsets) != len(containers): + raise ValueError( + f"resource_offsets length must match containers, " + f"got {len(resource_offsets)} and {len(containers)}." + ) + offsets = resource_offsets + else: + offsets = [Coordinate.zero()] * len(containers) + x_pos, y_pos = compute_positions(containers, offsets, wrt_resource) + else: + containers = None + coordinates = cast(List[Coordinate], targets) + x_pos = [c.x for c in coordinates] + y_pos = [c.y for c in coordinates] + # Normalize scalar spacing to per-channel list. - # Size must cover all channels up to num_channels (if provided) for transition optimization. max_ch = max(use_channels) - min_len = max(max_ch + 1, num_channels or 0) if isinstance(channel_spacings, (int, float)): - spacings: List[float] = [float(channel_spacings)] * min_len + spacings: List[float] = [float(channel_spacings)] * (max_ch + 1) else: spacings = list(channel_spacings) - if len(spacings) < min_len: - spacings.extend([spacings[-1]] * (min_len - len(spacings))) + if len(spacings) < max_ch + 1: + spacings.extend([spacings[-1]] * (max_ch + 1 - len(spacings))) - # Group indices by X position (preserving first-appearance order). - # Uses floor-based bucketing to avoid Python's banker's rounding at boundaries. + # Group indices by X position. Sorts by X then merges adjacent positions + # within tolerance into the same group, so positions like 99.99 and 100.01 + # (0.02mm apart) are never split across groups. + sorted_by_x = sorted(range(len(x_pos)), key=lambda i: x_pos[i]) x_groups: Dict[float, List[int]] = {} - for i, x in enumerate(x_pos): - x_bucket = math.floor(x / x_tolerance) * x_tolerance - x_groups.setdefault(x_bucket, []).append(i) + current_key: Optional[float] = None + for i in sorted_by_x: + if current_key is None or abs(x_pos[i] - current_key) > x_tolerance: + current_key = x_pos[i] + x_groups.setdefault(current_key, []).append(i) + + # When multiple channels target the same container, offset their Y positions + # so they can be batched together + adjusted_y = list(y_pos) + if containers is not None and resource_offsets is None: + for indices in x_groups.values(): + container_groups: Dict[int, List[int]] = defaultdict(list) + for idx in indices: + container_groups[id(containers[idx])].append(idx) + for c_indices in container_groups.values(): + if len(c_indices) < 2: + continue + group_channels = [use_channels[i] for i in c_indices] + spread = compute_single_container_offsets( + container=containers[c_indices[0]], + use_channels=group_channels, + channel_spacings=channel_spacings, + ) + if spread is not None: + for i, idx_val in enumerate(c_indices): + adjusted_y[idx_val] += spread[i].y result: List[ChannelBatch] = [] for _, indices in sorted(x_groups.items()): group_x = x_pos[indices[0]] - result.extend(_partition_into_y_batches(indices, use_channels, y_pos, spacings, group_x)) - - if num_channels is not None and max_y is not None and min_y is not None: - _optimize_batch_transitions(result, num_channels, spacings, max_y, min_y) + result.extend(_partition_into_y_batches(indices, use_channels, adjusted_y, spacings, group_x)) return result diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 743634a0884..4826c090d0e 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -1,18 +1,23 @@ """Tests for pipette_batch_scheduling module.""" import unittest +from typing import List from unittest.mock import MagicMock, patch from pylabrobot.liquid_handling.pipette_batch_scheduling import ( - ChannelBatch, _effective_spacing, - _find_next_y_target, - _optimize_batch_transitions, _min_spacing_between, compute_single_container_offsets, plan_batches, ) +from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + + +def _coords(x_pos: List[float], y_pos: List[float]) -> List[Coordinate]: + """Build Coordinate targets from parallel x/y lists.""" + return [Coordinate(x, y, 0) for x, y in zip(x_pos, y_pos)] class TestEffectiveSpacing(unittest.TestCase): @@ -36,70 +41,80 @@ class TestPlanBatchesUniformSpacing(unittest.TestCase): # --- X grouping --- def test_single_x_group(self): - batches = plan_batches([0, 1, 2], [100.0] * 3, [270.0, 261.0, 252.0], self.S, x_tolerance=0.1) + batches = plan_batches( + [0, 1, 2], _coords([100.0] * 3, [270.0, 261.0, 252.0]), self.S, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) self.assertAlmostEqual(batches[0].x_position, 100.0) + self.assertEqual(sorted(batches[0].channels), [0, 1, 2]) def test_two_x_groups(self): batches = plan_batches( - [0, 1, 2, 3], [100.0, 100.0, 200.0, 200.0], [270.0, 261.0, 270.0, 261.0], self.S, x_tolerance=0.1, + [0, 1, 2, 3], + _coords([100.0, 100.0, 200.0, 200.0], [270.0, 261.0, 270.0, 261.0]), + self.S, + x_tolerance=0.1, ) x_positions = [b.x_position for b in batches] self.assertAlmostEqual(x_positions[0], 100.0) self.assertAlmostEqual(x_positions[-1], 200.0) def test_x_groups_sorted_by_ascending_x(self): - batches = plan_batches([0, 1, 2], [300.0, 100.0, 200.0], [270.0] * 3, self.S, x_tolerance=0.1) + batches = plan_batches( + [0, 1, 2], _coords([300.0, 100.0, 200.0], [270.0] * 3), self.S, x_tolerance=0.1 + ) x_positions = [b.x_position for b in batches] self.assertAlmostEqual(x_positions[0], 100.0) self.assertAlmostEqual(x_positions[1], 200.0) self.assertAlmostEqual(x_positions[2], 300.0) def test_x_positions_within_tolerance_grouped(self): - batches = plan_batches([0, 1], [100.0, 100.05], [270.0, 261.0], self.S, x_tolerance=0.1) + batches = plan_batches( + [0, 1], _coords([100.0, 100.05], [270.0, 261.0]), self.S, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) def test_x_positions_outside_tolerance_split(self): - batches = plan_batches([0, 1], [100.0, 100.2], [270.0, 270.0], self.S, x_tolerance=0.1) + batches = plan_batches([0, 1], _coords([100.0, 100.2], [270.0, 270.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) # --- Y batching --- - def test_consecutive_channels_single_batch(self): - batches = plan_batches([0, 1, 2], [100.0] * 3, [270.0, 261.0, 252.0], self.S, x_tolerance=0.1) - self.assertEqual(len(batches), 1) - self.assertEqual(sorted(batches[0].channels), [0, 1, 2]) - def test_same_y_forces_serialization(self): - batches = plan_batches([0, 1, 2], [100.0] * 3, [200.0] * 3, self.S, x_tolerance=0.1) + batches = plan_batches([0, 1, 2], _coords([100.0] * 3, [200.0] * 3), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 3) def test_barely_fitting_spacing(self): - batches = plan_batches([0, 1], [100.0] * 2, [209.0, 200.0], self.S, x_tolerance=0.1) + batches = plan_batches([0, 1], _coords([100.0] * 2, [209.0, 200.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) def test_barely_insufficient_spacing(self): - batches = plan_batches([0, 1], [100.0] * 2, [208.9, 200.0], self.S, x_tolerance=0.1) + batches = plan_batches([0, 1], _coords([100.0] * 2, [208.9, 200.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) def test_reversed_y_order_splits(self): - batches = plan_batches([0, 1], [100.0] * 2, [200.0, 220.0], self.S, x_tolerance=0.1) + batches = plan_batches([0, 1], _coords([100.0] * 2, [200.0, 220.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) # --- Non-consecutive channels --- - def test_non_consecutive_channels_fit(self): + def test_non_consecutive_channels_with_phantoms(self): batches = plan_batches( [0, 1, 2, 5, 6, 7], - [100.0] * 6, - [300.0, 291.0, 282.0, 255.0, 246.0, 237.0], - self.S, x_tolerance=0.1, + _coords([100.0] * 6, [300.0, 291.0, 282.0, 255.0, 246.0, 237.0]), + self.S, + x_tolerance=0.1, ) self.assertEqual(len(batches), 1) self.assertEqual(sorted(batches[0].channels), [0, 1, 2, 5, 6, 7]) + y = batches[0].y_positions + self.assertIn(3, y) + self.assertIn(4, y) + self.assertAlmostEqual(y[3], 282.0 - 9.0) + self.assertAlmostEqual(y[4], 282.0 - 18.0) def test_phantom_channels_interpolated(self): - batches = plan_batches([0, 3], [100.0] * 2, [300.0, 273.0], self.S, x_tolerance=0.1) + batches = plan_batches([0, 3], _coords([100.0] * 2, [300.0, 273.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) y = batches[0].y_positions self.assertAlmostEqual(y[0], 300.0) @@ -108,7 +123,7 @@ def test_phantom_channels_interpolated(self): self.assertAlmostEqual(y[3], 273.0) def test_phantoms_only_within_batch(self): - batches = plan_batches([0, 3], [100.0] * 2, [200.0, 250.0], self.S, x_tolerance=0.1) + batches = plan_batches([0, 3], _coords([100.0] * 2, [200.0, 250.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) for batch in batches: self.assertEqual(len(batch.y_positions), 1) @@ -118,9 +133,9 @@ def test_phantoms_only_within_batch(self): def test_mixed_complexity(self): batches = plan_batches( [0, 1, 2, 3], - [100.0, 100.0, 200.0, 200.0], - [200.0, 200.0, 270.0, 261.0], - self.S, x_tolerance=0.1, + _coords([100.0, 100.0, 200.0, 200.0], [200.0, 200.0, 270.0, 261.0]), + self.S, + x_tolerance=0.1, ) x100 = [b for b in batches if abs(b.x_position - 100.0) < 0.01] x200 = [b for b in batches if abs(b.x_position - 200.0) < 0.01] @@ -131,17 +146,19 @@ def test_mixed_complexity(self): def test_mismatched_lengths(self): with self.assertRaises(ValueError): - plan_batches([0, 1], [100.0], [200.0, 200.0], self.S, x_tolerance=0.1) + plan_batches([0, 1], _coords([100.0], [200.0]), self.S, x_tolerance=0.1) def test_empty(self): with self.assertRaises(ValueError): - plan_batches([], [], [], self.S, x_tolerance=0.1) + plan_batches([], [], self.S, x_tolerance=0.1) # --- Index correctness --- def test_indices_map_back_correctly(self): use_channels = [3, 7, 0] - batches = plan_batches(use_channels, [100.0] * 3, [261.0, 237.0, 270.0], self.S, x_tolerance=0.1) + batches = plan_batches( + use_channels, _coords([100.0] * 3, [261.0, 237.0, 270.0]), self.S, x_tolerance=0.1 + ) all_indices = [idx for b in batches for idx in b.indices] self.assertEqual(sorted(all_indices), [0, 1, 2]) for batch in batches: @@ -151,27 +168,20 @@ def test_indices_map_back_correctly(self): # --- Realistic --- def test_8_channels_trough(self): - batches = plan_batches(list(range(8)), [100.0] * 8, [300.0 - i * 9.0 for i in range(8)], self.S, x_tolerance=0.1) + batches = plan_batches( + list(range(8)), + _coords([100.0] * 8, [300.0 - i * 9.0 for i in range(8)]), + self.S, + x_tolerance=0.1, + ) self.assertEqual(len(batches), 1) self.assertEqual(len(batches[0].channels), 8) def test_8_channels_narrow_well(self): - batches = plan_batches(list(range(8)), [100.0] * 8, [200.0] * 8, self.S, x_tolerance=0.1) - self.assertEqual(len(batches), 8) - - def test_channels_0_1_2_5_6_7_phantoms(self): batches = plan_batches( - [0, 1, 2, 5, 6, 7], - [100.0] * 6, - [300.0, 291.0, 282.0, 255.0, 246.0, 237.0], - self.S, x_tolerance=0.1, + list(range(8)), _coords([100.0] * 8, [200.0] * 8), self.S, x_tolerance=0.1 ) - self.assertEqual(len(batches), 1) - y = batches[0].y_positions - self.assertIn(3, y) - self.assertIn(4, y) - self.assertAlmostEqual(y[3], 282.0 - 9.0) - self.assertAlmostEqual(y[4], 282.0 - 18.0) + self.assertEqual(len(batches), 8) class TestPlanBatchesMixedSpacing(unittest.TestCase): @@ -181,67 +191,145 @@ class TestPlanBatchesMixedSpacing(unittest.TestCase): SPACINGS = [8.98, 8.98, 17.96, 17.96] def test_two_1ml_channels_fit_at_9mm(self): - batches = plan_batches([0, 1], [100.0] * 2, [208.98, 200.0], self.SPACINGS, x_tolerance=0.1) + # ceil(8.98 * 10) / 10 = 9.0mm effective spacing + batches = plan_batches( + [0, 1], _coords([100.0] * 2, [209.0, 200.0]), self.SPACINGS, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) def test_1ml_and_5ml_need_wider_spacing(self): - batches = plan_batches([1, 2], [100.0] * 2, [209.0, 200.0], self.SPACINGS, x_tolerance=0.1) + # ceil(17.96 * 10) / 10 = 18.0mm effective spacing between ch1 and ch2 + batches = plan_batches( + [1, 2], _coords([100.0] * 2, [217.9, 200.0]), self.SPACINGS, x_tolerance=0.1 + ) self.assertEqual(len(batches), 2) def test_1ml_and_5ml_fit_at_wide_spacing(self): - batches = plan_batches([1, 2], [100.0] * 2, [217.96, 200.0], self.SPACINGS, x_tolerance=0.1) - self.assertEqual(len(batches), 1) - - def test_5ml_channels_fit_at_wide_spacing(self): - batches = plan_batches([2, 3], [100.0] * 2, [217.96, 200.0], self.SPACINGS, x_tolerance=0.1) + batches = plan_batches( + [1, 2], _coords([100.0] * 2, [218.0, 200.0]), self.SPACINGS, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) def test_5ml_channels_too_close(self): - batches = plan_batches([2, 3], [100.0] * 2, [209.0, 200.0], self.SPACINGS, x_tolerance=0.1) + batches = plan_batches( + [2, 3], _coords([100.0] * 2, [217.9, 200.0]), self.SPACINGS, x_tolerance=0.1 + ) self.assertEqual(len(batches), 2) - def test_span_across_1ml_and_5ml(self): - # Pairwise sum: max(8.98,8.98) + max(8.98,17.96) + max(17.96,17.96) = 44.9 - batches = plan_batches([0, 3], [100.0] * 2, [244.9, 200.0], self.SPACINGS, x_tolerance=0.1) + def test_span_across_1ml_and_5ml_boundary(self): + # Rounded pairwise sum: 9.0 + 18.0 + 18.0 = 45.0mm + batches = plan_batches( + [0, 3], _coords([100.0] * 2, [245.0, 200.0]), self.SPACINGS, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) - batches = plan_batches([0, 3], [100.0] * 2, [244.0, 200.0], self.SPACINGS, x_tolerance=0.1) + # Also check phantom positions + y = batches[0].y_positions + self.assertAlmostEqual(y[1], 245.0 - 9.0) + self.assertAlmostEqual(y[2], 245.0 - 9.0 - 18.0) + # 0.1mm less doesn't fit + batches = plan_batches( + [0, 3], _coords([100.0] * 2, [244.9, 200.0]), self.SPACINGS, x_tolerance=0.1 + ) self.assertEqual(len(batches), 2) - def test_phantom_channels_use_pairwise_spacing(self): - # ch0→ch1: max(8.98, 8.98) = 8.98, ch1→ch2: max(8.98, 17.96) = 17.96 - batches = plan_batches([0, 3], [100.0] * 2, [244.9, 200.0], self.SPACINGS, x_tolerance=0.1) + def test_pairwise_sum_avoids_unnecessary_split(self): + # Rounded pairwise sum ch0→ch3: 9.0 + 18.0 + 18.0 = 45.0mm, NOT 3 * 18.0 = 54.0mm. + # 50mm gap fits with pairwise even though < 54.0 + batches = plan_batches( + [0, 3], _coords([100.0] * 2, [250.0, 200.0]), self.SPACINGS, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) - y = batches[0].y_positions - self.assertAlmostEqual(y[1], 244.9 - 8.98) - self.assertAlmostEqual(y[2], 244.9 - 8.98 - 17.96) def test_mixed_all_four_channels_spaced_wide(self): - s = 17.96 batches = plan_batches( [0, 1, 2, 3], - [100.0] * 4, - [300.0, 300.0 - s, 300.0 - 2 * s, 300.0 - 3 * s], - self.SPACINGS, x_tolerance=0.1, + _coords([100.0] * 4, [300.0, 291.0, 273.0, 255.0]), + self.SPACINGS, + x_tolerance=0.1, ) self.assertEqual(len(batches), 1) - def test_pairwise_sum_avoids_unnecessary_split(self): - # With spacings [8.98, 8.98, 17.96, 17.96], spanning ch0→ch3 requires - # 8.98 + 17.96 + 17.96 = 44.9mm (pairwise sum), NOT 3 * 17.96 = 53.88mm. - # A gap of 50mm should fit in one batch (pairwise) even though it's less than 53.88. - batches = plan_batches([0, 3], [100.0] * 2, [250.0, 200.0], self.SPACINGS, x_tolerance=0.1) - self.assertEqual(len(batches), 1) - def test_mixed_channels_at_1ml_spacing_forces_serialization(self): batches = plan_batches( [0, 1, 2, 3], - [100.0] * 4, - [300.0, 291.0, 282.0, 273.0], - self.SPACINGS, x_tolerance=0.1, + _coords([100.0] * 4, [300.0, 291.0, 282.0, 273.0]), + self.SPACINGS, + x_tolerance=0.1, ) self.assertGreater(len(batches), 1) +class TestPlanBatchesWithContainers(unittest.TestCase): + """Tests for the Container path with auto-spreading.""" + + S = 9.0 + + def _mock_container(self, cx: float, cy: float, size_y: float = 10.0, name: str = "well"): + c = MagicMock(spec=Container) + c.get_absolute_size_y.return_value = size_y + c.name = name + c.get_location_wrt = MagicMock(return_value=Coordinate(cx, cy, 0)) + return c + + def _mock_deck(self): + return MagicMock(spec=Resource) + + def test_single_container_no_spreading(self): + """One channel per container — no spreading needed.""" + c1 = self._mock_container(100.0, 270.0) + c2 = self._mock_container(100.0, 261.0) + deck = self._mock_deck() + batches = plan_batches([0, 1], [c1, c2], self.S, x_tolerance=0.1, wrt_resource=deck) + self.assertEqual(len(batches), 1) + self.assertEqual(sorted(batches[0].channels), [0, 1]) + + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" + ) + def test_same_container_auto_spreads(self, mock_offsets): + """Two channels targeting the same wide container get spread offsets.""" + mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] + trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") + deck = self._mock_deck() + batches = plan_batches([0, 1], [trough, trough], self.S, x_tolerance=0.1, wrt_resource=deck) + # With spreading, ch0 at 204.5 and ch1 at 195.5 — 9mm apart, fits in one batch + self.assertEqual(len(batches), 1) + y = batches[0].y_positions + self.assertAlmostEqual(y[0], 200.0 + 4.5) + self.assertAlmostEqual(y[1], 200.0 - 4.5) + + def test_same_narrow_container_serialized(self): + """Two channels targeting the same narrow container can't spread — serialized.""" + well = self._mock_container(100.0, 200.0, size_y=5.0, name="narrow_well") + deck = self._mock_deck() + batches = plan_batches([0, 1], [well, well], self.S, x_tolerance=0.1, wrt_resource=deck) + # Can't spread in 5mm container, both at y=200 — must serialize + self.assertEqual(len(batches), 2) + + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" + ) + def test_resource_offsets_skips_auto_spreading(self, mock_offsets): + """User-provided offsets disable auto-spreading.""" + trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") + deck = self._mock_deck() + user_offsets = [Coordinate(0, 10.0, 0), Coordinate(0, -10.0, 0)] + batches = plan_batches( + [0, 1], + [trough, trough], + self.S, + x_tolerance=0.1, + wrt_resource=deck, + resource_offsets=user_offsets, + ) + # Should use user offsets (210, 190) not auto-spread + mock_offsets.assert_not_called() + self.assertEqual(len(batches), 1) + y = batches[0].y_positions + self.assertAlmostEqual(y[0], 210.0) + self.assertAlmostEqual(y[1], 190.0) + + class TestComputeSingleContainerOffsets(unittest.TestCase): S = 9.0 @@ -259,11 +347,7 @@ def test_even_span_no_center_offset(self, mock_offsets): self.assertAlmostEqual(result[0].y, 4.5) self.assertAlmostEqual(result[1].y, -4.5) - @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" - ) - def test_single_channel_no_center_offset(self, mock_offsets): - mock_offsets.return_value = [Coordinate(0, 0.0, 0)] + def test_single_channel_returns_zero(self): result = compute_single_container_offsets(self._mock_container(50.0), [0], self.S) self.assertAlmostEqual(result[0].y, 0.0) @@ -335,157 +419,5 @@ def test_mixed_spacing(self): self.assertAlmostEqual(_min_spacing_between(spacings, 2, 3), 18.0) -class TestFindNextYTarget(unittest.TestCase): - def _batch(self, y_positions): - return ChannelBatch(x_position=100.0, indices=[], channels=[], y_positions=y_positions) - - def test_found_in_immediate_next_batch(self): - batches = [self._batch({0: 400}), self._batch({0: 300, 1: 291})] - self.assertAlmostEqual(_find_next_y_target(0, 1, batches), 300.0) - - def test_found_in_later_batch(self): - batches = [ - self._batch({0: 400}), - self._batch({2: 300}), - self._batch({0: 200}), - ] - # start_batch=1, channel 0 not in batch[1], found in batch[2] - self.assertAlmostEqual(_find_next_y_target(0, 1, batches), 200.0) - - def test_not_found_returns_none(self): - batches = [self._batch({0: 400}), self._batch({1: 300})] - self.assertIsNone(_find_next_y_target(2, 0, batches)) - - def test_phantom_position_used_as_target(self): - # Channel 1 is a phantom in batch 1 (between active 0 and 2) - batches = [self._batch({0: 400}), self._batch({0: 300, 1: 291, 2: 282})] - self.assertAlmostEqual(_find_next_y_target(1, 1, batches), 291.0) - - -class TestForwardPlan(unittest.TestCase): - S = [9.0] * 8 - N = 8 - MAX_Y = 650.0 - MIN_Y = 6.0 - - def _batch(self, y_positions): - return ChannelBatch(x_position=100.0, indices=[], channels=[], y_positions=dict(y_positions)) - - def _optimize_batch_transitions( - self, batches, spacings=None, num_channels=None, max_y=None, min_y=None - ): - _optimize_batch_transitions( - batches, - num_channels or self.N, - spacings or self.S, - max_y=max_y if max_y is not None else self.MAX_Y, - min_y=min_y if min_y is not None else self.MIN_Y, - ) - - def _check_spacing(self, positions, spacings, num_channels): - """Assert all adjacent channels satisfy minimum spacing.""" - for ch in range(num_channels - 1): - spacing = _min_spacing_between(spacings, ch, ch + 1) - diff = positions[ch] - positions[ch + 1] - self.assertGreaterEqual( - diff + 1e-9, spacing, f"channels {ch}-{ch + 1}: diff={diff:.2f} < spacing={spacing:.2f}" - ) - - def test_single_batch_fills_all_channels(self): - batches = [self._batch({0: 400, 1: 391})] - self._optimize_batch_transitions(batches) - self.assertEqual(set(batches[0].y_positions.keys()), set(range(self.N))) - - def test_idle_channels_move_toward_future_batch(self): - batches = [ - self._batch({0: 400, 1: 391}), - self._batch({6: 200, 7: 191}), - ] - self._optimize_batch_transitions(batches) - # Channels 6, 7 should be at or near their batch-1 targets - self.assertAlmostEqual(batches[0].y_positions[6], 200.0) - self.assertAlmostEqual(batches[0].y_positions[7], 191.0) - - def test_fixed_channels_not_modified(self): - batches = [ - self._batch({0: 400, 1: 391}), - self._batch({6: 200, 7: 191}), - ] - self._optimize_batch_transitions(batches) - self.assertAlmostEqual(batches[0].y_positions[0], 400.0) - self.assertAlmostEqual(batches[0].y_positions[1], 391.0) - self.assertAlmostEqual(batches[1].y_positions[6], 200.0) - self.assertAlmostEqual(batches[1].y_positions[7], 191.0) - - def test_spacing_constraints_satisfied(self): - batches = [ - self._batch({0: 400, 1: 391}), - self._batch({6: 200, 7: 191}), - ] - self._optimize_batch_transitions(batches) - for batch in batches: - self._check_spacing(batch.y_positions, self.S, self.N) - - def test_bounds_respected(self): - batches = [self._batch({3: 300})] - self._optimize_batch_transitions(batches) - self.assertLessEqual(batches[0].y_positions[0], self.MAX_Y) - self.assertGreaterEqual(batches[0].y_positions[self.N - 1], self.MIN_Y) - - def test_custom_bounds(self): - batches = [self._batch({3: 300})] - self._optimize_batch_transitions(batches, max_y=500.0, min_y=50.0) - self.assertLessEqual(batches[0].y_positions[0], 500.0) - self.assertGreaterEqual(batches[0].y_positions[self.N - 1], 50.0) - - def test_no_future_use_channels_packed_tightly(self): - # Only one batch, channels 0,1 active. Channels 2-7 have no future use. - batches = [self._batch({0: 400, 1: 391})] - self._optimize_batch_transitions(batches) - # Channels 2-7 should be packed at minimum spacing below channel 1 - for ch in range(2, self.N): - spacing = _min_spacing_between(self.S, ch - 1, ch) - expected = batches[0].y_positions[ch - 1] - spacing - self.assertAlmostEqual( - batches[0].y_positions[ch], expected, places=5, msg=f"channel {ch} not tightly packed" - ) - - def test_mixed_spacing(self): - spacings = [8.98, 8.98, 17.96, 17.96, 9.0, 9.0, 9.0, 9.0] - batches = [self._batch({0: 500, 1: 491})] - self._optimize_batch_transitions(batches, spacings=spacings) - self.assertEqual(set(batches[0].y_positions.keys()), set(range(self.N))) - self._check_spacing(batches[0].y_positions, spacings, self.N) - - def test_three_batches_progressive_prepositioning(self): - batches = [ - self._batch({0: 500, 1: 491}), - self._batch({4: 350, 5: 341}), - self._batch({6: 200, 7: 191}), - ] - self._optimize_batch_transitions(batches) - # Batch 0: channels 4,5 should target their batch-1 positions - self.assertAlmostEqual(batches[0].y_positions[4], 350.0) - self.assertAlmostEqual(batches[0].y_positions[5], 341.0) - # Batch 0: channels 6,7 should target their batch-2 positions - self.assertAlmostEqual(batches[0].y_positions[6], 200.0) - self.assertAlmostEqual(batches[0].y_positions[7], 191.0) - # All batches satisfy spacing - for batch in batches: - self._check_spacing(batch.y_positions, self.S, self.N) - - def test_target_constrained_by_fixed_channels(self): - # Channel 2 wants to be at 390 (future target), but channel 1 is fixed at 391. - # Spacing constraint forces channel 2 down to 391 - 9 = 382. - batches = [ - self._batch({1: 391}), - self._batch({2: 390}), - ] - self._optimize_batch_transitions(batches) - self.assertAlmostEqual(batches[0].y_positions[1], 391.0) - self.assertLessEqual(batches[0].y_positions[2], 391.0 - 9.0 + 1e-9) - self._check_spacing(batches[0].y_positions, self.S, self.N) - - if __name__ == "__main__": unittest.main() From f6e8690f9d71811016adaf7ffde82efa6847154c Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sun, 22 Mar 2026 22:05:32 +0000 Subject: [PATCH 08/39] fix linting --- .../backends/hamilton/STAR_tests.py | 13 +- .../pipette_batch_scheduling_tests.py | 335 +++++------------- 2 files changed, 101 insertions(+), 247 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 74c111666e8..1e81f76cb90 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -43,7 +43,6 @@ from .STAR_chatterbox import ( _DEFAULT_EXTENDED_CONFIGURATION, _DEFAULT_MACHINE_CONFIGURATION, - STARChatterboxBackend, ) @@ -1635,7 +1634,8 @@ async def test_single_well_returns_height(self): mocks = self._standard_mocks() with contextlib.ExitStack() as stack: - entered = {k: stack.enter_context(v) for k, v in mocks.items()} + for v in mocks.values(): + stack.enter_context(v) result = await self.STAR.probe_liquid_heights(containers=[well], use_channels=[0]) # request_pip_height_last_lld returns list(range(12)), so channel 0 gets height 0. @@ -1650,7 +1650,8 @@ async def test_n_replicates(self): mock_detect = unittest.mock.AsyncMock(return_value=None) mocks = self._standard_mocks(detect_side_effect=mock_detect) with contextlib.ExitStack() as stack: - entered = {k: stack.enter_context(v) for k, v in mocks.items()} + for v in mocks.values(): + stack.enter_context(v) await self.STAR.probe_liquid_heights(containers=[well], use_channels=[0], n_replicates=3) self.assertEqual(mock_detect.await_count, 3) @@ -1678,7 +1679,8 @@ async def raise_error(**kwargs): detect_side_effect=unittest.mock.AsyncMock(side_effect=raise_error) ) with contextlib.ExitStack() as stack: - entered = {k: stack.enter_context(v) for k, v in mocks.items()} + for v in mocks.values(): + stack.enter_context(v) result = await self.STAR.probe_liquid_heights(containers=[well], use_channels=[0]) self.assertEqual(result[0], 0.0) @@ -1712,7 +1714,8 @@ async def side_effect(**kwargs): detect_side_effect=unittest.mock.AsyncMock(side_effect=side_effect) ) with contextlib.ExitStack() as stack: - entered = {k: stack.enter_context(v) for k, v in mocks.items()} + for v in mocks.values(): + stack.enter_context(v) with self.assertRaises(RuntimeError): await self.STAR.probe_liquid_heights(containers=[well], use_channels=[0], n_replicates=2) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 4826c090d0e..e1770727ab4 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -1,11 +1,15 @@ -"""Tests for pipette_batch_scheduling module.""" +"""Tests for pipette_batch_scheduling module. + +Tests cover functionality added or changed by this module vs the previous +planning.py: mixed channel spacing, phantom interpolation, container auto-spreading, +Coordinate targets, and compute_single_container_offsets. +""" import unittest from typing import List from unittest.mock import MagicMock, patch from pylabrobot.liquid_handling.pipette_batch_scheduling import ( - _effective_spacing, _min_spacing_between, compute_single_container_offsets, plan_batches, @@ -20,144 +24,133 @@ def _coords(x_pos: List[float], y_pos: List[float]) -> List[Coordinate]: return [Coordinate(x, y, 0) for x, y in zip(x_pos, y_pos)] -class TestEffectiveSpacing(unittest.TestCase): - def test_uniform(self): - self.assertAlmostEqual(_effective_spacing([9.0, 9.0, 9.0, 9.0], 0, 3), 9.0) - - def test_mixed_takes_max(self): - spacings = [9.0, 9.0, 18.0, 18.0] - self.assertAlmostEqual(_effective_spacing(spacings, 0, 3), 18.0) - self.assertAlmostEqual(_effective_spacing(spacings, 0, 1), 9.0) - self.assertAlmostEqual(_effective_spacing(spacings, 1, 2), 18.0) - - def test_single_channel(self): - self.assertAlmostEqual(_effective_spacing([9.0, 18.0], 0, 0), 9.0) - self.assertAlmostEqual(_effective_spacing([9.0, 18.0], 1, 1), 18.0) +class TestMixedChannelSpacing(unittest.TestCase): + """Pairwise spacing with non-uniform channel sizes (e.g. 1mL + 5mL).""" + SPACINGS = [8.98, 8.98, 17.96, 17.96] -class TestPlanBatchesUniformSpacing(unittest.TestCase): - S = 9.0 - - # --- X grouping --- + def test_pairwise_rounding(self): + # max(8.98, 17.96) = 17.96 -> ceil(179.6)/10 = 18.0 + self.assertAlmostEqual(_min_spacing_between(self.SPACINGS, 1, 2), 18.0) + # max(8.98, 8.98) = 8.98 -> ceil(89.8)/10 = 9.0 + self.assertAlmostEqual(_min_spacing_between(self.SPACINGS, 0, 1), 9.0) - def test_single_x_group(self): + def test_mixed_spacing_boundary(self): + # 18.0mm needed between ch1 (1mL) and ch2 (5mL) batches = plan_batches( - [0, 1, 2], _coords([100.0] * 3, [270.0, 261.0, 252.0]), self.S, x_tolerance=0.1 + [1, 2], _coords([100.0] * 2, [217.9, 200.0]), self.SPACINGS, x_tolerance=0.1 ) - self.assertEqual(len(batches), 1) - self.assertAlmostEqual(batches[0].x_position, 100.0) - self.assertEqual(sorted(batches[0].channels), [0, 1, 2]) - - def test_two_x_groups(self): + self.assertEqual(len(batches), 2) batches = plan_batches( - [0, 1, 2, 3], - _coords([100.0, 100.0, 200.0, 200.0], [270.0, 261.0, 270.0, 261.0]), - self.S, - x_tolerance=0.1, + [1, 2], _coords([100.0] * 2, [218.0, 200.0]), self.SPACINGS, x_tolerance=0.1 ) - x_positions = [b.x_position for b in batches] - self.assertAlmostEqual(x_positions[0], 100.0) - self.assertAlmostEqual(x_positions[-1], 200.0) + self.assertEqual(len(batches), 1) - def test_x_groups_sorted_by_ascending_x(self): + def test_pairwise_sum_not_uniform_product(self): + # ch0->ch3: 9.0 + 18.0 + 18.0 = 45.0mm pairwise, NOT 3 * 18.0 = 54.0mm uniform batches = plan_batches( - [0, 1, 2], _coords([300.0, 100.0, 200.0], [270.0] * 3), self.S, x_tolerance=0.1 + [0, 3], _coords([100.0] * 2, [250.0, 200.0]), self.SPACINGS, x_tolerance=0.1 ) - x_positions = [b.x_position for b in batches] - self.assertAlmostEqual(x_positions[0], 100.0) - self.assertAlmostEqual(x_positions[1], 200.0) - self.assertAlmostEqual(x_positions[2], 300.0) + self.assertEqual(len(batches), 1) - def test_x_positions_within_tolerance_grouped(self): + def test_mixed_phantoms_use_pairwise_spacing(self): batches = plan_batches( - [0, 1], _coords([100.0, 100.05], [270.0, 261.0]), self.S, x_tolerance=0.1 + [0, 3], _coords([100.0] * 2, [245.0, 200.0]), self.SPACINGS, x_tolerance=0.1 ) self.assertEqual(len(batches), 1) + y = batches[0].y_positions + # ch0->ch1: 9.0mm, ch1->ch2: 18.0mm + self.assertAlmostEqual(y[1], 245.0 - 9.0) + self.assertAlmostEqual(y[2], 245.0 - 9.0 - 18.0) - def test_x_positions_outside_tolerance_split(self): - batches = plan_batches([0, 1], _coords([100.0, 100.2], [270.0, 270.0]), self.S, x_tolerance=0.1) - self.assertEqual(len(batches), 2) - # --- Y batching --- +class TestCoreBatching(unittest.TestCase): + """Fundamental X grouping, Y batching, and validation.""" - def test_same_y_forces_serialization(self): - batches = plan_batches([0, 1, 2], _coords([100.0] * 3, [200.0] * 3), self.S, x_tolerance=0.1) - self.assertEqual(len(batches), 3) + S = 9.0 - def test_barely_fitting_spacing(self): + def test_spacing_boundary(self): + # Exactly 9mm -> one batch batches = plan_batches([0, 1], _coords([100.0] * 2, [209.0, 200.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) - - def test_barely_insufficient_spacing(self): + # 0.1mm short -> two batches batches = plan_batches([0, 1], _coords([100.0] * 2, [208.9, 200.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) - def test_reversed_y_order_splits(self): - batches = plan_batches([0, 1], _coords([100.0] * 2, [200.0, 220.0]), self.S, x_tolerance=0.1) + def test_same_y_serializes(self): + batches = plan_batches([0, 1, 2], _coords([100.0] * 3, [200.0] * 3), self.S, x_tolerance=0.1) + self.assertEqual(len(batches), 3) + + def test_x_tolerance_boundary(self): + # Within tolerance -> one group + batches = plan_batches( + [0, 1], _coords([100.0, 100.05], [270.0, 261.0]), self.S, x_tolerance=0.1 + ) + self.assertEqual(len(batches), 1) + # Outside tolerance -> two groups + batches = plan_batches([0, 1], _coords([100.0, 100.2], [270.0, 270.0]), self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) - # --- Non-consecutive channels --- + def test_x_groups_sorted_ascending(self): + batches = plan_batches( + [0, 1, 2], _coords([300.0, 100.0, 200.0], [270.0] * 3), self.S, x_tolerance=0.1 + ) + xs = [b.x_position for b in batches] + self.assertEqual(xs, sorted(xs)) + + def test_empty_raises(self): + with self.assertRaises(ValueError): + plan_batches([], [], self.S, x_tolerance=0.1) - def test_non_consecutive_channels_with_phantoms(self): + def test_mismatched_lengths_raises(self): + with self.assertRaises(ValueError): + plan_batches([0, 1], _coords([100.0], [200.0]), self.S, x_tolerance=0.1) + + +class TestPhantomInterpolation(unittest.TestCase): + """Phantom channels between non-consecutive batch members.""" + + def test_phantoms_interpolated_at_spacing(self): batches = plan_batches( [0, 1, 2, 5, 6, 7], _coords([100.0] * 6, [300.0, 291.0, 282.0, 255.0, 246.0, 237.0]), - self.S, + 9.0, x_tolerance=0.1, ) self.assertEqual(len(batches), 1) - self.assertEqual(sorted(batches[0].channels), [0, 1, 2, 5, 6, 7]) y = batches[0].y_positions self.assertIn(3, y) self.assertIn(4, y) self.assertAlmostEqual(y[3], 282.0 - 9.0) self.assertAlmostEqual(y[4], 282.0 - 18.0) - def test_phantom_channels_interpolated(self): - batches = plan_batches([0, 3], _coords([100.0] * 2, [300.0, 273.0]), self.S, x_tolerance=0.1) - self.assertEqual(len(batches), 1) - y = batches[0].y_positions - self.assertAlmostEqual(y[0], 300.0) - self.assertAlmostEqual(y[1], 291.0) - self.assertAlmostEqual(y[2], 282.0) - self.assertAlmostEqual(y[3], 273.0) - def test_phantoms_only_within_batch(self): - batches = plan_batches([0, 3], _coords([100.0] * 2, [200.0, 250.0]), self.S, x_tolerance=0.1) + # Split into 2 batches — no phantoms across batches + batches = plan_batches([0, 3], _coords([100.0] * 2, [200.0, 250.0]), 9.0, x_tolerance=0.1) self.assertEqual(len(batches), 2) for batch in batches: self.assertEqual(len(batch.y_positions), 1) - # --- Mixed X and Y --- - def test_mixed_complexity(self): +class TestCoordinateTargets(unittest.TestCase): + """plan_batches with Coordinate targets (no containers).""" + + def test_coordinate_x_grouping_and_y_batching(self): batches = plan_batches( [0, 1, 2, 3], _coords([100.0, 100.0, 200.0, 200.0], [200.0, 200.0, 270.0, 261.0]), - self.S, + 9.0, x_tolerance=0.1, ) x100 = [b for b in batches if abs(b.x_position - 100.0) < 0.01] x200 = [b for b in batches if abs(b.x_position - 200.0) < 0.01] - self.assertEqual(len(x100), 2) - self.assertEqual(len(x200), 1) - - # --- Validation --- - - def test_mismatched_lengths(self): - with self.assertRaises(ValueError): - plan_batches([0, 1], _coords([100.0], [200.0]), self.S, x_tolerance=0.1) - - def test_empty(self): - with self.assertRaises(ValueError): - plan_batches([], [], self.S, x_tolerance=0.1) - - # --- Index correctness --- + self.assertEqual(len(x100), 2) # same Y -> serialized + self.assertEqual(len(x200), 1) # 9mm apart -> parallel def test_indices_map_back_correctly(self): use_channels = [3, 7, 0] batches = plan_batches( - use_channels, _coords([100.0] * 3, [261.0, 237.0, 270.0]), self.S, x_tolerance=0.1 + use_channels, _coords([100.0] * 3, [261.0, 237.0, 270.0]), 9.0, x_tolerance=0.1 ) all_indices = [idx for b in batches for idx in b.indices] self.assertEqual(sorted(all_indices), [0, 1, 2]) @@ -165,102 +158,9 @@ def test_indices_map_back_correctly(self): for idx, ch in zip(batch.indices, batch.channels): self.assertEqual(use_channels[idx], ch) - # --- Realistic --- - - def test_8_channels_trough(self): - batches = plan_batches( - list(range(8)), - _coords([100.0] * 8, [300.0 - i * 9.0 for i in range(8)]), - self.S, - x_tolerance=0.1, - ) - self.assertEqual(len(batches), 1) - self.assertEqual(len(batches[0].channels), 8) - - def test_8_channels_narrow_well(self): - batches = plan_batches( - list(range(8)), _coords([100.0] * 8, [200.0] * 8), self.S, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 8) - - -class TestPlanBatchesMixedSpacing(unittest.TestCase): - """Tests for mixed-channel instruments (e.g. 1mL + 5mL).""" - - # Channels 0,1 are 1mL (8.98mm), channels 2,3 are 5mL (17.96mm) - SPACINGS = [8.98, 8.98, 17.96, 17.96] - - def test_two_1ml_channels_fit_at_9mm(self): - # ceil(8.98 * 10) / 10 = 9.0mm effective spacing - batches = plan_batches( - [0, 1], _coords([100.0] * 2, [209.0, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 1) - - def test_1ml_and_5ml_need_wider_spacing(self): - # ceil(17.96 * 10) / 10 = 18.0mm effective spacing between ch1 and ch2 - batches = plan_batches( - [1, 2], _coords([100.0] * 2, [217.9, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 2) - - def test_1ml_and_5ml_fit_at_wide_spacing(self): - batches = plan_batches( - [1, 2], _coords([100.0] * 2, [218.0, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 1) - def test_5ml_channels_too_close(self): - batches = plan_batches( - [2, 3], _coords([100.0] * 2, [217.9, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 2) - - def test_span_across_1ml_and_5ml_boundary(self): - # Rounded pairwise sum: 9.0 + 18.0 + 18.0 = 45.0mm - batches = plan_batches( - [0, 3], _coords([100.0] * 2, [245.0, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 1) - # Also check phantom positions - y = batches[0].y_positions - self.assertAlmostEqual(y[1], 245.0 - 9.0) - self.assertAlmostEqual(y[2], 245.0 - 9.0 - 18.0) - # 0.1mm less doesn't fit - batches = plan_batches( - [0, 3], _coords([100.0] * 2, [244.9, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 2) - - def test_pairwise_sum_avoids_unnecessary_split(self): - # Rounded pairwise sum ch0→ch3: 9.0 + 18.0 + 18.0 = 45.0mm, NOT 3 * 18.0 = 54.0mm. - # 50mm gap fits with pairwise even though < 54.0 - batches = plan_batches( - [0, 3], _coords([100.0] * 2, [250.0, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 1) - - def test_mixed_all_four_channels_spaced_wide(self): - batches = plan_batches( - [0, 1, 2, 3], - _coords([100.0] * 4, [300.0, 291.0, 273.0, 255.0]), - self.SPACINGS, - x_tolerance=0.1, - ) - self.assertEqual(len(batches), 1) - - def test_mixed_channels_at_1ml_spacing_forces_serialization(self): - batches = plan_batches( - [0, 1, 2, 3], - _coords([100.0] * 4, [300.0, 291.0, 282.0, 273.0]), - self.SPACINGS, - x_tolerance=0.1, - ) - self.assertGreater(len(batches), 1) - - -class TestPlanBatchesWithContainers(unittest.TestCase): - """Tests for the Container path with auto-spreading.""" +class TestContainerTargets(unittest.TestCase): + """plan_batches with Container targets and auto-spreading.""" S = 9.0 @@ -274,43 +174,29 @@ def _mock_container(self, cx: float, cy: float, size_y: float = 10.0, name: str def _mock_deck(self): return MagicMock(spec=Resource) - def test_single_container_no_spreading(self): - """One channel per container — no spreading needed.""" - c1 = self._mock_container(100.0, 270.0) - c2 = self._mock_container(100.0, 261.0) - deck = self._mock_deck() - batches = plan_batches([0, 1], [c1, c2], self.S, x_tolerance=0.1, wrt_resource=deck) - self.assertEqual(len(batches), 1) - self.assertEqual(sorted(batches[0].channels), [0, 1]) - @patch( "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" ) def test_same_container_auto_spreads(self, mock_offsets): - """Two channels targeting the same wide container get spread offsets.""" mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") deck = self._mock_deck() batches = plan_batches([0, 1], [trough, trough], self.S, x_tolerance=0.1, wrt_resource=deck) - # With spreading, ch0 at 204.5 and ch1 at 195.5 — 9mm apart, fits in one batch self.assertEqual(len(batches), 1) y = batches[0].y_positions self.assertAlmostEqual(y[0], 200.0 + 4.5) self.assertAlmostEqual(y[1], 200.0 - 4.5) def test_same_narrow_container_serialized(self): - """Two channels targeting the same narrow container can't spread — serialized.""" well = self._mock_container(100.0, 200.0, size_y=5.0, name="narrow_well") deck = self._mock_deck() batches = plan_batches([0, 1], [well, well], self.S, x_tolerance=0.1, wrt_resource=deck) - # Can't spread in 5mm container, both at y=200 — must serialize self.assertEqual(len(batches), 2) @patch( "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" ) def test_resource_offsets_skips_auto_spreading(self, mock_offsets): - """User-provided offsets disable auto-spreading.""" trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") deck = self._mock_deck() user_offsets = [Coordinate(0, 10.0, 0), Coordinate(0, -10.0, 0)] @@ -322,7 +208,6 @@ def test_resource_offsets_skips_auto_spreading(self, mock_offsets): wrt_resource=deck, resource_offsets=user_offsets, ) - # Should use user offsets (210, 190) not auto-spread mock_offsets.assert_not_called() self.assertEqual(len(batches), 1) y = batches[0].y_positions @@ -344,80 +229,46 @@ def _mock_container(self, size_y: float): def test_even_span_no_center_offset(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] result = compute_single_container_offsets(self._mock_container(50.0), [0, 1], self.S) + assert result is not None self.assertAlmostEqual(result[0].y, 4.5) self.assertAlmostEqual(result[1].y, -4.5) - def test_single_channel_returns_zero(self): - result = compute_single_container_offsets(self._mock_container(50.0), [0], self.S) - self.assertAlmostEqual(result[0].y, 0.0) - @patch( "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" ) - def test_odd_span_applies_center_offset(self, mock_offsets): - mock_offsets.return_value = [ - Coordinate(0, 9.0, 0), - Coordinate(0, 0.0, 0), - Coordinate(0, -9.0, 0), - ] + def test_odd_span_center_offset_when_wide_enough(self, mock_offsets): + mock_offsets.return_value = [Coordinate(0, 9.0, 0), Coordinate(0, 0.0, 0), Coordinate(0, -9.0, 0)] + # 50mm: max_offset=9.0, 9.0 + 5.5 + 4.5 = 19.0 <= 25.0 -> shift applied result = compute_single_container_offsets(self._mock_container(50.0), [0, 1, 2], self.S) + assert result is not None self.assertAlmostEqual(result[0].y, 9.0 + 5.5) - self.assertAlmostEqual(result[1].y, 0.0 + 5.5) - self.assertAlmostEqual(result[2].y, -9.0 + 5.5) + + def test_container_too_small_returns_none(self): + self.assertIsNone(compute_single_container_offsets(self._mock_container(10.0), [0, 1], self.S)) @patch( "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" ) - def test_non_consecutive_selects_correct_offsets(self, mock_offsets): - mock_offsets.return_value = [ - Coordinate(0, 10.0, 0), - Coordinate(0, 0.0, 0), - Coordinate(0, -10.0, 0), - ] + def test_non_consecutive_uses_full_physical_span(self, mock_offsets): + mock_offsets.return_value = [Coordinate(0, 10.0, 0), Coordinate(0, 0.0, 0), Coordinate(0, -10.0, 0)] result = compute_single_container_offsets(self._mock_container(50.0), [0, 2], self.S) + assert result is not None self.assertEqual(len(result), 2) mock_offsets.assert_called_once_with( resource=unittest.mock.ANY, num_channels=3, min_spacing=self.S ) - def test_container_too_small_returns_none(self): - self.assertIsNone(compute_single_container_offsets(self._mock_container(10.0), [0, 1], self.S)) - - def test_empty_channels(self): - self.assertEqual(compute_single_container_offsets(self._mock_container(50.0), [], self.S), []) - @patch( "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" ) def test_mixed_spacing_uses_effective(self, mock_offsets): - mock_offsets.return_value = [ - Coordinate(0, 18.0, 0), - Coordinate(0, 0.0, 0), - Coordinate(0, -18.0, 0), - ] - spacings = [9.0, 9.0, 18.0] - result = compute_single_container_offsets(self._mock_container(100.0), [0, 2], spacings) + mock_offsets.return_value = [Coordinate(0, 18.0, 0), Coordinate(0, 0.0, 0), Coordinate(0, -18.0, 0)] + result = compute_single_container_offsets(self._mock_container(100.0), [0, 2], [9.0, 9.0, 18.0]) self.assertIsNotNone(result) mock_offsets.assert_called_once_with( resource=unittest.mock.ANY, num_channels=3, min_spacing=18.0 ) -class TestPairwiseMinSpacing(unittest.TestCase): - def test_uniform_spacing(self): - spacings = [9.0] * 8 - self.assertAlmostEqual(_min_spacing_between(spacings, 0, 1), 9.0) - self.assertAlmostEqual(_min_spacing_between(spacings, 5, 6), 9.0) - - def test_mixed_spacing(self): - spacings = [8.98, 8.98, 17.96, 17.96] - # max(8.98, 8.98) = 8.98 → ceil(89.8)/10 = 9.0 - self.assertAlmostEqual(_min_spacing_between(spacings, 0, 1), 9.0) - # max(8.98, 17.96) = 17.96 → ceil(179.6)/10 = 18.0 - self.assertAlmostEqual(_min_spacing_between(spacings, 1, 2), 18.0) - # max(17.96, 17.96) = 17.96 → ceil(179.6)/10 = 18.0 - self.assertAlmostEqual(_min_spacing_between(spacings, 2, 3), 18.0) - - if __name__ == "__main__": unittest.main() From 9c3448aaebcc33c42ffc2299019f18e62e313e84 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sun, 22 Mar 2026 22:11:24 +0000 Subject: [PATCH 09/39] `make format` --- .../pipette_batch_scheduling_tests.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index e1770727ab4..2a5573d66ef 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -237,7 +237,11 @@ def test_even_span_no_center_offset(self, mock_offsets): "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" ) def test_odd_span_center_offset_when_wide_enough(self, mock_offsets): - mock_offsets.return_value = [Coordinate(0, 9.0, 0), Coordinate(0, 0.0, 0), Coordinate(0, -9.0, 0)] + mock_offsets.return_value = [ + Coordinate(0, 9.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -9.0, 0), + ] # 50mm: max_offset=9.0, 9.0 + 5.5 + 4.5 = 19.0 <= 25.0 -> shift applied result = compute_single_container_offsets(self._mock_container(50.0), [0, 1, 2], self.S) assert result is not None @@ -250,7 +254,11 @@ def test_container_too_small_returns_none(self): "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" ) def test_non_consecutive_uses_full_physical_span(self, mock_offsets): - mock_offsets.return_value = [Coordinate(0, 10.0, 0), Coordinate(0, 0.0, 0), Coordinate(0, -10.0, 0)] + mock_offsets.return_value = [ + Coordinate(0, 10.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -10.0, 0), + ] result = compute_single_container_offsets(self._mock_container(50.0), [0, 2], self.S) assert result is not None self.assertEqual(len(result), 2) @@ -262,7 +270,11 @@ def test_non_consecutive_uses_full_physical_span(self, mock_offsets): "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" ) def test_mixed_spacing_uses_effective(self, mock_offsets): - mock_offsets.return_value = [Coordinate(0, 18.0, 0), Coordinate(0, 0.0, 0), Coordinate(0, -18.0, 0)] + mock_offsets.return_value = [ + Coordinate(0, 18.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -18.0, 0), + ] result = compute_single_container_offsets(self._mock_container(100.0), [0, 2], [9.0, 9.0, 18.0]) self.assertIsNotNone(result) mock_offsets.assert_called_once_with( From 5147f11e5ac719572907d438834ab5f155e10376 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 23 Mar 2026 09:50:24 +0000 Subject: [PATCH 10/39] update docstrings --- pylabrobot/liquid_handling/pipette_batch_scheduling.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 749b4a7d9dd..e637e5b2ad4 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -32,8 +32,8 @@ class ChannelBatch: """A group of channels that can operate simultaneously. - After transition optimization, ``y_positions`` contains entries for all instrument - channels (not just active and phantom ones). + ``y_positions`` contains entries for active channels and any phantom channels + between non-consecutive active members. """ x_position: float @@ -403,8 +403,8 @@ def plan_batches( Args: use_channels: Channel indices being used (e.g. [0, 1, 2, 5, 6, 7]). - targets: Either Container objects (requires *deck*) or Coordinate objects with - absolute X/Y positions. One per entry in *use_channels*. + targets: Either Container objects (requires *wrt_resource*) or Coordinate objects + with absolute X/Y positions. One per entry in *use_channels*. channel_spacings: Minimum Y spacing per channel (mm). Scalar for uniform, or a list with one entry per channel on the instrument. x_tolerance: Positions within this tolerance share an X group. From 3be2bc8b0e5a81c40059c22bf073d856ef468c46 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 8 Apr 2026 17:08:02 +0100 Subject: [PATCH 11/39] Improve probe_liquid_heights Z handling, use compute_channel_offsets directly Eliminate yoyo Z motion by raising idle channels in parallel (PX ZA) and positioning involved channels at traverse height in one pass. Replace deprecated get_wide_single_resource_liquid_op_offsets with compute_channel_offsets. Restore subset-scoped spacing in pierce_foil. Add x_tolerance validation in plan_batches. --- .../backends/hamilton/STAR_backend.py | 30 ++++++++++++------- .../pipette_batch_scheduling.py | 9 ++++-- .../pipette_batch_scheduling_tests.py | 28 +++++------------ 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 8a4cd08eb1a..4414c8df04f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2127,15 +2127,16 @@ async def probe_liquid_heights( use_channels: Channel indices to use for probing (0-indexed). resource_offsets: Optional XYZ offsets from container centers. When not provided, ``plan_batches`` auto-spreads channels targeting the same container. - lld_mode: Detection mode - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. - Defaults to capacitive. + lld_mode: Detection mode - GAMMA for capacitive, PRESSURE for pressure-based. + Defaults to GAMMA. search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. n_replicates: Number of measurements per channel. Default 1. move_to_z_safety_after: Whether to move channels to safe Z height after probing. Set to False when probing is immediately followed by another Z operation (e.g. aspirate) to avoid unnecessary Z travel. Default True. min_traverse_height_at_beginning_of_command: Absolute Z height (mm) to move involved - channels to before the first batch. None (default) uses full Z safety. + channels to before the first batch. Must clear all deck obstacles since channels + travel laterally at this height. None (default) uses full Z safety. min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to between batches. None (default) uses full Z safety. z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after @@ -2158,13 +2159,16 @@ async def probe_liquid_heights( if n_replicates < 1: raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") if lld_mode not in {self.LLDMode.GAMMA, self.LLDMode.PRESSURE}: - raise ValueError(f"LLDMode must be 1 (capacitive) or 2 (pressure-based), is {lld_mode}") + raise ValueError( + f"lld_mode must be GAMMA (capacitive) or PRESSURE (pressure-based), got {lld_mode!r}" + ) use_channels = validate_channel_selections( containers=containers, use_channels=use_channels, num_channels=self.num_channels, ) + idle_channels = sorted(set(range(self.num_channels)) - set(use_channels)) # Verify tips and query tip lengths tip_presence = await self.request_tip_presence() @@ -2172,15 +2176,18 @@ async def probe_liquid_heights( raise RuntimeError("All specified channels must have tips attached.") tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] - # TODO: this raises ALL channels to max Z, then lowers involved channels back down — - # wasteful yoyo motion. Should raise uninvolved to safety and involved to - # min_traverse_height_at_beginning_of_command in one pass. Requires a channel-filtered - # version of move_all_channels_in_z_safety. - await self.move_all_channels_in_z_safety() if min_traverse_height_at_beginning_of_command is not None: + await asyncio.gather( + *[ + self.move_channel_stop_disk_z(channel_idx=ch_idx, z=self.MAXIMUM_CHANNEL_Z_POSITION) + for ch_idx in idle_channels + ] + ) await self.position_channels_in_z_direction( {ch: min_traverse_height_at_beginning_of_command for ch in use_channels} ) + else: + await self.move_all_channels_in_z_safety() # Compute Z positions z_cavity_bottom: List[float] = [] @@ -11678,7 +11685,10 @@ async def pierce_foil( offsets = get_wide_single_resource_liquid_op_offsets( resource=well, num_channels=len(piercing_channels), - min_spacing=max(self._channels_minimum_y_spacing), + min_spacing=max( + self._min_spacing_between(lo, hi) + for lo, hi in zip(sorted(piercing_channels)[:-1], sorted(piercing_channels)[1:]) + ), ) else: offsets = get_tight_single_resource_liquid_op_offsets( diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 2cab525f1c6..57cf090c712 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -19,7 +19,7 @@ from pylabrobot.liquid_handling.channel_positioning import ( MIN_SPACING_EDGE, - get_wide_single_resource_liquid_op_offsets, + compute_channel_offsets, ) from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate @@ -241,10 +241,11 @@ def _offsets_for_consecutive_group( if container.get_absolute_size_y() < min_required: return None - all_offsets = get_wide_single_resource_liquid_op_offsets( + all_offsets = compute_channel_offsets( resource=container, num_channels=num_physical, - min_spacing=spacing, + spread="wide", + channel_spacings=[spacing] * num_physical, ) offsets = [all_offsets[ch - ch_lo] for ch in use_channels] @@ -418,6 +419,8 @@ def plan_batches( Flat list of ChannelBatch sorted by ascending X position. """ + if x_tolerance <= 0: + raise ValueError(f"x_tolerance must be > 0, got {x_tolerance}.") if len(use_channels) != len(targets): raise ValueError( f"use_channels and targets must have the same length, " diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 2a5573d66ef..010bd0fb457 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -174,9 +174,7 @@ def _mock_container(self, cx: float, cy: float, size_y: float = 10.0, name: str def _mock_deck(self): return MagicMock(spec=Resource) - @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" - ) + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") def test_same_container_auto_spreads(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") @@ -193,9 +191,7 @@ def test_same_narrow_container_serialized(self): batches = plan_batches([0, 1], [well, well], self.S, x_tolerance=0.1, wrt_resource=deck) self.assertEqual(len(batches), 2) - @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" - ) + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") def test_resource_offsets_skips_auto_spreading(self, mock_offsets): trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") deck = self._mock_deck() @@ -223,9 +219,7 @@ def _mock_container(self, size_y: float): c.get_absolute_size_y.return_value = size_y return c - @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" - ) + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") def test_even_span_no_center_offset(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] result = compute_single_container_offsets(self._mock_container(50.0), [0, 1], self.S) @@ -233,9 +227,7 @@ def test_even_span_no_center_offset(self, mock_offsets): self.assertAlmostEqual(result[0].y, 4.5) self.assertAlmostEqual(result[1].y, -4.5) - @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" - ) + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") def test_odd_span_center_offset_when_wide_enough(self, mock_offsets): mock_offsets.return_value = [ Coordinate(0, 9.0, 0), @@ -250,9 +242,7 @@ def test_odd_span_center_offset_when_wide_enough(self, mock_offsets): def test_container_too_small_returns_none(self): self.assertIsNone(compute_single_container_offsets(self._mock_container(10.0), [0, 1], self.S)) - @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" - ) + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") def test_non_consecutive_uses_full_physical_span(self, mock_offsets): mock_offsets.return_value = [ Coordinate(0, 10.0, 0), @@ -263,12 +253,10 @@ def test_non_consecutive_uses_full_physical_span(self, mock_offsets): assert result is not None self.assertEqual(len(result), 2) mock_offsets.assert_called_once_with( - resource=unittest.mock.ANY, num_channels=3, min_spacing=self.S + resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[self.S] * 3 ) - @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.get_wide_single_resource_liquid_op_offsets" - ) + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") def test_mixed_spacing_uses_effective(self, mock_offsets): mock_offsets.return_value = [ Coordinate(0, 18.0, 0), @@ -278,7 +266,7 @@ def test_mixed_spacing_uses_effective(self, mock_offsets): result = compute_single_container_offsets(self._mock_container(100.0), [0, 2], [9.0, 9.0, 18.0]) self.assertIsNotNone(result) mock_offsets.assert_called_once_with( - resource=unittest.mock.ANY, num_channels=3, min_spacing=18.0 + resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[18.0] * 3 ) From 8ad38ab8beb7ae17790dda7923e78ff1465f8813 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 8 Apr 2026 17:14:37 +0100 Subject: [PATCH 12/39] add TODO for move to radii model (out of PR scope) --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 4414c8df04f..88ae716d7c7 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1366,6 +1366,10 @@ def _min_spacing_between(self, i: int, j: int) -> float: Uses max() of both channels' spacings for firmware safety (conservative). For adjacent channels, ceiling-rounded to 0.1mm. For non-adjacent channels, the sum of all intermediate adjacent-pair spacings. + + TODO: migrate to radii model (spacing[i]/2 + spacing[j]/2) to match + compute_channel_offsets. Current max() model is conservative but inconsistent + with channel_positioning.py's diameter-based abstraction. """ lo, hi = min(i, j), max(i, j) if hi - lo == 1: From cee71a133e3ae01447d436e845da7ae9f19c5cb7 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 8 Apr 2026 17:23:03 +0100 Subject: [PATCH 13/39] Allow duplicate `use_channels`, remove redundant inline comment --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 5 ++--- pylabrobot/liquid_handling/pipette_batch_scheduling.py | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 88ae716d7c7..b9b797d97b6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2114,7 +2114,6 @@ async def probe_liquid_heights( min_traverse_height_at_beginning_of_command: Optional[float] = None, min_traverse_height_during_command: Optional[float] = None, z_position_at_end_of_command: Optional[float] = None, - # X grouping tolerance (mm) — containers within this distance share an X group x_grouping_tolerance: Optional[float] = None, ) -> List[float]: """Probe liquid surface heights in containers using liquid level detection. @@ -2152,8 +2151,8 @@ async def probe_liquid_heights( Mean of measured liquid heights for each container (mm from cavity bottom). Raises: - ValueError: If ``use_channels`` is empty, contains out-of-range indices, contains - duplicates, or if input list lengths don't match. + ValueError: If ``use_channels`` is empty, contains out-of-range indices, + or if input list lengths don't match. RuntimeError: If any specified channel lacks a tip. """ diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 57cf090c712..5d4e58814cc 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -332,7 +332,7 @@ def validate_channel_selections( Validated list of channel indices. Raises: - ValueError: If channels are empty, out of range, or contain duplicates. + ValueError: If channels are empty or out of range. """ if use_channels is None: use_channels = list(range(len(containers))) @@ -342,8 +342,6 @@ def validate_channel_selections( raise ValueError( f"All use_channels must be integers in range [0, {num_channels - 1}], got {use_channels}." ) - if len(use_channels) != len(set(use_channels)): - raise ValueError("use_channels must not contain duplicates.") if len(containers) != len(use_channels): raise ValueError( f"Length of containers and use_channels must match, " From afe8996aec8526988fe75cc62c0ee9d5106e1fa3 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 8 Apr 2026 17:35:52 +0100 Subject: [PATCH 14/39] Address PR review: reorder `validate_channel_selections` args, use list comprehensions --- .../backends/hamilton/STAR_backend.py | 12 +++++------- .../backends/hamilton/STAR_chatterbox.py | 2 +- .../liquid_handling/pipette_batch_scheduling.py | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index b9b797d97b6..5e3541e4301 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2168,8 +2168,8 @@ async def probe_liquid_heights( use_channels = validate_channel_selections( containers=containers, - use_channels=use_channels, num_channels=self.num_channels, + use_channels=use_channels, ) idle_channels = sorted(set(range(self.num_channels)) - set(use_channels)) @@ -2192,12 +2192,10 @@ async def probe_liquid_heights( else: await self.move_all_channels_in_z_safety() - # Compute Z positions - z_cavity_bottom: List[float] = [] - z_top: List[float] = [] - for resource in containers: - z_cavity_bottom.append(resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z) - z_top.append(resource.get_location_wrt(self.deck, "c", "c", "t").z) + z_cavity_bottom = [ + r.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z for r in containers + ] + z_top = [r.get_location_wrt(self.deck, "c", "c", "t").z for r in containers] batches = plan_batches( use_channels=use_channels, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 847b9bfdc32..79e350f2e24 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -369,8 +369,8 @@ async def probe_liquid_heights( use_channels = validate_channel_selections( containers=containers, - use_channels=use_channels, num_channels=self.num_channels, + use_channels=use_channels, ) # Validate tip presence using tip tracker diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 5d4e58814cc..ba25767de21 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -321,8 +321,8 @@ def compute_single_container_offsets( def validate_channel_selections( containers: List[Container], - use_channels: Optional[List[int]], num_channels: int, + use_channels: Optional[List[int]] = None, ) -> List[int]: """Validate and normalize channel selection. From 37e5449bcef42330f3168aa58517c57290855f1f Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 8 Apr 2026 18:24:18 +0100 Subject: [PATCH 15/39] Return data from callbacks, don't mutate inputs, use literal box-drawing chars --- .../backends/hamilton/STAR_backend.py | 34 +++++++++++++------ .../pipette_batch_scheduling.py | 28 ++++++++------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 5e3541e4301..6e21b110972 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2059,10 +2059,10 @@ class PressureLLDMode(enum.Enum): async def execute_batched( self, - func: Callable[[ChannelBatch], Awaitable[None]], + func: Callable[[ChannelBatch], Awaitable[T]], batches: List[ChannelBatch], min_traverse_height_during_command: Optional[float] = None, - ) -> None: + ) -> List[T]: """Execute a Z-axis callback across pre-planned batches with X/Y positioning. Handles inter-batch safety: raises channels between batches, moves X when the @@ -2075,7 +2075,11 @@ async def execute_batched( batches: Pre-planned batches from ``plan_batches()``. min_traverse_height_during_command: Absolute Z height (mm) for inter-batch channel raises. ``None`` uses full Z safety. + + Returns: + List of results from each batch callback, in batch order. """ + results: List[T] = [] try: prev_batch: Optional[ChannelBatch] = None for batch in batches: @@ -2091,7 +2095,7 @@ async def execute_batched( await self.move_channel_x(0, batch.x_position) await self.position_channels_in_y_direction(batch.y_positions) - await func(batch) + results.append(await func(batch)) prev_batch = batch except Exception: # firmware errors, RuntimeError, etc. @@ -2101,6 +2105,8 @@ async def execute_batched( await self.move_all_channels_in_z_safety() raise + return results + async def probe_liquid_heights( self, containers: List[Container], @@ -2213,12 +2219,9 @@ async def probe_liquid_heights( else: detect_func = self._search_for_surface_using_plld - # Execute batches - absolute_heights_measurements: Dict[int, List[Optional[float]]] = { - ch: [] for ch in use_channels - } - - async def _probe_batch_heights(batch: ChannelBatch) -> None: + async def _probe_batch_heights( + batch: ChannelBatch, + ) -> Dict[int, List[Optional[float]]]: batch_lowest_immers = [ z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH for i in batch.indices ] @@ -2227,6 +2230,8 @@ async def _probe_batch_heights(batch: ChannelBatch) -> None: for i in batch.indices ] + measurements: Dict[int, List[Optional[float]]] = {ch: [] for ch in batch.channels} + for _ in range(n_replicates): results = await asyncio.gather( *[ @@ -2262,14 +2267,21 @@ async def _probe_batch_heights(batch: ChannelBatch) -> None: raise result else: height = current_absolute_liquid_heights[ch_idx] - absolute_heights_measurements[ch_idx].append(height) + measurements[ch_idx].append(height) - await self.execute_batched( + return measurements + + batch_results = await self.execute_batched( func=_probe_batch_heights, batches=batches, min_traverse_height_during_command=min_traverse_height_during_command, ) + absolute_heights_measurements: Dict[int, List[Optional[float]]] = {} + for batch_measurements in batch_results: + for ch, heights in batch_measurements.items(): + absolute_heights_measurements.setdefault(ch, []).extend(heights) + # Compute liquid heights relative to well bottom relative_to_well: List[float] = [] inconsistent_channels: List[str] = [] diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index ba25767de21..15680903c08 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -72,21 +72,21 @@ def print_batches( for xg_i, x_key in enumerate(xg_keys): xg_batches = x_groups[x_key] is_last_xg = xg_i == len(xg_keys) - 1 - xg_branch = "\u2514" if is_last_xg else "\u251c" - xg_cont = " " if is_last_xg else "\u2502" - print(f" {xg_branch}\u2500\u2500 x-group {xg_i + 1} (x={x_key:.1f} mm)") + xg_branch = "└" if is_last_xg else "├" + xg_cont = " " if is_last_xg else "│" + print(f" {xg_branch}── x-group {xg_i + 1} (x={x_key:.1f} mm)") for yb_i, b in enumerate(xg_batches): is_last_yb = yb_i == len(xg_batches) - 1 - yb_branch = "\u2514" if is_last_yb else "\u251c" - yb_cont = " " if is_last_yb else "\u2502" - print(f" {xg_cont} {yb_branch}\u2500\u2500 y-batch {yb_i + 1}") + yb_branch = "└" if is_last_yb else "├" + yb_cont = " " if is_last_yb else "│" + print(f" {xg_cont} {yb_branch}── y-batch {yb_i + 1}") for ch in sorted(b.y_positions.keys()): is_last_ch = ch == max(b.y_positions.keys()) - ch_branch = "\u2514" if is_last_ch else "\u251c" + ch_branch = "└" if is_last_ch else "├" active = "*" if ch in b.channels else " " container_name = f" ({ch_to_container[ch].name})" if ch in ch_to_container else "" print( - f" {xg_cont} {yb_cont} {ch_branch}\u2500\u2500 {active}ch{ch}: y={b.y_positions[ch]:.1f} mm{container_name}" + f" {xg_cont} {yb_cont} {ch_branch}── {active}ch{ch}: y={b.y_positions[ch]:.1f} mm{container_name}" ) @@ -155,20 +155,22 @@ def _channel_fits_batch( def _interpolate_phantoms( channels: List[int], y_positions: Dict[int, float], spacings: List[float] -) -> None: - """Fill in Y positions for phantom channels between non-consecutive batch members. +) -> Dict[int, float]: + """Return Y positions with phantom channels filled in between non-consecutive batch members. Each phantom is placed at its actual pairwise spacing from the previous channel, so non-uniform spacings are respected (e.g. a wide channel only widens its own gaps). """ + result = dict(y_positions) sorted_chs = sorted(channels) for k in range(len(sorted_chs) - 1): ch_lo, ch_hi = sorted_chs[k], sorted_chs[k + 1] cumulative = 0.0 for phantom in range(ch_lo + 1, ch_hi): cumulative += _min_spacing_between(spacings, phantom - 1, phantom) - if phantom not in y_positions: - y_positions[phantom] = y_positions[ch_lo] - cumulative + if phantom not in result: + result[phantom] = result[ch_lo] - cumulative + return result def _partition_into_y_batches( @@ -207,7 +209,7 @@ def _partition_into_y_batches( for batch in batches: batch_channels = [use_channels[i] for i in batch.indices] y_positions: Dict[int, float] = {use_channels[i]: y_pos[i] for i in batch.indices} - _interpolate_phantoms(batch_channels, y_positions, spacings) + y_positions = _interpolate_phantoms(batch_channels, y_positions, spacings) result.append( ChannelBatch( x_position=x_position, From b485855f9f6c6a363e39a3c3d57a51de7b962ae7 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 9 Apr 2026 14:24:52 +0100 Subject: [PATCH 16/39] Simplify plan_batches to coordinates-only, validate channel_spacings, restore duplicate-channel guard Address PR review feedback: - Split plan_batches into resolve_container_targets (container-to-coordinate conversion with auto-spreading) and plan_batches (coordinate-only batching). Removes wrt_resource, resource_offsets, and Union target type. - Change channel_spacings from Union[float, List[float]] to List[float]. A too-short list now raises ValueError instead of silently padding. - Restore duplicate-channel guard in _partition_into_y_batches that was lost when replacing group_by_x_batch_by_xy (needed for multi-aspirate). - Inline compute_positions into resolve_container_targets (single caller). - Update callers (STAR_backend, STAR_chatterbox), tests, and docstrings. --- .../backends/hamilton/STAR_backend.py | 19 +- .../backends/hamilton/STAR_chatterbox.py | 14 +- .../pipette_batch_scheduling.py | 181 +++++++++--------- .../pipette_batch_scheduling_tests.py | 49 +++-- 4 files changed, 139 insertions(+), 124 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 6e21b110972..b2ea274ab70 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -50,6 +50,7 @@ from pylabrobot.liquid_handling.pipette_batch_scheduling import ( ChannelBatch, plan_batches, + resolve_container_targets, validate_channel_selections, ) from pylabrobot.liquid_handling.standard import ( @@ -2128,14 +2129,15 @@ async def probe_liquid_heights( container positions and sensing the liquid surface. Heights are measured from the bottom of each container's cavity. - Uses ``plan_batches`` for X/Y partitioning and auto-spreading, then ``execute_batched`` - to iterate batches with Z safety. + Uses ``resolve_container_targets`` for position computation and auto-spreading, + ``plan_batches`` for X/Y partitioning, then ``execute_batched`` to iterate + batches with Z safety. Args: containers: List of Container objects to probe, one per channel. use_channels: Channel indices to use for probing (0-indexed). resource_offsets: Optional XYZ offsets from container centers. When not provided, - ``plan_batches`` auto-spreads channels targeting the same container. + ``resolve_container_targets`` auto-spreads channels targeting the same container. lld_mode: Detection mode - GAMMA for capacitive, PRESSURE for pressure-based. Defaults to GAMMA. search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. @@ -2203,14 +2205,19 @@ async def probe_liquid_heights( ] z_top = [r.get_location_wrt(self.deck, "c", "c", "t").z for r in containers] - batches = plan_batches( + targets = resolve_container_targets( + containers=containers, use_channels=use_channels, - targets=containers, channel_spacings=self._channels_minimum_y_spacing, - x_tolerance=x_grouping_tolerance, wrt_resource=self.deck, resource_offsets=resource_offsets, ) + batches = plan_batches( + use_channels=use_channels, + targets=targets, + channel_spacings=self._channels_minimum_y_spacing, + x_tolerance=x_grouping_tolerance, + ) # Select detection function and kwargs detect_func: Callable[..., Any] diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 79e350f2e24..41238f8beae 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -15,6 +15,7 @@ from pylabrobot.liquid_handling.pipette_batch_scheduling import ( plan_batches, print_batches, + resolve_container_targets, validate_channel_selections, ) from pylabrobot.resources.container import Container @@ -353,7 +354,7 @@ async def probe_liquid_heights( Args: containers: List of Container objects to probe, one per channel. use_channels: Channel indices to use (0-indexed). Defaults to ``[0, ..., len(containers)-1]``. - resource_offsets: Passed to ``plan_batches`` for auto-spreading. See ``plan_batches``. + resource_offsets: Passed to ``resolve_container_targets`` for auto-spreading. All other parameters: Accepted for API compatibility but unused in mock. Returns: @@ -377,14 +378,19 @@ async def probe_liquid_heights( for ch in use_channels: self.head[ch].get_tip() # Raises NoTipError if no tip - batches = plan_batches( + targets = resolve_container_targets( + containers=containers, use_channels=use_channels, - targets=containers, channel_spacings=self._channels_minimum_y_spacing, - x_tolerance=x_grouping_tolerance, wrt_resource=self.deck, resource_offsets=resource_offsets, ) + batches = plan_batches( + use_channels=use_channels, + targets=targets, + channel_spacings=self._channels_minimum_y_spacing, + x_tolerance=x_grouping_tolerance, + ) print_batches(batches, use_channels, containers, label="probe_liquid_heights plan") diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 15680903c08..5bb4e851149 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -4,18 +4,15 @@ Y spacing, descending Y order by channel index) that limit which channels can act simultaneously. - batches = plan_batches( - use_channels, targets=containers, - channel_spacings=[9.0]*8, x_tolerance=0.1, - wrt_resource=deck, - ) + targets = resolve_container_targets(containers, use_channels, spacings, deck) + batches = plan_batches(use_channels, targets, spacings, x_tolerance=0.1) await backend.execute_batched(func=my_z_callback, batches=batches) """ import math from collections import defaultdict from dataclasses import dataclass, field -from typing import Dict, List, Optional, Tuple, Union, cast +from typing import Dict, List, Optional from pylabrobot.liquid_handling.channel_positioning import ( MIN_SPACING_EDGE, @@ -195,6 +192,8 @@ def _partition_into_y_batches( assigned = False for batch in batches: + if channel in [use_channels[i] for i in batch.indices]: + continue if _channel_fits_batch(batch, channel, y, spacings): batch.indices.append(idx) batch.hi_ch = channel @@ -265,7 +264,7 @@ def _offsets_for_consecutive_group( def compute_single_container_offsets( container: Container, use_channels: List[int], - channel_spacings: Union[float, List[float]], + channel_spacings: List[float], ) -> Optional[List[Coordinate]]: """Compute spread Y offsets for multiple channels targeting the same container. @@ -284,10 +283,12 @@ def compute_single_container_offsets( return [Coordinate.zero()] ch_lo, ch_hi = min(use_channels), max(use_channels) - if isinstance(channel_spacings, (int, float)): - spacing = float(channel_spacings) - else: - spacing = _effective_spacing(channel_spacings, ch_lo, ch_hi) + if len(channel_spacings) < ch_hi + 1: + raise ValueError( + f"channel_spacings list must have at least {ch_hi + 1} entries " + f"(max channel index is {ch_hi}), got {len(channel_spacings)}." + ) + spacing = _effective_spacing(channel_spacings, ch_lo, ch_hi) # Try the full span first (all channels including phantoms fit) full = _offsets_for_consecutive_group(container, use_channels, spacing) @@ -352,27 +353,73 @@ def validate_channel_selections( return use_channels -def compute_positions( +def resolve_container_targets( containers: List[Container], - resource_offsets: List[Coordinate], + use_channels: List[int], + channel_spacings: List[float], wrt_resource: Resource, -) -> Tuple[List[float], List[float]]: - """Convert containers and offsets into absolute X/Y positions relative to a resource. + resource_offsets: Optional[List[Coordinate]] = None, +) -> List[Coordinate]: + """Convert containers to absolute Coordinates, auto-spreading when needed. + + When *resource_offsets* is ``None`` and multiple channels target the same + container, computes spread offsets via ``compute_single_container_offsets`` + so channels can be batched in parallel. If the container is too narrow to + spread, channels stay at center and will be serialized by ``plan_batches``. + + When *resource_offsets* is provided, uses those offsets directly (no + auto-spreading). - Each container must be a descendant of *wrt_resource* (checked by - ``Resource.get_location_wrt``, which raises ``ValueError`` if not). + Args: + containers: Container objects, one per entry in *use_channels*. + use_channels: Channel indices being used. + channel_spacings: Minimum Y spacing per channel (mm), one entry per + channel on the instrument. + wrt_resource: Reference resource for computing positions. All containers + must be descendants of this resource. + resource_offsets: Optional XYZ offsets from container centers. Returns: - (x_positions, y_positions) — parallel lists of coordinates in mm, - one entry per container. + List of Coordinates (parallel to *use_channels* / *containers*) with + absolute X/Y positions ready for ``plan_batches``. """ + if resource_offsets is not None: + if len(resource_offsets) != len(containers): + raise ValueError( + f"resource_offsets length must match containers, " + f"got {len(resource_offsets)} and {len(containers)}." + ) + offsets = resource_offsets + else: + offsets = [Coordinate.zero()] * len(containers) + x_pos: List[float] = [] y_pos: List[float] = [] - for resource, offset in zip(containers, resource_offsets): - loc = resource.get_location_wrt(wrt_resource, x="c", y="c", z="b") + for container, offset in zip(containers, offsets): + loc = container.get_location_wrt(wrt_resource, x="c", y="c", z="b") x_pos.append(loc.x + offset.x) y_pos.append(loc.y + offset.y) - return x_pos, y_pos + + # Auto-spread: when multiple channels target the same container and no + # explicit offsets were given, compute spread offsets so they can be batched. + if resource_offsets is None: + container_groups: Dict[int, List[int]] = defaultdict(list) + for idx in range(len(containers)): + container_groups[id(containers[idx])].append(idx) + for c_indices in container_groups.values(): + if len(c_indices) < 2: + continue + group_channels = [use_channels[i] for i in c_indices] + spread = compute_single_container_offsets( + container=containers[c_indices[0]], + use_channels=group_channels, + channel_spacings=channel_spacings, + ) + if spread is not None: + for i, idx_val in enumerate(c_indices): + y_pos[idx_val] += spread[i].y + + return [Coordinate(x, y, 0) for x, y in zip(x_pos, y_pos)] # --- Public API --- @@ -380,40 +427,26 @@ def compute_positions( def plan_batches( use_channels: List[int], - targets: Union[List[Container], List[Coordinate]], - channel_spacings: Union[float, List[float]], + targets: List[Coordinate], + channel_spacings: List[float], x_tolerance: float, - wrt_resource: Optional[Resource] = None, - resource_offsets: Optional[List[Coordinate]] = None, ) -> List[ChannelBatch]: """Partition channel–target pairs into executable batches. - Targets can be either: - - - **Containers** (with *wrt_resource*): computes X/Y positions from containers relative - to a reference resource. When multiple channels target the same container and *resource_offsets* - is not provided, automatically spreads them using ``compute_single_container_offsets``. - Channels that cannot be spread (container too narrow) stay at the container center - and are serialized into separate batches. - - - **Coordinates**: uses absolute X/Y positions directly. No auto-spreading. - Groups by X position (within *x_tolerance*), then within each X group partitions into Y sub-batches respecting per-channel minimum spacing. Computes phantom channel positions for intermediate channels between non-consecutive batch members. + Use ``resolve_container_targets`` to convert Container objects to Coordinates + before calling this function. + Args: use_channels: Channel indices being used (e.g. [0, 1, 2, 5, 6, 7]). - targets: Either Container objects (requires *wrt_resource*) or Coordinate objects - with absolute X/Y positions. One per entry in *use_channels*. - channel_spacings: Minimum Y spacing per channel (mm). Scalar for uniform, - or a list with one entry per channel on the instrument. + targets: Coordinate objects with absolute X/Y positions. One per entry + in *use_channels*. + channel_spacings: Minimum Y spacing per channel (mm), one entry per + channel on the instrument. x_tolerance: Positions within this tolerance share an X group. - wrt_resource: Reference resource for computing positions. All containers must - be descendants of this resource. Required when *targets* are Containers. - resource_offsets: Optional XYZ offsets from container centers. When provided, - auto-spreading is disabled and these offsets are used directly. Only valid - when *targets* are Containers. Returns: Flat list of ChannelBatch sorted by ascending X position. @@ -428,33 +461,16 @@ def plan_batches( ) if len(use_channels) == 0: raise ValueError("use_channels must not be empty.") - - if wrt_resource is not None: - containers = cast(List[Container], targets) - if resource_offsets is not None: - if len(resource_offsets) != len(containers): - raise ValueError( - f"resource_offsets length must match containers, " - f"got {len(resource_offsets)} and {len(containers)}." - ) - offsets = resource_offsets - else: - offsets = [Coordinate.zero()] * len(containers) - x_pos, y_pos = compute_positions(containers, offsets, wrt_resource) - else: - containers = None - coordinates = cast(List[Coordinate], targets) - x_pos = [c.x for c in coordinates] - y_pos = [c.y for c in coordinates] - - # Normalize scalar spacing to per-channel list. max_ch = max(use_channels) - if isinstance(channel_spacings, (int, float)): - spacings: List[float] = [float(channel_spacings)] * (max_ch + 1) - else: - spacings = list(channel_spacings) - if len(spacings) < max_ch + 1: - spacings.extend([spacings[-1]] * (max_ch + 1 - len(spacings))) + if len(channel_spacings) < max_ch + 1: + raise ValueError( + f"channel_spacings list must have at least {max_ch + 1} entries " + f"(max channel index is {max_ch}), got {len(channel_spacings)}." + ) + + x_pos = [c.x for c in targets] + y_pos = [c.y for c in targets] + spacings = list(channel_spacings) # Group indices by X position. Sorts by X then merges adjacent positions # within tolerance into the same group, so positions like 99.99 and 100.01 @@ -467,30 +483,9 @@ def plan_batches( current_key = x_pos[i] x_groups.setdefault(current_key, []).append(i) - # When multiple channels target the same container, offset their Y positions - # so they can be batched together - adjusted_y = list(y_pos) - if containers is not None and resource_offsets is None: - for indices in x_groups.values(): - container_groups: Dict[int, List[int]] = defaultdict(list) - for idx in indices: - container_groups[id(containers[idx])].append(idx) - for c_indices in container_groups.values(): - if len(c_indices) < 2: - continue - group_channels = [use_channels[i] for i in c_indices] - spread = compute_single_container_offsets( - container=containers[c_indices[0]], - use_channels=group_channels, - channel_spacings=channel_spacings, - ) - if spread is not None: - for i, idx_val in enumerate(c_indices): - adjusted_y[idx_val] += spread[i].y - result: List[ChannelBatch] = [] for _, indices in sorted(x_groups.items()): group_x = x_pos[indices[0]] - result.extend(_partition_into_y_batches(indices, use_channels, adjusted_y, spacings, group_x)) + result.extend(_partition_into_y_batches(indices, use_channels, y_pos, spacings, group_x)) return result diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 010bd0fb457..8d3dcaeda56 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -1,8 +1,8 @@ """Tests for pipette_batch_scheduling module. -Tests cover functionality added or changed by this module vs the previous -planning.py: mixed channel spacing, phantom interpolation, container auto-spreading, -Coordinate targets, and compute_single_container_offsets. +Tests cover: mixed channel spacing, phantom interpolation, coordinate batching, +container-to-coordinate resolution (resolve_container_targets), auto-spreading, +and compute_single_container_offsets. """ import unittest @@ -13,6 +13,7 @@ _min_spacing_between, compute_single_container_offsets, plan_batches, + resolve_container_targets, ) from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate @@ -67,7 +68,7 @@ def test_mixed_phantoms_use_pairwise_spacing(self): class TestCoreBatching(unittest.TestCase): """Fundamental X grouping, Y batching, and validation.""" - S = 9.0 + S = [9.0] * 8 def test_spacing_boundary(self): # Exactly 9mm -> one batch @@ -106,6 +107,14 @@ def test_mismatched_lengths_raises(self): with self.assertRaises(ValueError): plan_batches([0, 1], _coords([100.0], [200.0]), self.S, x_tolerance=0.1) + def test_duplicate_channels_serialized(self): + batches = plan_batches([0, 0], _coords([100.0] * 2, [200.0] * 2), self.S, x_tolerance=0.1) + self.assertEqual(len(batches), 2) + + def test_duplicate_channels_three_ops(self): + batches = plan_batches([0, 0, 0], _coords([100.0] * 3, [200.0] * 3), self.S, x_tolerance=0.1) + self.assertEqual(len(batches), 3) + class TestPhantomInterpolation(unittest.TestCase): """Phantom channels between non-consecutive batch members.""" @@ -114,7 +123,7 @@ def test_phantoms_interpolated_at_spacing(self): batches = plan_batches( [0, 1, 2, 5, 6, 7], _coords([100.0] * 6, [300.0, 291.0, 282.0, 255.0, 246.0, 237.0]), - 9.0, + [9.0] * 8, x_tolerance=0.1, ) self.assertEqual(len(batches), 1) @@ -126,7 +135,7 @@ def test_phantoms_interpolated_at_spacing(self): def test_phantoms_only_within_batch(self): # Split into 2 batches — no phantoms across batches - batches = plan_batches([0, 3], _coords([100.0] * 2, [200.0, 250.0]), 9.0, x_tolerance=0.1) + batches = plan_batches([0, 3], _coords([100.0] * 2, [200.0, 250.0]), [9.0] * 4, x_tolerance=0.1) self.assertEqual(len(batches), 2) for batch in batches: self.assertEqual(len(batch.y_positions), 1) @@ -139,7 +148,7 @@ def test_coordinate_x_grouping_and_y_batching(self): batches = plan_batches( [0, 1, 2, 3], _coords([100.0, 100.0, 200.0, 200.0], [200.0, 200.0, 270.0, 261.0]), - 9.0, + [9.0] * 4, x_tolerance=0.1, ) x100 = [b for b in batches if abs(b.x_position - 100.0) < 0.01] @@ -150,7 +159,7 @@ def test_coordinate_x_grouping_and_y_batching(self): def test_indices_map_back_correctly(self): use_channels = [3, 7, 0] batches = plan_batches( - use_channels, _coords([100.0] * 3, [261.0, 237.0, 270.0]), 9.0, x_tolerance=0.1 + use_channels, _coords([100.0] * 3, [261.0, 237.0, 270.0]), [9.0] * 8, x_tolerance=0.1 ) all_indices = [idx for b in batches for idx in b.indices] self.assertEqual(sorted(all_indices), [0, 1, 2]) @@ -160,9 +169,9 @@ def test_indices_map_back_correctly(self): class TestContainerTargets(unittest.TestCase): - """plan_batches with Container targets and auto-spreading.""" + """resolve_container_targets + plan_batches with Container auto-spreading.""" - S = 9.0 + S = [9.0] * 8 def _mock_container(self, cx: float, cy: float, size_y: float = 10.0, name: str = "well"): c = MagicMock(spec=Container) @@ -179,7 +188,8 @@ def test_same_container_auto_spreads(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") deck = self._mock_deck() - batches = plan_batches([0, 1], [trough, trough], self.S, x_tolerance=0.1, wrt_resource=deck) + targets = resolve_container_targets([trough, trough], [0, 1], self.S, deck) + batches = plan_batches([0, 1], targets, self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) y = batches[0].y_positions self.assertAlmostEqual(y[0], 200.0 + 4.5) @@ -188,7 +198,8 @@ def test_same_container_auto_spreads(self, mock_offsets): def test_same_narrow_container_serialized(self): well = self._mock_container(100.0, 200.0, size_y=5.0, name="narrow_well") deck = self._mock_deck() - batches = plan_batches([0, 1], [well, well], self.S, x_tolerance=0.1, wrt_resource=deck) + targets = resolve_container_targets([well, well], [0, 1], self.S, deck) + batches = plan_batches([0, 1], targets, self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") @@ -196,15 +207,11 @@ def test_resource_offsets_skips_auto_spreading(self, mock_offsets): trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") deck = self._mock_deck() user_offsets = [Coordinate(0, 10.0, 0), Coordinate(0, -10.0, 0)] - batches = plan_batches( - [0, 1], - [trough, trough], - self.S, - x_tolerance=0.1, - wrt_resource=deck, - resource_offsets=user_offsets, + targets = resolve_container_targets( + [trough, trough], [0, 1], self.S, deck, resource_offsets=user_offsets ) mock_offsets.assert_not_called() + batches = plan_batches([0, 1], targets, self.S, x_tolerance=0.1) self.assertEqual(len(batches), 1) y = batches[0].y_positions self.assertAlmostEqual(y[0], 210.0) @@ -212,7 +219,7 @@ def test_resource_offsets_skips_auto_spreading(self, mock_offsets): class TestComputeSingleContainerOffsets(unittest.TestCase): - S = 9.0 + S = [9.0] * 8 def _mock_container(self, size_y: float): c = MagicMock(spec=["get_absolute_size_y"]) @@ -253,7 +260,7 @@ def test_non_consecutive_uses_full_physical_span(self, mock_offsets): assert result is not None self.assertEqual(len(result), 2) mock_offsets.assert_called_once_with( - resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[self.S] * 3 + resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[9.0] * 3 ) @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") From 6f8f7342936cc41f0e49daf6f82f213a37a783e1 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 9 Apr 2026 14:56:23 +0100 Subject: [PATCH 17/39] Remove obsolete odd-span center shift, inline `_offsets_for_consecutive_group`, tighten `channel_spacings` to `List[float]` --- .../pipette_batch_scheduling.py | 67 +++++++------------ .../pipette_batch_scheduling_tests.py | 6 +- 2 files changed, 27 insertions(+), 46 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 5bb4e851149..19e8e6f7560 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -1,11 +1,11 @@ -"""Pipette orchestration: partition channel–target pairs into executable batches. +"""Pipette orchestration: resolve container positions and partition into executable batches. Multi-channel liquid handlers have physical constraints (single X carriage, minimum Y spacing, descending Y order by channel index) that limit which channels can act simultaneously. - targets = resolve_container_targets(containers, use_channels, spacings, deck) - batches = plan_batches(use_channels, targets, spacings, x_tolerance=0.1) + targets = resolve_container_targets(containers, use_channels, channel_spacings, deck) + batches = plan_batches(use_channels, targets, channel_spacings, x_tolerance=0.1) await backend.execute_batched(func=my_z_callback, batches=batches) """ @@ -224,43 +224,8 @@ def _partition_into_y_batches( # --- Input validation and position computation --- -# Shift applied to odd channel spans to avoid center divider walls in troughs. -# Will be replaced by per-container no_go_zones (see docs/proposals/container_no_go_zones.md). -_ODD_SPAN_CENTER_AVOIDANCE = 5.5 # mm - - -def _offsets_for_consecutive_group( - container: Container, - use_channels: List[int], - spacing: float, -) -> Optional[List[Coordinate]]: - """Compute spread offsets for a group of channels whose full physical span fits the container.""" - ch_lo, ch_hi = min(use_channels), max(use_channels) - num_physical = ch_hi - ch_lo + 1 - min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing - - if container.get_absolute_size_y() < min_required: - return None - - all_offsets = compute_channel_offsets( - resource=container, - num_channels=num_physical, - spread="wide", - channel_spacings=[spacing] * num_physical, - ) - offsets = [all_offsets[ch - ch_lo] for ch in use_channels] - - # Shift odd channel spans to avoid center divider walls, but only if the - # outermost channel (center + half-spacing) stays within the container. - if num_physical > 1 and num_physical % 2 != 0: - max_offset_y = max(o.y for o in offsets) - container_half_y = container.get_absolute_size_y() / 2 - if max_offset_y + _ODD_SPAN_CENTER_AVOIDANCE + spacing / 2 <= container_half_y: - offsets = [o + Coordinate(0, _ODD_SPAN_CENTER_AVOIDANCE, 0) for o in offsets] - - return offsets - - +# TODO: eliminate once compute_channel_offsets supports use_channels directly +# (non-consecutive channel handling + sub-group fallback would move there). def compute_single_container_offsets( container: Container, use_channels: List[int], @@ -290,8 +255,23 @@ def compute_single_container_offsets( ) spacing = _effective_spacing(channel_spacings, ch_lo, ch_hi) + def _try_group(channels: List[int]) -> Optional[List[Coordinate]]: + """Try to fit channels into the container, returning None if too narrow.""" + g_lo, g_hi = min(channels), max(channels) + num_physical = g_hi - g_lo + 1 + min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing + if container.get_absolute_size_y() < min_required: + return None + all_offsets = compute_channel_offsets( + resource=container, + num_channels=num_physical, + spread="wide", + channel_spacings=[spacing] * num_physical, + ) + return [all_offsets[ch - g_lo] for ch in channels] + # Try the full span first (all channels including phantoms fit) - full = _offsets_for_consecutive_group(container, use_channels, spacing) + full = _try_group(use_channels) if full is not None: return full @@ -312,7 +292,7 @@ def compute_single_container_offsets( # Compute offsets per sub-group ch_to_offset: Dict[int, Coordinate] = {} for group in groups: - group_offsets = _offsets_for_consecutive_group(container, group, spacing) + group_offsets = _try_group(group) if group_offsets is None: return None # even a sub-group doesn't fit for ch, offset in zip(group, group_offsets): @@ -335,7 +315,8 @@ def validate_channel_selections( Validated list of channel indices. Raises: - ValueError: If channels are empty or out of range. + ValueError: If channels are empty, out of range, or if *containers* + and *use_channels* have different lengths. """ if use_channels is None: use_channels = list(range(len(containers))) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 8d3dcaeda56..8ace239359c 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -235,16 +235,16 @@ def test_even_span_no_center_offset(self, mock_offsets): self.assertAlmostEqual(result[1].y, -4.5) @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") - def test_odd_span_center_offset_when_wide_enough(self, mock_offsets): + def test_odd_span_passes_through_offsets(self, mock_offsets): mock_offsets.return_value = [ Coordinate(0, 9.0, 0), Coordinate(0, 0.0, 0), Coordinate(0, -9.0, 0), ] - # 50mm: max_offset=9.0, 9.0 + 5.5 + 4.5 = 19.0 <= 25.0 -> shift applied + # No additional shift; compute_channel_offsets handles no-go zones directly result = compute_single_container_offsets(self._mock_container(50.0), [0, 1, 2], self.S) assert result is not None - self.assertAlmostEqual(result[0].y, 9.0 + 5.5) + self.assertAlmostEqual(result[0].y, 9.0) def test_container_too_small_returns_none(self): self.assertIsNone(compute_single_container_offsets(self._mock_container(10.0), [0, 1], self.S)) From cd192fc5941d43ae303b90d3774d324926795911 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 9 Apr 2026 16:47:15 +0100 Subject: [PATCH 18/39] Route single-channel container offsets through compute_channel_offsets to respect no-go zones --- pylabrobot/liquid_handling/pipette_batch_scheduling.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 19e8e6f7560..e20d2425e7d 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -244,8 +244,6 @@ def compute_single_container_offsets( if len(use_channels) == 0: return [] - if len(use_channels) == 1: - return [Coordinate.zero()] ch_lo, ch_hi = min(use_channels), max(use_channels) if len(channel_spacings) < ch_hi + 1: From 6dcf72ea6aeb15aba9289ace55e4d2b5715f6d96 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 9 Apr 2026 23:13:03 +0100 Subject: [PATCH 19/39] Apply container offsets for single-channel targets, consolidate lld_mode dispatch --- .../liquid_handling/backends/hamilton/STAR_backend.py | 10 +++------- pylabrobot/liquid_handling/pipette_batch_scheduling.py | 2 -- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index b2ea274ab70..1bc64e4456a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2169,11 +2169,6 @@ async def probe_liquid_heights( if n_replicates < 1: raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") - if lld_mode not in {self.LLDMode.GAMMA, self.LLDMode.PRESSURE}: - raise ValueError( - f"lld_mode must be GAMMA (capacitive) or PRESSURE (pressure-based), got {lld_mode!r}" - ) - use_channels = validate_channel_selections( containers=containers, num_channels=self.num_channels, @@ -2219,12 +2214,13 @@ async def probe_liquid_heights( x_tolerance=x_grouping_tolerance, ) - # Select detection function and kwargs detect_func: Callable[..., Any] if lld_mode == self.LLDMode.GAMMA: detect_func = self._move_z_drive_to_liquid_surface_using_clld - else: + elif lld_mode == self.LLDMode.PRESSURE: detect_func = self._search_for_surface_using_plld + else: + raise ValueError(f"Unsupported lld_mode: {lld_mode!r}") async def _probe_batch_heights( batch: ChannelBatch, diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index e20d2425e7d..9eabec27f2d 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -386,8 +386,6 @@ def resolve_container_targets( for idx in range(len(containers)): container_groups[id(containers[idx])].append(idx) for c_indices in container_groups.values(): - if len(c_indices) < 2: - continue group_channels = [use_channels[i] for i in c_indices] spread = compute_single_container_offsets( container=containers[c_indices[0]], From f6cd56fb95204f60c3bdfc9994676519db415f8f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 9 Apr 2026 21:04:55 -0700 Subject: [PATCH 20/39] Guard _min_spacing_between against non-adjacent channel indices Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/liquid_handling/pipette_batch_scheduling.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 9eabec27f2d..64d6e72f846 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -117,6 +117,8 @@ def _min_spacing_between(spacings: List[float], i: int, j: int) -> float: Mirrors ``STARBackend._min_spacing_between`` (which operates on ``self._channels_minimum_y_spacing`` instead of an explicit list). """ + if not abs(i - j) == 1: + raise ValueError(f"Channels {i} and {j} are not adjacent.") return math.ceil(max(spacings[i], spacings[j]) * 10) / 10 From a19c32d7938ff32db5118a8d7b6668b0e38cdcba Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 10 Apr 2026 16:38:05 +0100 Subject: [PATCH 21/39] Scope `_effective_spacing` to sub-group in `compute_single_container_offsets` --- pylabrobot/liquid_handling/pipette_batch_scheduling.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 64d6e72f846..76f50400c57 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -247,17 +247,17 @@ def compute_single_container_offsets( if len(use_channels) == 0: return [] - ch_lo, ch_hi = min(use_channels), max(use_channels) + ch_hi = max(use_channels) if len(channel_spacings) < ch_hi + 1: raise ValueError( f"channel_spacings list must have at least {ch_hi + 1} entries " f"(max channel index is {ch_hi}), got {len(channel_spacings)}." ) - spacing = _effective_spacing(channel_spacings, ch_lo, ch_hi) def _try_group(channels: List[int]) -> Optional[List[Coordinate]]: """Try to fit channels into the container, returning None if too narrow.""" g_lo, g_hi = min(channels), max(channels) + spacing = _effective_spacing(channel_spacings, g_lo, g_hi) num_physical = g_hi - g_lo + 1 min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing if container.get_absolute_size_y() < min_required: From b2062014f9e0a6beffd0b09462c8fe67a53841f0 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 10 Apr 2026 16:54:52 +0100 Subject: [PATCH 22/39] Inline `_min_spacing_between` into `_span_required` --- .../pipette_batch_scheduling.py | 24 ++++--------------- .../pipette_batch_scheduling_tests.py | 6 ++--- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 76f50400c57..e3e021916de 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -102,24 +102,10 @@ def _effective_spacing(spacings: List[float], ch_lo: int, ch_hi: int) -> float: def _span_required(spacings: List[float], ch_lo: int, ch_hi: int) -> float: """Minimum total Y distance required between channels ch_lo and ch_hi. - Sums the rounded pairwise spacing for each adjacent pair in the range via - ``_min_spacing_between``, matching what the firmware enforces. + Sums the rounded pairwise spacing for each adjacent pair in the range, + matching what the firmware enforces. """ - return sum(_min_spacing_between(spacings, ch, ch + 1) for ch in range(ch_lo, ch_hi)) - - -def _min_spacing_between(spacings: List[float], i: int, j: int) -> float: - """Minimum Y spacing between adjacent channels *i* and *j*. - - Takes the larger of the two channels' spacings, then rounds up to 0.1 mm: - ``math.ceil(max(spacings[i], spacings[j]) * 10) / 10``. - - Mirrors ``STARBackend._min_spacing_between`` (which operates on - ``self._channels_minimum_y_spacing`` instead of an explicit list). - """ - if not abs(i - j) == 1: - raise ValueError(f"Channels {i} and {j} are not adjacent.") - return math.ceil(max(spacings[i], spacings[j]) * 10) / 10 + return sum(math.ceil(max(spacings[ch], spacings[ch + 1]) * 10) / 10 for ch in range(ch_lo, ch_hi)) # --- Batch partitioning --- @@ -164,11 +150,9 @@ def _interpolate_phantoms( sorted_chs = sorted(channels) for k in range(len(sorted_chs) - 1): ch_lo, ch_hi = sorted_chs[k], sorted_chs[k + 1] - cumulative = 0.0 for phantom in range(ch_lo + 1, ch_hi): - cumulative += _min_spacing_between(spacings, phantom - 1, phantom) if phantom not in result: - result[phantom] = result[ch_lo] - cumulative + result[phantom] = result[ch_lo] - _span_required(spacings, ch_lo, phantom) return result diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 8ace239359c..2e1cfc402c4 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch from pylabrobot.liquid_handling.pipette_batch_scheduling import ( - _min_spacing_between, + _span_required, compute_single_container_offsets, plan_batches, resolve_container_targets, @@ -32,9 +32,9 @@ class TestMixedChannelSpacing(unittest.TestCase): def test_pairwise_rounding(self): # max(8.98, 17.96) = 17.96 -> ceil(179.6)/10 = 18.0 - self.assertAlmostEqual(_min_spacing_between(self.SPACINGS, 1, 2), 18.0) + self.assertAlmostEqual(_span_required(self.SPACINGS, 1, 2), 18.0) # max(8.98, 8.98) = 8.98 -> ceil(89.8)/10 = 9.0 - self.assertAlmostEqual(_min_spacing_between(self.SPACINGS, 0, 1), 9.0) + self.assertAlmostEqual(_span_required(self.SPACINGS, 0, 1), 9.0) def test_mixed_spacing_boundary(self): # 18.0mm needed between ch1 (1mL) and ch2 (5mL) From 7307c8e0499a7a748e20af0d0093f72c98b4fa94 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 10 Apr 2026 17:05:58 +0100 Subject: [PATCH 23/39] =?UTF-8?q?Remove=20redundant=20lo=E2=86=92candidate?= =?UTF-8?q?=20span=20check=20from=20batch=20partitioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipette_batch_scheduling.py | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index e3e021916de..c3513a735b3 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -116,28 +116,10 @@ class _BatchAccumulator: """Mutable working state for a batch being built up during partitioning.""" indices: List[int] - lo_ch: int hi_ch: int - lo_y: float hi_y: float -def _channel_fits_batch( - batch: _BatchAccumulator, channel: int, y: float, spacings: List[float] -) -> bool: - """Check whether *channel* at *y* can be added to *batch* without violating spacing. - - Two checks suffice because channels are processed in ascending order, so the candidate - is always the new high end. The (lo → candidate) check covers the full span; the - (hi → candidate) check catches the local gap. - """ - if batch.hi_y - y < _span_required(spacings, batch.hi_ch, channel) - 1e-9: - return False - if batch.lo_y - y < _span_required(spacings, batch.lo_ch, channel) - 1e-9: - return False - return True - - def _interpolate_phantoms( channels: List[int], y_positions: Dict[int, float], spacings: List[float] ) -> Dict[int, float]: @@ -180,7 +162,7 @@ def _partition_into_y_batches( for batch in batches: if channel in [use_channels[i] for i in batch.indices]: continue - if _channel_fits_batch(batch, channel, y, spacings): + if batch.hi_y - y >= _span_required(spacings, batch.hi_ch, channel) - 1e-9: batch.indices.append(idx) batch.hi_ch = channel batch.hi_y = y @@ -188,7 +170,7 @@ def _partition_into_y_batches( break if not assigned: - batches.append(_BatchAccumulator(indices=[idx], lo_ch=channel, hi_ch=channel, lo_y=y, hi_y=y)) + batches.append(_BatchAccumulator(indices=[idx], hi_ch=channel, hi_y=y)) result: List[ChannelBatch] = [] for batch in batches: From 665fc368756b1304153aafd7af37f52f924c2901 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 10 Apr 2026 17:16:38 +0100 Subject: [PATCH 24/39] Move `compute_single_container_offsets` to channel_positioning --- .../liquid_handling/channel_positioning.py | 81 ++++++++++++++++- .../channel_positioning_tests.py | 61 +++++++++++++ .../pipette_batch_scheduling.py | 88 +------------------ .../pipette_batch_scheduling_tests.py | 67 +------------- 4 files changed, 145 insertions(+), 152 deletions(-) diff --git a/pylabrobot/liquid_handling/channel_positioning.py b/pylabrobot/liquid_handling/channel_positioning.py index 535dc8a8681..c2fc31bfacf 100644 --- a/pylabrobot/liquid_handling/channel_positioning.py +++ b/pylabrobot/liquid_handling/channel_positioning.py @@ -1,7 +1,7 @@ import logging import math import warnings -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from pylabrobot.liquid_handling.errors import ChannelsDoNotFitError from pylabrobot.resources.container import Container @@ -448,6 +448,85 @@ def compute_channel_offsets( return _centers_to_offsets(centers, resource) +# --------------------------------------------------------------------------- +# Non-consecutive channel offsets +# --------------------------------------------------------------------------- + + +def compute_single_container_offsets( + container: Container, + use_channels: List[int], + channel_spacings: List[float], +) -> Optional[List[Coordinate]]: + """Compute spread Y offsets for multiple channels targeting the same container. + + Accounts for the full physical span including phantom intermediate channels. + When the full span doesn't fit, splits active channels into consecutive + sub-groups at gaps in the channel sequence and computes offsets per sub-group. + Each sub-group gets centered spread offsets, so plan_batches will naturally + batch sub-groups that can't coexist into separate Y batches. + + Returns None if even a single pair of adjacent active channels can't fit. + """ + + if len(use_channels) == 0: + return [] + + ch_hi = max(use_channels) + if len(channel_spacings) < ch_hi + 1: + raise ValueError( + f"channel_spacings list must have at least {ch_hi + 1} entries " + f"(max channel index is {ch_hi}), got {len(channel_spacings)}." + ) + + def _try_group(channels: List[int]) -> Optional[List[Coordinate]]: + """Try to fit channels into the container, returning None if too narrow.""" + g_lo, g_hi = min(channels), max(channels) + spacing = max(channel_spacings[g_lo : g_hi + 1]) + num_physical = g_hi - g_lo + 1 + min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing + if container.get_absolute_size_y() < min_required: + return None + all_offsets = compute_channel_offsets( + resource=container, + num_channels=num_physical, + spread="wide", + channel_spacings=[spacing] * num_physical, + ) + return [all_offsets[ch - g_lo] for ch in channels] + + # Try the full span first (all channels including phantoms fit) + full = _try_group(use_channels) + if full is not None: + return full + + # Full span doesn't fit. Split at gaps in the sorted channel sequence + # into consecutive sub-groups and compute offsets for each independently. + sorted_chs = sorted(use_channels) + groups: List[List[int]] = [[sorted_chs[0]]] + for i in range(1, len(sorted_chs)): + if sorted_chs[i] == sorted_chs[i - 1] + 1: + groups[-1].append(sorted_chs[i]) + else: + groups.append([sorted_chs[i]]) + + # If there's only one consecutive group and it didn't fit above, container is too small + if len(groups) == 1: + return None + + # Compute offsets per sub-group + ch_to_offset: Dict[int, Coordinate] = {} + for group in groups: + group_offsets = _try_group(group) + if group_offsets is None: + return None # even a sub-group doesn't fit + for ch, offset in zip(group, group_offsets): + ch_to_offset[ch] = offset + + # Return in the original use_channels order + return [ch_to_offset[ch] for ch in use_channels] + + # --------------------------------------------------------------------------- # Deprecated wrappers (remove in v1) # --------------------------------------------------------------------------- diff --git a/pylabrobot/liquid_handling/channel_positioning_tests.py b/pylabrobot/liquid_handling/channel_positioning_tests.py index 78f9fa6612e..1cda6101592 100644 --- a/pylabrobot/liquid_handling/channel_positioning_tests.py +++ b/pylabrobot/liquid_handling/channel_positioning_tests.py @@ -1,4 +1,5 @@ import unittest +from unittest.mock import MagicMock, patch from pylabrobot.liquid_handling.channel_positioning import ( _centers_to_offsets, @@ -9,6 +10,7 @@ _resolve_channel_spacings, _space_needed, compute_channel_offsets, + compute_single_container_offsets, required_spacing_between, ) from pylabrobot.liquid_handling.errors import ChannelsDoNotFitError @@ -402,5 +404,64 @@ def test_wide_vs_tight_gap_difference(self): self.assertGreaterEqual(wide_gap, tight_gap - 0.01) +class TestComputeSingleContainerOffsets(unittest.TestCase): + S = [9.0] * 8 + + def _mock_container(self, size_y: float): + c = MagicMock(spec=["get_absolute_size_y"]) + c.get_absolute_size_y.return_value = size_y + return c + + @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") + def test_even_span_no_center_offset(self, mock_offsets): + mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] + result = compute_single_container_offsets(self._mock_container(50.0), [0, 1], self.S) + assert result is not None + self.assertAlmostEqual(result[0].y, 4.5) + self.assertAlmostEqual(result[1].y, -4.5) + + @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") + def test_odd_span_passes_through_offsets(self, mock_offsets): + mock_offsets.return_value = [ + Coordinate(0, 9.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -9.0, 0), + ] + # No additional shift; compute_channel_offsets handles no-go zones directly + result = compute_single_container_offsets(self._mock_container(50.0), [0, 1, 2], self.S) + assert result is not None + self.assertAlmostEqual(result[0].y, 9.0) + + def test_container_too_small_returns_none(self): + self.assertIsNone(compute_single_container_offsets(self._mock_container(10.0), [0, 1], self.S)) + + @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") + def test_non_consecutive_uses_full_physical_span(self, mock_offsets): + mock_offsets.return_value = [ + Coordinate(0, 10.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -10.0, 0), + ] + result = compute_single_container_offsets(self._mock_container(50.0), [0, 2], self.S) + assert result is not None + self.assertEqual(len(result), 2) + mock_offsets.assert_called_once_with( + resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[9.0] * 3 + ) + + @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") + def test_mixed_spacing_uses_effective(self, mock_offsets): + mock_offsets.return_value = [ + Coordinate(0, 18.0, 0), + Coordinate(0, 0.0, 0), + Coordinate(0, -18.0, 0), + ] + result = compute_single_container_offsets(self._mock_container(100.0), [0, 2], [9.0, 9.0, 18.0]) + self.assertIsNotNone(result) + mock_offsets.assert_called_once_with( + resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[18.0] * 3 + ) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index c3513a735b3..1ea3f944d06 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -15,8 +15,7 @@ from typing import Dict, List, Optional from pylabrobot.liquid_handling.channel_positioning import ( - MIN_SPACING_EDGE, - compute_channel_offsets, + compute_single_container_offsets, ) from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate @@ -90,15 +89,6 @@ def print_batches( # --- Spacing helpers --- -def _effective_spacing(spacings: List[float], ch_lo: int, ch_hi: int) -> float: - """Max of per-channel spacings across ch_lo..ch_hi (inclusive). - - Used by ``compute_single_container_offsets`` to determine a single uniform spacing - for spreading channels across a wide container. - """ - return max(spacings[ch_lo : ch_hi + 1]) - - def _span_required(spacings: List[float], ch_lo: int, ch_hi: int) -> float: """Minimum total Y distance required between channels ch_lo and ch_hi. @@ -192,82 +182,6 @@ def _partition_into_y_batches( # --- Input validation and position computation --- -# TODO: eliminate once compute_channel_offsets supports use_channels directly -# (non-consecutive channel handling + sub-group fallback would move there). -def compute_single_container_offsets( - container: Container, - use_channels: List[int], - channel_spacings: List[float], -) -> Optional[List[Coordinate]]: - """Compute spread Y offsets for multiple channels targeting the same container. - - Accounts for the full physical span including phantom intermediate channels. - When the full span doesn't fit, splits active channels into consecutive - sub-groups at gaps in the channel sequence and computes offsets per sub-group. - Each sub-group gets centered spread offsets, so plan_batches will naturally - batch sub-groups that can't coexist into separate Y batches. - - Returns None if even a single pair of adjacent active channels can't fit. - """ - - if len(use_channels) == 0: - return [] - - ch_hi = max(use_channels) - if len(channel_spacings) < ch_hi + 1: - raise ValueError( - f"channel_spacings list must have at least {ch_hi + 1} entries " - f"(max channel index is {ch_hi}), got {len(channel_spacings)}." - ) - - def _try_group(channels: List[int]) -> Optional[List[Coordinate]]: - """Try to fit channels into the container, returning None if too narrow.""" - g_lo, g_hi = min(channels), max(channels) - spacing = _effective_spacing(channel_spacings, g_lo, g_hi) - num_physical = g_hi - g_lo + 1 - min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing - if container.get_absolute_size_y() < min_required: - return None - all_offsets = compute_channel_offsets( - resource=container, - num_channels=num_physical, - spread="wide", - channel_spacings=[spacing] * num_physical, - ) - return [all_offsets[ch - g_lo] for ch in channels] - - # Try the full span first (all channels including phantoms fit) - full = _try_group(use_channels) - if full is not None: - return full - - # Full span doesn't fit. Split at gaps in the sorted channel sequence - # into consecutive sub-groups and compute offsets for each independently. - sorted_chs = sorted(use_channels) - groups: List[List[int]] = [[sorted_chs[0]]] - for i in range(1, len(sorted_chs)): - if sorted_chs[i] == sorted_chs[i - 1] + 1: - groups[-1].append(sorted_chs[i]) - else: - groups.append([sorted_chs[i]]) - - # If there's only one consecutive group and it didn't fit above, container is too small - if len(groups) == 1: - return None - - # Compute offsets per sub-group - ch_to_offset: Dict[int, Coordinate] = {} - for group in groups: - group_offsets = _try_group(group) - if group_offsets is None: - return None # even a sub-group doesn't fit - for ch, offset in zip(group, group_offsets): - ch_to_offset[ch] = offset - - # Return in the original use_channels order - return [ch_to_offset[ch] for ch in use_channels] - - def validate_channel_selections( containers: List[Container], num_channels: int, diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 2e1cfc402c4..89de9c89dfd 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -1,8 +1,7 @@ """Tests for pipette_batch_scheduling module. Tests cover: mixed channel spacing, phantom interpolation, coordinate batching, -container-to-coordinate resolution (resolve_container_targets), auto-spreading, -and compute_single_container_offsets. +and container-to-coordinate resolution (resolve_container_targets). """ import unittest @@ -11,7 +10,6 @@ from pylabrobot.liquid_handling.pipette_batch_scheduling import ( _span_required, - compute_single_container_offsets, plan_batches, resolve_container_targets, ) @@ -183,7 +181,7 @@ def _mock_container(self, cx: float, cy: float, size_y: float = 10.0, name: str def _mock_deck(self): return MagicMock(spec=Resource) - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") + @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") def test_same_container_auto_spreads(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") @@ -202,7 +200,7 @@ def test_same_narrow_container_serialized(self): batches = plan_batches([0, 1], targets, self.S, x_tolerance=0.1) self.assertEqual(len(batches), 2) - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") + @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") def test_resource_offsets_skips_auto_spreading(self, mock_offsets): trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") deck = self._mock_deck() @@ -218,64 +216,5 @@ def test_resource_offsets_skips_auto_spreading(self, mock_offsets): self.assertAlmostEqual(y[1], 190.0) -class TestComputeSingleContainerOffsets(unittest.TestCase): - S = [9.0] * 8 - - def _mock_container(self, size_y: float): - c = MagicMock(spec=["get_absolute_size_y"]) - c.get_absolute_size_y.return_value = size_y - return c - - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") - def test_even_span_no_center_offset(self, mock_offsets): - mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] - result = compute_single_container_offsets(self._mock_container(50.0), [0, 1], self.S) - assert result is not None - self.assertAlmostEqual(result[0].y, 4.5) - self.assertAlmostEqual(result[1].y, -4.5) - - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") - def test_odd_span_passes_through_offsets(self, mock_offsets): - mock_offsets.return_value = [ - Coordinate(0, 9.0, 0), - Coordinate(0, 0.0, 0), - Coordinate(0, -9.0, 0), - ] - # No additional shift; compute_channel_offsets handles no-go zones directly - result = compute_single_container_offsets(self._mock_container(50.0), [0, 1, 2], self.S) - assert result is not None - self.assertAlmostEqual(result[0].y, 9.0) - - def test_container_too_small_returns_none(self): - self.assertIsNone(compute_single_container_offsets(self._mock_container(10.0), [0, 1], self.S)) - - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") - def test_non_consecutive_uses_full_physical_span(self, mock_offsets): - mock_offsets.return_value = [ - Coordinate(0, 10.0, 0), - Coordinate(0, 0.0, 0), - Coordinate(0, -10.0, 0), - ] - result = compute_single_container_offsets(self._mock_container(50.0), [0, 2], self.S) - assert result is not None - self.assertEqual(len(result), 2) - mock_offsets.assert_called_once_with( - resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[9.0] * 3 - ) - - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling.compute_channel_offsets") - def test_mixed_spacing_uses_effective(self, mock_offsets): - mock_offsets.return_value = [ - Coordinate(0, 18.0, 0), - Coordinate(0, 0.0, 0), - Coordinate(0, -18.0, 0), - ] - result = compute_single_container_offsets(self._mock_container(100.0), [0, 2], [9.0, 9.0, 18.0]) - self.assertIsNotNone(result) - mock_offsets.assert_called_once_with( - resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[18.0] * 3 - ) - - if __name__ == "__main__": unittest.main() From b3e43e9f48ee25514ad1fa8f73fbe91244157760 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 10 Apr 2026 17:32:45 +0100 Subject: [PATCH 25/39] Rename to `compute_nonconsecutive_channel_offsets`, improve docstrings, handle no-go-zone fit failures --- .../liquid_handling/channel_positioning.py | 46 ++++++++++++------- .../channel_positioning_tests.py | 16 ++++--- .../pipette_batch_scheduling.py | 6 +-- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/pylabrobot/liquid_handling/channel_positioning.py b/pylabrobot/liquid_handling/channel_positioning.py index c2fc31bfacf..047ea7c48f0 100644 --- a/pylabrobot/liquid_handling/channel_positioning.py +++ b/pylabrobot/liquid_handling/channel_positioning.py @@ -298,10 +298,11 @@ def compute_channel_offsets( spread: str = "wide", channel_spacings: Optional[List[float]] = None, ) -> List[Coordinate]: - """Compute Y offsets for positioning pipette channels in a resource. + """Compute Y offsets for positioning consecutive channels (0..num_channels-1) in a resource. - Single entry point for all channel positioning logic. Handles containers with no-go zones - (distributing channels across compartments) and plain resources (wide/tight spread). + Handles container geometry: compartments with no-go zones, wide/tight spread. + Assumes channels are consecutively indexed — for non-consecutive channel selections + (e.g. [0, 2, 5]), use ``compute_nonconsecutive_channel_offsets`` instead. Args: resource: The target resource (Container, Trough, Well, etc.). @@ -453,20 +454,28 @@ def compute_channel_offsets( # --------------------------------------------------------------------------- -def compute_single_container_offsets( +def compute_nonconsecutive_channel_offsets( container: Container, use_channels: List[int], channel_spacings: List[float], ) -> Optional[List[Coordinate]]: - """Compute spread Y offsets for multiple channels targeting the same container. + """Compute Y offsets for non-consecutive channel selections targeting one container. - Accounts for the full physical span including phantom intermediate channels. - When the full span doesn't fit, splits active channels into consecutive - sub-groups at gaps in the channel sequence and computes offsets per sub-group. - Each sub-group gets centered spread offsets, so plan_batches will naturally - batch sub-groups that can't coexist into separate Y batches. + Wraps ``compute_channel_offsets``: fills in phantom channels for gaps in the + channel sequence (e.g. [0, 2, 5] → physical span 0..5), attempts to fit the + full span, and falls back to splitting into consecutive sub-groups when it + doesn't fit. - Returns None if even a single pair of adjacent active channels can't fit. + Args: + container: The target container. + use_channels: Channel indices being used (e.g. [0, 2, 5]). Need not be + consecutive — gaps are filled with phantom channels. + channel_spacings: Per-channel occupancy diameters in mm. Must have at least + ``max(use_channels) + 1`` entries. + + Returns: + List of Y offsets (one per entry in *use_channels*), or None if even a + single pair of adjacent channels can't fit in the container. """ if len(use_channels) == 0: @@ -487,12 +496,15 @@ def _try_group(channels: List[int]) -> Optional[List[Coordinate]]: min_required = MIN_SPACING_EDGE * 2 + (num_physical - 1) * spacing if container.get_absolute_size_y() < min_required: return None - all_offsets = compute_channel_offsets( - resource=container, - num_channels=num_physical, - spread="wide", - channel_spacings=[spacing] * num_physical, - ) + try: + all_offsets = compute_channel_offsets( + resource=container, + num_channels=num_physical, + spread="wide", + channel_spacings=[spacing] * num_physical, + ) + except (ChannelsDoNotFitError, ValueError): + return None return [all_offsets[ch - g_lo] for ch in channels] # Try the full span first (all channels including phantoms fit) diff --git a/pylabrobot/liquid_handling/channel_positioning_tests.py b/pylabrobot/liquid_handling/channel_positioning_tests.py index 1cda6101592..13e6ca7ba2a 100644 --- a/pylabrobot/liquid_handling/channel_positioning_tests.py +++ b/pylabrobot/liquid_handling/channel_positioning_tests.py @@ -10,7 +10,7 @@ _resolve_channel_spacings, _space_needed, compute_channel_offsets, - compute_single_container_offsets, + compute_nonconsecutive_channel_offsets, required_spacing_between, ) from pylabrobot.liquid_handling.errors import ChannelsDoNotFitError @@ -415,7 +415,7 @@ def _mock_container(self, size_y: float): @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") def test_even_span_no_center_offset(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] - result = compute_single_container_offsets(self._mock_container(50.0), [0, 1], self.S) + result = compute_nonconsecutive_channel_offsets(self._mock_container(50.0), [0, 1], self.S) assert result is not None self.assertAlmostEqual(result[0].y, 4.5) self.assertAlmostEqual(result[1].y, -4.5) @@ -428,12 +428,14 @@ def test_odd_span_passes_through_offsets(self, mock_offsets): Coordinate(0, -9.0, 0), ] # No additional shift; compute_channel_offsets handles no-go zones directly - result = compute_single_container_offsets(self._mock_container(50.0), [0, 1, 2], self.S) + result = compute_nonconsecutive_channel_offsets(self._mock_container(50.0), [0, 1, 2], self.S) assert result is not None self.assertAlmostEqual(result[0].y, 9.0) def test_container_too_small_returns_none(self): - self.assertIsNone(compute_single_container_offsets(self._mock_container(10.0), [0, 1], self.S)) + self.assertIsNone( + compute_nonconsecutive_channel_offsets(self._mock_container(10.0), [0, 1], self.S) + ) @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") def test_non_consecutive_uses_full_physical_span(self, mock_offsets): @@ -442,7 +444,7 @@ def test_non_consecutive_uses_full_physical_span(self, mock_offsets): Coordinate(0, 0.0, 0), Coordinate(0, -10.0, 0), ] - result = compute_single_container_offsets(self._mock_container(50.0), [0, 2], self.S) + result = compute_nonconsecutive_channel_offsets(self._mock_container(50.0), [0, 2], self.S) assert result is not None self.assertEqual(len(result), 2) mock_offsets.assert_called_once_with( @@ -456,7 +458,9 @@ def test_mixed_spacing_uses_effective(self, mock_offsets): Coordinate(0, 0.0, 0), Coordinate(0, -18.0, 0), ] - result = compute_single_container_offsets(self._mock_container(100.0), [0, 2], [9.0, 9.0, 18.0]) + result = compute_nonconsecutive_channel_offsets( + self._mock_container(100.0), [0, 2], [9.0, 9.0, 18.0] + ) self.assertIsNotNone(result) mock_offsets.assert_called_once_with( resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[18.0] * 3 diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 1ea3f944d06..265c12a7435 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -15,7 +15,7 @@ from typing import Dict, List, Optional from pylabrobot.liquid_handling.channel_positioning import ( - compute_single_container_offsets, + compute_nonconsecutive_channel_offsets, ) from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate @@ -224,7 +224,7 @@ def resolve_container_targets( """Convert containers to absolute Coordinates, auto-spreading when needed. When *resource_offsets* is ``None`` and multiple channels target the same - container, computes spread offsets via ``compute_single_container_offsets`` + container, computes spread offsets via ``compute_nonconsecutive_channel_offsets`` so channels can be batched in parallel. If the container is too narrow to spread, channels stay at center and will be serialized by ``plan_batches``. @@ -269,7 +269,7 @@ def resolve_container_targets( container_groups[id(containers[idx])].append(idx) for c_indices in container_groups.values(): group_channels = [use_channels[i] for i in c_indices] - spread = compute_single_container_offsets( + spread = compute_nonconsecutive_channel_offsets( container=containers[c_indices[0]], use_channels=group_channels, channel_spacings=channel_spacings, From c488da2fb228577969ac588048396d6291349f6a Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 10 Apr 2026 18:00:15 +0100 Subject: [PATCH 26/39] Simplify batch partitioning, inline into plan_batches, update stale docstrings --- .../backends/hamilton/STAR_backend.py | 5 +- .../backends/hamilton/STAR_chatterbox.py | 10 +- .../pipette_batch_scheduling.py | 100 ++++++------------ 3 files changed, 44 insertions(+), 71 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 1bc64e4456a..1b12d778c19 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1364,9 +1364,8 @@ def __init__( def _min_spacing_between(self, i: int, j: int) -> float: """Return the firmware-safe minimum Y spacing between channels *i* and *j*. - Uses max() of both channels' spacings for firmware safety (conservative). - For adjacent channels, ceiling-rounded to 0.1mm. - For non-adjacent channels, the sum of all intermediate adjacent-pair spacings. + For each adjacent pair, takes max() of both channels' spacings and ceiling-rounds + to 0.1mm. For non-adjacent channels, sums these per-pair spacings. TODO: migrate to radii model (spacing[i]/2 + spacing[j]/2) to match compute_channel_offsets. Current max() model is conservative but inconsistent diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 41238f8beae..4ffada2f020 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -348,14 +348,18 @@ async def probe_liquid_heights( ) -> List[float]: """Probe liquid heights by computing from tracked container volumes. - Instead of simulating hardware LLD, this mock computes liquid heights directly from - each container's volume tracker using ``container.compute_height_from_volume()``. + Instead of simulating hardware LLD, this mock computes liquid heights from + each container's volume tracker. Returns 0.0 for empty containers, otherwise + uses ``container.compute_height_from_volume()``. Args: containers: List of Container objects to probe, one per channel. use_channels: Channel indices to use (0-indexed). Defaults to ``[0, ..., len(containers)-1]``. resource_offsets: Passed to ``resolve_container_targets`` for auto-spreading. - All other parameters: Accepted for API compatibility but unused in mock. + x_grouping_tolerance: X tolerance for batch grouping (defaults to instrument setting). + lld_mode, search_speed, n_replicates, move_to_z_safety_after, + min_traverse_height_at_beginning_of_command, min_traverse_height_during_command, + z_position_at_end_of_command: Accepted for API compatibility but unused. Returns: Liquid heights in mm from cavity bottom for each container, computed from tracked volumes. diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 265c12a7435..d6c794c35af 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -4,7 +4,7 @@ Y spacing, descending Y order by channel index) that limit which channels can act simultaneously. - targets = resolve_container_targets(containers, use_channels, channel_spacings, deck) + targets = resolve_container_targets(containers, use_channels, channel_spacings, wrt_resource) batches = plan_batches(use_channels, targets, channel_spacings, x_tolerance=0.1) await backend.execute_batched(func=my_z_callback, batches=batches) """ @@ -101,21 +101,12 @@ def _span_required(spacings: List[float], ch_lo: int, ch_hi: int) -> float: # --- Batch partitioning --- -@dataclass -class _BatchAccumulator: - """Mutable working state for a batch being built up during partitioning.""" - - indices: List[int] - hi_ch: int - hi_y: float - - def _interpolate_phantoms( channels: List[int], y_positions: Dict[int, float], spacings: List[float] ) -> Dict[int, float]: """Return Y positions with phantom channels filled in between non-consecutive batch members. - Each phantom is placed at its actual pairwise spacing from the previous channel, + Each phantom is placed using the conservative max-spacing model (via ``_span_required``), so non-uniform spacings are respected (e.g. a wide channel only widens its own gaps). """ result = dict(y_positions) @@ -128,57 +119,6 @@ def _interpolate_phantoms( return result -def _partition_into_y_batches( - indices: List[int], - use_channels: List[int], - y_pos: List[float], - spacings: List[float], - x_position: float, -) -> List[ChannelBatch]: - """Partition channels within an X group into minimum parallel-compatible batches. - - Uses greedy first-fit: processes channels in ascending order and assigns each to - the first batch where it fits, or creates a new batch. - """ - - channels_by_index = sorted(indices, key=lambda i: use_channels[i]) - batches: List[_BatchAccumulator] = [] - - for idx in channels_by_index: - channel = use_channels[idx] - y = y_pos[idx] - - assigned = False - for batch in batches: - if channel in [use_channels[i] for i in batch.indices]: - continue - if batch.hi_y - y >= _span_required(spacings, batch.hi_ch, channel) - 1e-9: - batch.indices.append(idx) - batch.hi_ch = channel - batch.hi_y = y - assigned = True - break - - if not assigned: - batches.append(_BatchAccumulator(indices=[idx], hi_ch=channel, hi_y=y)) - - result: List[ChannelBatch] = [] - for batch in batches: - batch_channels = [use_channels[i] for i in batch.indices] - y_positions: Dict[int, float] = {use_channels[i]: y_pos[i] for i in batch.indices} - y_positions = _interpolate_phantoms(batch_channels, y_positions, spacings) - result.append( - ChannelBatch( - x_position=x_position, - indices=batch.indices, - channels=batch_channels, - y_positions=y_positions, - ) - ) - - return result - - # --- Input validation and position computation --- @@ -342,9 +282,39 @@ def plan_batches( current_key = x_pos[i] x_groups.setdefault(current_key, []).append(i) + # Within each X group, batch by Y position (greedy first-fit) result: List[ChannelBatch] = [] - for _, indices in sorted(x_groups.items()): - group_x = x_pos[indices[0]] - result.extend(_partition_into_y_batches(indices, use_channels, y_pos, spacings, group_x)) + for _, x_group_indices in sorted(x_groups.items()): + group_x = x_pos[x_group_indices[0]] + channels_by_index = sorted(x_group_indices, key=lambda i: use_channels[i]) + y_batches: List[List[int]] = [] + + for idx in channels_by_index: + channel = use_channels[idx] + y = y_pos[idx] + + for batch in y_batches: + if channel in [use_channels[i] for i in batch]: + continue + hi_ch = use_channels[batch[-1]] + hi_y = y_pos[batch[-1]] + if hi_y - y >= _span_required(spacings, hi_ch, channel) - 1e-9: + batch.append(idx) + break + else: + y_batches.append([idx]) + + for batch in y_batches: + batch_channels = [use_channels[i] for i in batch] + y_positions: Dict[int, float] = {use_channels[i]: y_pos[i] for i in batch} + y_positions = _interpolate_phantoms(batch_channels, y_positions, spacings) + result.append( + ChannelBatch( + x_position=group_x, + indices=batch, + channels=batch_channels, + y_positions=y_positions, + ) + ) return result From 1d100ff406ed9f0965a8bae3900de71b7afff985 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 10 Apr 2026 18:17:52 +0100 Subject: [PATCH 27/39] Reject duplicate channels in `probe_liquid_heights` but allow in general pipette scheduling Batch scheduling allows duplicate channels since `aspirate`/`dispense` will reuse the same channel across batches. `probe_liquid_heights` rejects them because each channel probes exactly one container per call. Callers needing more containers than channels should call `probe_liquid_heights` multiple times in sequence and this has to be explicit. --- .../liquid_handling/backends/hamilton/STAR_backend.py | 6 ++++++ .../liquid_handling/backends/hamilton/STAR_chatterbox.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 1b12d778c19..7ddf6d4f5c7 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2173,6 +2173,12 @@ async def probe_liquid_heights( num_channels=self.num_channels, use_channels=use_channels, ) + if len(use_channels) != len(set(use_channels)): + raise ValueError( + f"Duplicate channels in use_channels {use_channels}: each physical channel " + f"can only probe one container per call. To probe more containers than available " + f"channels, call probe_liquid_heights multiple times in sequence." + ) idle_channels = sorted(set(range(self.num_channels)) - set(use_channels)) # Verify tips and query tip lengths diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 4ffada2f020..35c5e229820 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -377,6 +377,12 @@ async def probe_liquid_heights( num_channels=self.num_channels, use_channels=use_channels, ) + if len(use_channels) != len(set(use_channels)): + raise ValueError( + f"Duplicate channels in use_channels {use_channels}: each physical channel " + f"can only probe one container per call. To probe more containers than available " + f"channels, call probe_liquid_heights multiple times in sequence." + ) # Validate tip presence using tip tracker for ch in use_channels: From 16a323fe50ed95c172a69762ce93d35be8ed400a Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 10 Apr 2026 18:33:06 +0100 Subject: [PATCH 28/39] Override `execute_batched` in chatterbox, restructure `probe_liquid_heights` around it Step toward evolving the chatterbox from a command echo layer into a simulator. The chatterbox `execute_batched` iterates batches and calls the callback without physical moves. `probe_liquid_heights` now flows through it with a mock LLD callback, running the same validation, target resolution, and batch planning as the real backend. Protocols developed off-hardware will encounter the same errors and batch structure as on the instrument. The `execute_batched` override will also serve future batched aspirate/dispense. --- .../backends/hamilton/STAR_chatterbox.py | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 35c5e229820..a217ed91620 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -332,6 +332,13 @@ async def position_channels_in_y_direction(self, ys, make_space=True): async def request_pip_height_last_lld(self): return list(range(12)) + async def execute_batched(self, func, batches, min_traverse_height_during_command=None): + """Iterate batches and call func without physical X/Y/Z moves.""" + results = [] + for batch in batches: + results.append(await func(batch)) + return results + async def probe_liquid_heights( self, containers: List[Container], @@ -348,9 +355,12 @@ async def probe_liquid_heights( ) -> List[float]: """Probe liquid heights by computing from tracked container volumes. - Instead of simulating hardware LLD, this mock computes liquid heights from - each container's volume tracker. Returns 0.0 for empty containers, otherwise - uses ``container.compute_height_from_volume()``. + Runs the same validation, target resolution, and batch planning as the real + backend so that protocols developed off-hardware encounter the same errors + and batch structure they would on the physical instrument. Only the LLD + callback is replaced: instead of firmware sensing, heights are computed from + each container's volume tracker (0.0 for empty, otherwise + ``container.compute_height_from_volume()``). Args: containers: List of Container objects to probe, one per channel. @@ -384,7 +394,6 @@ async def probe_liquid_heights( f"channels, call probe_liquid_heights multiple times in sequence." ) - # Validate tip presence using tip tracker for ch in use_channels: self.head[ch].get_tip() # Raises NoTipError if no tip @@ -404,15 +413,25 @@ async def probe_liquid_heights( print_batches(batches, use_channels, containers, label="probe_liquid_heights plan") - # Compute heights from volume trackers - heights: List[float] = [] - for container in containers: - volume = container.tracker.get_used_volume() - if volume == 0: - heights.append(0.0) - else: - height = container.compute_height_from_volume(volume) - heights.append(height) - - print(f" heights: {[f'{h:.2f}' for h in heights]} mm") - return heights + async def _mock_probe(batch): + heights = {} + for idx, ch in zip(batch.indices, batch.channels): + container = containers[idx] + volume = container.tracker.get_used_volume() + if volume == 0: + heights[ch] = 0.0 + else: + heights[ch] = container.compute_height_from_volume(volume) + return heights + + batch_results = await self.execute_batched( + func=_mock_probe, + batches=batches, + ) + + # Merge batch results in use_channels order + ch_to_height = {} + for batch_heights in batch_results: + ch_to_height.update(batch_heights) + + return [ch_to_height[ch] for ch in use_channels] From f47c31232b445457e18dd5e2e582cbb6aec6b974 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Sat, 11 Apr 2026 20:30:49 +0100 Subject: [PATCH 29/39] Extract `_prepare_batched` for reuse by future aspirate/dispense --- .../backends/hamilton/STAR_backend.py | 115 +++++++++++------- .../backends/hamilton/STAR_chatterbox.py | 46 ++----- 2 files changed, 81 insertions(+), 80 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 7ddf6d4f5c7..4128c06a97e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2107,6 +2107,70 @@ async def execute_batched( return results + async def _prepare_batched( + self, + containers: List[Container], + use_channels: Optional[List[int]] = None, + resource_offsets: Optional[List[Coordinate]] = None, + x_grouping_tolerance: Optional[float] = None, + min_traverse_height_at_beginning_of_command: Optional[float] = None, + ) -> Tuple[List[int], List[float], List[ChannelBatch]]: + """Validate channels, verify tips, position Z, resolve targets, plan batches. + + Shared setup for any batched channel operation (probing, aspirate, + dispense). Returns everything the caller needs to define its callback + and call ``execute_batched``. + + Returns: + (use_channels, tip_lengths, batches). + """ + if x_grouping_tolerance is None: + x_grouping_tolerance = self._x_grouping_tolerance_mm + + use_channels = validate_channel_selections( + containers=containers, + num_channels=self.num_channels, + use_channels=use_channels, + ) + + # Verify tips and query tip lengths + tip_presence = await self.request_tip_presence() + if not all(tip_presence[idx] for idx in use_channels): + raise RuntimeError("All specified channels must have tips attached.") + tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] + + # Z pre-positioning + idle_channels = sorted(set(range(self.num_channels)) - set(use_channels)) + if min_traverse_height_at_beginning_of_command is not None: + await asyncio.gather( + *[ + self.move_channel_stop_disk_z(channel_idx=ch_idx, z=self.MAXIMUM_CHANNEL_Z_POSITION) + for ch_idx in idle_channels + ] + ) + await self.position_channels_in_z_direction( + {ch: min_traverse_height_at_beginning_of_command for ch in use_channels} + ) + else: + await self.move_all_channels_in_z_safety() + + # Resolve targets, plan batches + targets = resolve_container_targets( + containers=containers, + use_channels=use_channels, + channel_spacings=self._channels_minimum_y_spacing, + wrt_resource=self.deck, + resource_offsets=resource_offsets, + ) + batches = plan_batches( + use_channels=use_channels, + targets=targets, + channel_spacings=self._channels_minimum_y_spacing, + x_tolerance=x_grouping_tolerance, + ) + + return use_channels, tip_lengths, batches + async def probe_liquid_heights( self, containers: List[Container], @@ -2163,62 +2227,20 @@ async def probe_liquid_heights( RuntimeError: If any specified channel lacks a tip. """ - if x_grouping_tolerance is None: - x_grouping_tolerance = self._x_grouping_tolerance_mm - if n_replicates < 1: raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") - use_channels = validate_channel_selections( - containers=containers, - num_channels=self.num_channels, - use_channels=use_channels, - ) - if len(use_channels) != len(set(use_channels)): + if use_channels is not None and len(use_channels) != len(set(use_channels)): raise ValueError( f"Duplicate channels in use_channels {use_channels}: each physical channel " f"can only probe one container per call. To probe more containers than available " f"channels, call probe_liquid_heights multiple times in sequence." ) - idle_channels = sorted(set(range(self.num_channels)) - set(use_channels)) - - # Verify tips and query tip lengths - tip_presence = await self.request_tip_presence() - if not all(tip_presence[idx] for idx in use_channels): - raise RuntimeError("All specified channels must have tips attached.") - tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] - - if min_traverse_height_at_beginning_of_command is not None: - await asyncio.gather( - *[ - self.move_channel_stop_disk_z(channel_idx=ch_idx, z=self.MAXIMUM_CHANNEL_Z_POSITION) - for ch_idx in idle_channels - ] - ) - await self.position_channels_in_z_direction( - {ch: min_traverse_height_at_beginning_of_command for ch in use_channels} - ) - else: - await self.move_all_channels_in_z_safety() z_cavity_bottom = [ r.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z for r in containers ] z_top = [r.get_location_wrt(self.deck, "c", "c", "t").z for r in containers] - targets = resolve_container_targets( - containers=containers, - use_channels=use_channels, - channel_spacings=self._channels_minimum_y_spacing, - wrt_resource=self.deck, - resource_offsets=resource_offsets, - ) - batches = plan_batches( - use_channels=use_channels, - targets=targets, - channel_spacings=self._channels_minimum_y_spacing, - x_tolerance=x_grouping_tolerance, - ) - detect_func: Callable[..., Any] if lld_mode == self.LLDMode.GAMMA: detect_func = self._move_z_drive_to_liquid_surface_using_clld @@ -2279,6 +2301,13 @@ async def _probe_batch_heights( return measurements + use_channels, tip_lengths, batches = await self._prepare_batched( + containers=containers, + use_channels=use_channels, + resource_offsets=resource_offsets, + x_grouping_tolerance=x_grouping_tolerance, + min_traverse_height_at_beginning_of_command=min_traverse_height_at_beginning_of_command, + ) batch_results = await self.execute_batched( func=_probe_batch_heights, batches=batches, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index a217ed91620..4b3ca26d333 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -12,12 +12,7 @@ MachineConfiguration, STARBackend, ) -from pylabrobot.liquid_handling.pipette_batch_scheduling import ( - plan_batches, - print_batches, - resolve_container_targets, - validate_channel_selections, -) +from pylabrobot.liquid_handling.pipette_batch_scheduling import print_batches from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.well import Well @@ -379,40 +374,13 @@ async def probe_liquid_heights( ``containers`` and ``use_channels`` have different lengths. NoTipError: If any specified channel lacks a tip. """ - if x_grouping_tolerance is None: - x_grouping_tolerance = self._x_grouping_tolerance_mm - - use_channels = validate_channel_selections( - containers=containers, - num_channels=self.num_channels, - use_channels=use_channels, - ) - if len(use_channels) != len(set(use_channels)): + if use_channels is not None and len(use_channels) != len(set(use_channels)): raise ValueError( f"Duplicate channels in use_channels {use_channels}: each physical channel " f"can only probe one container per call. To probe more containers than available " f"channels, call probe_liquid_heights multiple times in sequence." ) - for ch in use_channels: - self.head[ch].get_tip() # Raises NoTipError if no tip - - targets = resolve_container_targets( - containers=containers, - use_channels=use_channels, - channel_spacings=self._channels_minimum_y_spacing, - wrt_resource=self.deck, - resource_offsets=resource_offsets, - ) - batches = plan_batches( - use_channels=use_channels, - targets=targets, - channel_spacings=self._channels_minimum_y_spacing, - x_tolerance=x_grouping_tolerance, - ) - - print_batches(batches, use_channels, containers, label="probe_liquid_heights plan") - async def _mock_probe(batch): heights = {} for idx, ch in zip(batch.indices, batch.channels): @@ -424,10 +392,14 @@ async def _mock_probe(batch): heights[ch] = container.compute_height_from_volume(volume) return heights - batch_results = await self.execute_batched( - func=_mock_probe, - batches=batches, + use_channels, _, batches = await self._prepare_batched( + containers=containers, + use_channels=use_channels, + resource_offsets=resource_offsets, + x_grouping_tolerance=x_grouping_tolerance, ) + print_batches(batches, use_channels, containers, label="probe_liquid_heights plan") + batch_results = await self.execute_batched(func=_mock_probe, batches=batches) # Merge batch results in use_channels order ch_to_height = {} From 25276b8fa66f3e3cf68ff7327962d676bbf43e0d Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 13 Apr 2026 14:31:55 +0100 Subject: [PATCH 30/39] Promote per-batch LLD callback to override hook, collapse chatterbox Extract `_run_lld_on_channel_batch` from the probe_liquid_heights closure to an instance method so chatterbox overrides only the sensing step instead of the entire function. Drops ~85 lines of chatterbox duplication (full probe_liquid_heights and redundant execute_batched override) - validation, target resolution, batch planning, merge, and Z-safety now have a single source of truth. Adds `verbose: bool = False` to `execute_batched`/`probe_liquid_heights` for optional plan printing, makes `print_batches`'s `use_channels`/`containers` parameters optional, and promotes `lld_mode` validation to fail-fast before any I/O. --- .../backends/hamilton/STAR_backend.py | 153 +++++++++++------- .../backends/hamilton/STAR_chatterbox.py | 103 +++--------- .../pipette_batch_scheduling.py | 16 +- 3 files changed, 127 insertions(+), 145 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 4128c06a97e..49d17560814 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -50,6 +50,7 @@ from pylabrobot.liquid_handling.pipette_batch_scheduling import ( ChannelBatch, plan_batches, + print_batches, resolve_container_targets, validate_channel_selections, ) @@ -2062,6 +2063,7 @@ async def execute_batched( func: Callable[[ChannelBatch], Awaitable[T]], batches: List[ChannelBatch], min_traverse_height_during_command: Optional[float] = None, + verbose: bool = False, ) -> List[T]: """Execute a Z-axis callback across pre-planned batches with X/Y positioning. @@ -2075,10 +2077,13 @@ async def execute_batched( batches: Pre-planned batches from ``plan_batches()``. min_traverse_height_during_command: Absolute Z height (mm) for inter-batch channel raises. ``None`` uses full Z safety. + verbose: If True, print the batch execution plan before running. Returns: List of results from each batch callback, in batch order. """ + if verbose: + print_batches(batches, label="execute_batched plan") results: List[T] = [] try: prev_batch: Optional[ChannelBatch] = None @@ -2171,6 +2176,77 @@ async def _prepare_batched( return use_channels, tip_lengths, batches + async def _run_lld_on_channel_batch( + self, + batch: ChannelBatch, + containers: List[Container], + tip_lengths: List[float], + z_cavity_bottom: List[float], + z_top: List[float], + lld_mode: LLDMode, + search_speed: float, + n_replicates: int, + ) -> Dict[int, List[Optional[float]]]: + """Per-batch liquid level detection. Override to substitute simulated sensing. + + Returns absolute heights (one list per channel, ``None`` entries for replicates + where no liquid was detected). The caller subtracts ``z_cavity_bottom`` to get + heights relative to container bottom. + """ + detect_func: Callable[..., Any] = ( + self._move_z_drive_to_liquid_surface_using_clld + if lld_mode == self.LLDMode.GAMMA + else self._search_for_surface_using_plld + ) + batch_lowest_immers = [ + z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH for i in batch.indices + ] + batch_start_pos = [ + z_top[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH + self.SEARCH_START_CLEARANCE_MM + for i in batch.indices + ] + + measurements: Dict[int, List[Optional[float]]] = {ch: [] for ch in batch.channels} + + for _ in range(n_replicates): + results = await asyncio.gather( + *[ + detect_func( + channel_idx=channel, + lowest_immers_pos=lip, + start_pos_search=sps, + channel_speed=search_speed, + ) + for channel, lip, sps in zip(batch.channels, batch_lowest_immers, batch_start_pos) + ], + return_exceptions=True, + ) + + current_absolute_liquid_heights = await self.request_pip_height_last_lld() + for local_idx, (ch_idx, result) in enumerate(zip(batch.channels, results)): + orig_idx = batch.indices[local_idx] + if isinstance(result, STARFirmwareError): + error_msg = str(result).lower() + if "no liquid level found" in error_msg or "no liquid was present" in error_msg: + height = None + msg = ( + f"Channel {ch_idx}: No liquid detected. Could be because there is " + f"no liquid in container {containers[orig_idx].name} or liquid level " + f"is too low." + ) + if lld_mode == self.LLDMode.GAMMA: + msg += " Consider using pressure-based LLD if liquid is believed to exist." + logger.warning(msg) + else: + raise result + elif isinstance(result, Exception): + raise result + else: + height = current_absolute_liquid_heights[ch_idx] + measurements[ch_idx].append(height) + + return measurements + async def probe_liquid_heights( self, containers: List[Container], @@ -2185,6 +2261,7 @@ async def probe_liquid_heights( min_traverse_height_during_command: Optional[float] = None, z_position_at_end_of_command: Optional[float] = None, x_grouping_tolerance: Optional[float] = None, + verbose: bool = False, ) -> List[float]: """Probe liquid surface heights in containers using liquid level detection. @@ -2217,6 +2294,7 @@ async def probe_liquid_heights( probing. None (default) uses full Z safety. x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). + verbose: If True, print the batch execution plan before running. Returns: Mean of measured liquid heights for each container (mm from cavity bottom). @@ -2236,71 +2314,14 @@ async def probe_liquid_heights( f"channels, call probe_liquid_heights multiple times in sequence." ) + if lld_mode not in (self.LLDMode.GAMMA, self.LLDMode.PRESSURE): + raise ValueError(f"Unsupported lld_mode: {lld_mode!r}") + z_cavity_bottom = [ r.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z for r in containers ] z_top = [r.get_location_wrt(self.deck, "c", "c", "t").z for r in containers] - detect_func: Callable[..., Any] - if lld_mode == self.LLDMode.GAMMA: - detect_func = self._move_z_drive_to_liquid_surface_using_clld - elif lld_mode == self.LLDMode.PRESSURE: - detect_func = self._search_for_surface_using_plld - else: - raise ValueError(f"Unsupported lld_mode: {lld_mode!r}") - - async def _probe_batch_heights( - batch: ChannelBatch, - ) -> Dict[int, List[Optional[float]]]: - batch_lowest_immers = [ - z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH for i in batch.indices - ] - batch_start_pos = [ - z_top[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH + self.SEARCH_START_CLEARANCE_MM - for i in batch.indices - ] - - measurements: Dict[int, List[Optional[float]]] = {ch: [] for ch in batch.channels} - - for _ in range(n_replicates): - results = await asyncio.gather( - *[ - detect_func( - channel_idx=channel, - lowest_immers_pos=lip, - start_pos_search=sps, - channel_speed=search_speed, - ) - for channel, lip, sps in zip(batch.channels, batch_lowest_immers, batch_start_pos) - ], - return_exceptions=True, - ) - - current_absolute_liquid_heights = await self.request_pip_height_last_lld() - for local_idx, (ch_idx, result) in enumerate(zip(batch.channels, results)): - orig_idx = batch.indices[local_idx] - if isinstance(result, STARFirmwareError): - error_msg = str(result).lower() - if "no liquid level found" in error_msg or "no liquid was present" in error_msg: - height = None - msg = ( - f"Channel {ch_idx}: No liquid detected. Could be because there is " - f"no liquid in container {containers[orig_idx].name} or liquid level " - f"is too low." - ) - if lld_mode == self.LLDMode.GAMMA: - msg += " Consider using pressure-based LLD if liquid is believed to exist." - logger.warning(msg) - else: - raise result - elif isinstance(result, Exception): - raise result - else: - height = current_absolute_liquid_heights[ch_idx] - measurements[ch_idx].append(height) - - return measurements - use_channels, tip_lengths, batches = await self._prepare_batched( containers=containers, use_channels=use_channels, @@ -2309,9 +2330,19 @@ async def _probe_batch_heights( min_traverse_height_at_beginning_of_command=min_traverse_height_at_beginning_of_command, ) batch_results = await self.execute_batched( - func=_probe_batch_heights, + func=lambda b: self._run_lld_on_channel_batch( + batch=b, + containers=containers, + tip_lengths=tip_lengths, + z_cavity_bottom=z_cavity_bottom, + z_top=z_top, + lld_mode=lld_mode, + search_speed=search_speed, + n_replicates=n_replicates, + ), batches=batches, min_traverse_height_during_command=min_traverse_height_during_command, + verbose=verbose, ) absolute_heights_measurements: Dict[int, List[Optional[float]]] = {} diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 4b3ca26d333..99e994bb9a7 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -12,9 +12,7 @@ MachineConfiguration, STARBackend, ) -from pylabrobot.liquid_handling.pipette_batch_scheduling import print_batches from pylabrobot.resources.container import Container -from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.well import Well # Type alias for nested enum (for cleaner signatures) @@ -327,83 +325,30 @@ async def position_channels_in_y_direction(self, ys, make_space=True): async def request_pip_height_last_lld(self): return list(range(12)) - async def execute_batched(self, func, batches, min_traverse_height_during_command=None): - """Iterate batches and call func without physical X/Y/Z moves.""" - results = [] - for batch in batches: - results.append(await func(batch)) - return results - - async def probe_liquid_heights( + async def _run_lld_on_channel_batch( self, + batch, containers: List[Container], - use_channels: Optional[List[int]] = None, - resource_offsets: Optional[List[Coordinate]] = None, - lld_mode: LLDMode = LLDMode.GAMMA, - search_speed: float = 10.0, - n_replicates: int = 1, - move_to_z_safety_after: bool = True, - min_traverse_height_at_beginning_of_command: Optional[float] = None, - min_traverse_height_during_command: Optional[float] = None, - z_position_at_end_of_command: Optional[float] = None, - x_grouping_tolerance: Optional[float] = None, - ) -> List[float]: - """Probe liquid heights by computing from tracked container volumes. - - Runs the same validation, target resolution, and batch planning as the real - backend so that protocols developed off-hardware encounter the same errors - and batch structure they would on the physical instrument. Only the LLD - callback is replaced: instead of firmware sensing, heights are computed from - each container's volume tracker (0.0 for empty, otherwise - ``container.compute_height_from_volume()``). - - Args: - containers: List of Container objects to probe, one per channel. - use_channels: Channel indices to use (0-indexed). Defaults to ``[0, ..., len(containers)-1]``. - resource_offsets: Passed to ``resolve_container_targets`` for auto-spreading. - x_grouping_tolerance: X tolerance for batch grouping (defaults to instrument setting). - lld_mode, search_speed, n_replicates, move_to_z_safety_after, - min_traverse_height_at_beginning_of_command, min_traverse_height_during_command, - z_position_at_end_of_command: Accepted for API compatibility but unused. - - Returns: - Liquid heights in mm from cavity bottom for each container, computed from tracked volumes. - - Raises: - ValueError: If ``use_channels`` is empty, contains out-of-range indices, or if - ``containers`` and ``use_channels`` have different lengths. - NoTipError: If any specified channel lacks a tip. + tip_lengths: List[float], + z_cavity_bottom: List[float], + z_top: List[float], + lld_mode: LLDMode, + search_speed: float, + n_replicates: int, + ) -> Dict[int, List[Optional[float]]]: + """Simulate LLD by computing absolute heights from each container's volume tracker. + + Empty containers report the cavity-bottom Z (relative height 0). Non-empty + containers report ``cavity_bottom + compute_height_from_volume(volume)`` so the + parent ``probe_liquid_heights`` can subtract ``z_cavity_bottom`` consistently. """ - if use_channels is not None and len(use_channels) != len(set(use_channels)): - raise ValueError( - f"Duplicate channels in use_channels {use_channels}: each physical channel " - f"can only probe one container per call. To probe more containers than available " - f"channels, call probe_liquid_heights multiple times in sequence." - ) - - async def _mock_probe(batch): - heights = {} - for idx, ch in zip(batch.indices, batch.channels): - container = containers[idx] - volume = container.tracker.get_used_volume() - if volume == 0: - heights[ch] = 0.0 - else: - heights[ch] = container.compute_height_from_volume(volume) - return heights - - use_channels, _, batches = await self._prepare_batched( - containers=containers, - use_channels=use_channels, - resource_offsets=resource_offsets, - x_grouping_tolerance=x_grouping_tolerance, - ) - print_batches(batches, use_channels, containers, label="probe_liquid_heights plan") - batch_results = await self.execute_batched(func=_mock_probe, batches=batches) - - # Merge batch results in use_channels order - ch_to_height = {} - for batch_heights in batch_results: - ch_to_height.update(batch_heights) - - return [ch_to_height[ch] for ch in use_channels] + measurements: Dict[int, List[Optional[float]]] = {ch: [] for ch in batch.channels} + for local_idx, (ch, orig_idx) in enumerate(zip(batch.channels, batch.indices)): + container = containers[orig_idx] + volume = container.tracker.get_used_volume() + if volume == 0: + absolute_height = z_cavity_bottom[orig_idx] + else: + absolute_height = z_cavity_bottom[orig_idx] + container.compute_height_from_volume(volume) + measurements[ch] = [absolute_height] * n_replicates + return measurements diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index d6c794c35af..1800bf06a90 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -40,8 +40,8 @@ class ChannelBatch: def print_batches( batches: List[ChannelBatch], - use_channels: List[int], - containers: List["Container"], + use_channels: Optional[List[int]] = None, + containers: Optional[List["Container"]] = None, label: str = "plan", ) -> None: """Print a tree view of the batch execution plan. @@ -51,12 +51,18 @@ def print_batches( Args: batches: Output from ``plan_batches()``. - use_channels: Channel indices (parallel with *containers*). - containers: Container objects (parallel with *use_channels*). + use_channels: Channel indices (parallel with *containers*). If omitted, + container names are not shown next to active channels. + containers: Container objects (parallel with *use_channels*). If omitted, + container names are not shown next to active channels. label: Header label for the tree. """ - ch_to_container = dict(zip(use_channels, containers)) + ch_to_container = ( + dict(zip(use_channels, containers)) + if use_channels is not None and containers is not None + else {} + ) x_groups: Dict[float, list] = {} for b in batches: From 54daf5c782fab4c9435d4ce8450653e3f359c6a3 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 13 Apr 2026 15:02:24 +0100 Subject: [PATCH 31/39] Accept per-container `LLDMode` list in `probe_liquid_heights` Allows mixing GAMMA and PRESSURE LLD across containers in a single call (e.g. capacitive for aqueous, pressure for organic) by widening `lld_mode` to `Union[LLDMode, List[LLDMode], None]` and dispatching `detect_func` per channel within each batch. Matches the singular-name-list-type convention of aspirate/dispense. Backward-compatible: scalar values still work with a `DeprecationWarning` (remove in v1b1); None default silently broadcasts to GAMMA. --- .../backends/hamilton/STAR_backend.py | 59 ++++++++++++++----- .../backends/hamilton/STAR_chatterbox.py | 2 +- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 49d17560814..31f5a801e54 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2183,21 +2183,27 @@ async def _run_lld_on_channel_batch( tip_lengths: List[float], z_cavity_bottom: List[float], z_top: List[float], - lld_mode: LLDMode, + lld_mode: List[LLDMode], search_speed: float, n_replicates: int, ) -> Dict[int, List[Optional[float]]]: """Per-batch liquid level detection. Override to substitute simulated sensing. + *lld_mode* is indexed by original container position (``batch.indices[i]``), + so channels within a single batch may use different detection modes concurrently. + Returns absolute heights (one list per channel, ``None`` entries for replicates where no liquid was detected). The caller subtracts ``z_cavity_bottom`` to get heights relative to container bottom. """ - detect_func: Callable[..., Any] = ( - self._move_z_drive_to_liquid_surface_using_clld - if lld_mode == self.LLDMode.GAMMA - else self._search_for_surface_using_plld - ) + + def _detect_func(mode: "STARBackend.LLDMode") -> Callable[..., Any]: + return ( + self._move_z_drive_to_liquid_surface_using_clld + if mode == self.LLDMode.GAMMA + else self._search_for_surface_using_plld + ) + batch_lowest_immers = [ z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH for i in batch.indices ] @@ -2211,13 +2217,15 @@ async def _run_lld_on_channel_batch( for _ in range(n_replicates): results = await asyncio.gather( *[ - detect_func( + _detect_func(lld_mode[orig_idx])( channel_idx=channel, lowest_immers_pos=lip, start_pos_search=sps, channel_speed=search_speed, ) - for channel, lip, sps in zip(batch.channels, batch_lowest_immers, batch_start_pos) + for channel, lip, sps, orig_idx in zip( + batch.channels, batch_lowest_immers, batch_start_pos, batch.indices + ) ], return_exceptions=True, ) @@ -2234,7 +2242,7 @@ async def _run_lld_on_channel_batch( f"no liquid in container {containers[orig_idx].name} or liquid level " f"is too low." ) - if lld_mode == self.LLDMode.GAMMA: + if lld_mode[orig_idx] == self.LLDMode.GAMMA: msg += " Consider using pressure-based LLD if liquid is believed to exist." logger.warning(msg) else: @@ -2252,7 +2260,7 @@ async def probe_liquid_heights( containers: List[Container], use_channels: Optional[List[int]] = None, resource_offsets: Optional[List[Coordinate]] = None, - lld_mode: LLDMode = LLDMode.GAMMA, + lld_mode: Union[LLDMode, List[LLDMode], None] = None, search_speed: float = 10.0, n_replicates: int = 1, move_to_z_safety_after: bool = True, @@ -2278,8 +2286,10 @@ async def probe_liquid_heights( use_channels: Channel indices to use for probing (0-indexed). resource_offsets: Optional XYZ offsets from container centers. When not provided, ``resolve_container_targets`` auto-spreads channels targeting the same container. - lld_mode: Detection mode - GAMMA for capacitive, PRESSURE for pressure-based. - Defaults to GAMMA. + lld_mode: Detection mode. Either a single ``LLDMode`` applied to all containers + (deprecated, removed in v1b1) or a list of ``LLDMode``s (one per container) + allowing mixed GAMMA/PRESSURE within one call. ``None`` (default) applies + GAMMA to all containers. search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. n_replicates: Number of measurements per channel. Default 1. move_to_z_safety_after: Whether to move channels to safe Z height after probing. @@ -2314,8 +2324,29 @@ async def probe_liquid_heights( f"channels, call probe_liquid_heights multiple times in sequence." ) - if lld_mode not in (self.LLDMode.GAMMA, self.LLDMode.PRESSURE): - raise ValueError(f"Unsupported lld_mode: {lld_mode!r}") + if lld_mode is None: + lld_mode = [self.LLDMode.GAMMA] * len(containers) + elif isinstance(lld_mode, self.LLDMode): + warnings.warn( + "Passing a single LLDMode to probe_liquid_heights is deprecated and will be " + "removed in v1b1. Pass a list of LLDModes (one per container) instead.", + DeprecationWarning, + stacklevel=2, + ) + lld_mode = [lld_mode] * len(containers) + elif not isinstance(lld_mode, list): + raise TypeError( + f"lld_mode must be List[LLDMode], got {type(lld_mode).__name__}" + ) + + if len(lld_mode) != len(containers): + raise ValueError( + f"lld_mode list length must match containers: got {len(lld_mode)} LLD modes " + f"for {len(containers)} containers." + ) + for m in lld_mode: + if m not in (self.LLDMode.GAMMA, self.LLDMode.PRESSURE): + raise ValueError(f"Unsupported lld_mode: {m!r}") z_cavity_bottom = [ r.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z for r in containers diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 99e994bb9a7..edc137c78c8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -332,7 +332,7 @@ async def _run_lld_on_channel_batch( tip_lengths: List[float], z_cavity_bottom: List[float], z_top: List[float], - lld_mode: LLDMode, + lld_mode: List[LLDMode], search_speed: float, n_replicates: int, ) -> Dict[int, List[Optional[float]]]: From b79b41af33db894a53b2dfec69f919dc280b5d29 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 13 Apr 2026 17:59:59 +0100 Subject: [PATCH 32/39] `make format` --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 31f5a801e54..bbcdd5942ac 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2335,9 +2335,7 @@ async def probe_liquid_heights( ) lld_mode = [lld_mode] * len(containers) elif not isinstance(lld_mode, list): - raise TypeError( - f"lld_mode must be List[LLDMode], got {type(lld_mode).__name__}" - ) + raise TypeError(f"lld_mode must be List[LLDMode], got {type(lld_mode).__name__}") if len(lld_mode) != len(containers): raise ValueError( From aa8bdfaa90b88057bd5700b0b7aab2ff366083fd Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 13 Apr 2026 17:24:59 -0700 Subject: [PATCH 33/39] Replace greedy first-fit batching with container-aware exact-cover planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plan_batches` now takes containers + wrt_resource directly (instead of pre-resolved Coordinates) and decides per-batch container spread via `compute_nonconsecutive_channel_offsets`, so subsets that fit in a no-go-zone-divided container can batch even when the full set can't. Pipeline: `is_valid_batch` → `enumerate_valid_batches` → `minimum_exact_cover` → `plan_batches`. `ChannelBatch` is the currency throughout; partition is optimal (branch-and-bound), greedy-only on realistic inputs but a strict win on any container that fits K but not K+1 channels. Removes the prior two-step `resolve_container_targets` + greedy `plan_batches` pipeline and updates STAR's `_prepare_batched` to the single call. --- .../backends/hamilton/STAR_backend.py | 22 +- .../pipette_batch_scheduling.py | 328 +++++++++------ .../pipette_batch_scheduling_tests.py | 396 ++++++++++++------ 3 files changed, 480 insertions(+), 266 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index bbcdd5942ac..5ab10a1f36d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -51,7 +51,6 @@ ChannelBatch, plan_batches, print_batches, - resolve_container_targets, validate_channel_selections, ) from pylabrobot.liquid_handling.standard import ( @@ -2159,19 +2158,14 @@ async def _prepare_batched( else: await self.move_all_channels_in_z_safety() - # Resolve targets, plan batches - targets = resolve_container_targets( - containers=containers, - use_channels=use_channels, - channel_spacings=self._channels_minimum_y_spacing, - wrt_resource=self.deck, - resource_offsets=resource_offsets, - ) + # Plan batches directly from containers (per-batch spread, no-go-zone aware). batches = plan_batches( use_channels=use_channels, - targets=targets, + containers=containers, channel_spacings=self._channels_minimum_y_spacing, + wrt_resource=self.deck, x_tolerance=x_grouping_tolerance, + resource_offsets=resource_offsets, ) return use_channels, tip_lengths, batches @@ -2277,15 +2271,15 @@ async def probe_liquid_heights( container positions and sensing the liquid surface. Heights are measured from the bottom of each container's cavity. - Uses ``resolve_container_targets`` for position computation and auto-spreading, - ``plan_batches`` for X/Y partitioning, then ``execute_batched`` to iterate - batches with Z safety. + Uses ``plan_batches`` for X/Y partitioning with per-batch container spread + (respecting no-go zones), then ``execute_batched`` to iterate batches with + Z safety. Args: containers: List of Container objects to probe, one per channel. use_channels: Channel indices to use for probing (0-indexed). resource_offsets: Optional XYZ offsets from container centers. When not provided, - ``resolve_container_targets`` auto-spreads channels targeting the same container. + ``plan_batches`` auto-spreads channels targeting the same container. lld_mode: Detection mode. Either a single ``LLDMode`` applied to all containers (deprecated, removed in v1b1) or a list of ``LLDMode``s (one per container) allowing mixed GAMMA/PRESSURE within one call. ``None`` (default) applies diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 1800bf06a90..75287e1c9ee 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -12,7 +12,7 @@ import math from collections import defaultdict from dataclasses import dataclass, field -from typing import Dict, List, Optional +from typing import Collection, Dict, FrozenSet, List, Optional, Tuple from pylabrobot.liquid_handling.channel_positioning import ( compute_nonconsecutive_channel_offsets, @@ -160,112 +160,226 @@ def validate_channel_selections( return use_channels -def resolve_container_targets( - containers: List[Container], +# --- Validity predicate & enumeration --- + + +def is_valid_batch( + job_indices: Collection[int], use_channels: List[int], + containers: List[Container], channel_spacings: List[float], wrt_resource: Resource, + x_tolerance: float, resource_offsets: Optional[List[Coordinate]] = None, -) -> List[Coordinate]: - """Convert containers to absolute Coordinates, auto-spreading when needed. +) -> Optional[Dict[int, Coordinate]]: + """Validate a candidate batch against all physical constraints, cheapest first. + + Checks, in order: + 1. Unique channels — O(k), prunes most candidates instantly. + 2. Same X — O(k), uses container centers (X isn't shifted by auto-spread). + 3. Container fit — per container, calls + :func:`compute_nonconsecutive_channel_offsets` to see if the channels + targeting it can coexist (respecting no-go zones). Skipped when + *resource_offsets* is provided. This is the expensive check. + 4. Y spacing between adjacent channels — O(k) on resolved positions. - When *resource_offsets* is ``None`` and multiple channels target the same - container, computes spread offsets via ``compute_nonconsecutive_channel_offsets`` - so channels can be batched in parallel. If the container is too narrow to - spread, channels stay at center and will be serialized by ``plan_batches``. + Returns: + Dict mapping each job index to its resolved absolute Coordinate if the + batch is valid; ``None`` otherwise. + """ + jobs = list(job_indices) + if not jobs: + return {} + + # 1. Unique channels. + if len({use_channels[j] for j in jobs}) != len(jobs): + return None + + centers = [containers[j].get_location_wrt(wrt_resource, x="c", y="c", z="b") for j in jobs] + + # 2. Same X (container center + any user X offset; auto-spread only moves Y). + if len(jobs) > 1: + xs = [ + centers[k].x + (resource_offsets[jobs[k]].x if resource_offsets is not None else 0.0) + for k in range(len(jobs)) + ] + if max(xs) - min(xs) > x_tolerance: + return None + + # 3. Container fit → per-channel Y offset (skipped when user supplies offsets). + y_offsets: Dict[int, float] = {j: 0.0 for j in jobs} + if resource_offsets is None: + by_container: Dict[int, List[int]] = defaultdict(list) + for j in jobs: + by_container[id(containers[j])].append(j) + for cjobs in by_container.values(): + c = containers[cjobs[0]] + if len(cjobs) == 1 and not getattr(c, "no_go_zones", ()): + continue # single channel, no no-go zones — center is fine. + offsets = compute_nonconsecutive_channel_offsets( + c, [use_channels[j] for j in cjobs], channel_spacings, + ) + if offsets is None: + return None + for j, off in zip(cjobs, offsets): + y_offsets[j] = off.y + + # Resolve absolute targets. + targets: Dict[int, Coordinate] = {} + for k, j in enumerate(jobs): + ox = resource_offsets[j].x if resource_offsets is not None else 0.0 + oy = resource_offsets[j].y if resource_offsets is not None else y_offsets[j] + targets[j] = Coordinate(centers[k].x + ox, centers[k].y + oy, 0.0) + + if len(jobs) == 1: + return targets + + # 4. Y spacing between adjacent channels. + jobs_sorted = sorted(jobs, key=lambda j: use_channels[j]) + for lo, hi in zip(jobs_sorted, jobs_sorted[1:]): + required = _span_required(channel_spacings, use_channels[lo], use_channels[hi]) + if targets[lo].y - targets[hi].y < required - 1e-9: + return None + + return targets + + +def _build_channel_batch( + job_indices: Collection[int], + resolved: Dict[int, Coordinate], + use_channels: List[int], + channel_spacings: List[float], +) -> ChannelBatch: + """Assemble a :class:`ChannelBatch` from validated job indices and resolved positions.""" + indices = sorted(job_indices, key=lambda i: use_channels[i]) + channels = [use_channels[i] for i in indices] + y_positions: Dict[int, float] = {use_channels[i]: resolved[i].y for i in indices} + y_positions = _interpolate_phantoms(channels, y_positions, channel_spacings) + x_position = sum(resolved[i].x for i in indices) / len(indices) + return ChannelBatch( + x_position=x_position, + indices=indices, + channels=channels, + y_positions=y_positions, + ) - When *resource_offsets* is provided, uses those offsets directly (no - auto-spreading). - Args: - containers: Container objects, one per entry in *use_channels*. - use_channels: Channel indices being used. - channel_spacings: Minimum Y spacing per channel (mm), one entry per - channel on the instrument. - wrt_resource: Reference resource for computing positions. All containers - must be descendants of this resource. - resource_offsets: Optional XYZ offsets from container centers. +def enumerate_valid_batches( + use_channels: List[int], + containers: List[Container], + channel_spacings: List[float], + wrt_resource: Resource, + x_tolerance: float, + resource_offsets: Optional[List[Coordinate]] = None, +) -> List[ChannelBatch]: + """Enumerate every valid batch by backtracking, returning ChannelBatch objects. - Returns: - List of Coordinates (parallel to *use_channels* / *containers*) with - absolute X/Y positions ready for ``plan_batches``. + Jobs are visited in channel-ascending order; an invalid candidate prunes its subtree. """ - if resource_offsets is not None: - if len(resource_offsets) != len(containers): - raise ValueError( - f"resource_offsets length must match containers, " - f"got {len(resource_offsets)} and {len(containers)}." - ) - offsets = resource_offsets - else: - offsets = [Coordinate.zero()] * len(containers) - - x_pos: List[float] = [] - y_pos: List[float] = [] - for container, offset in zip(containers, offsets): - loc = container.get_location_wrt(wrt_resource, x="c", y="c", z="b") - x_pos.append(loc.x + offset.x) - y_pos.append(loc.y + offset.y) - - # Auto-spread: when multiple channels target the same container and no - # explicit offsets were given, compute spread offsets so they can be batched. - if resource_offsets is None: - container_groups: Dict[int, List[int]] = defaultdict(list) - for idx in range(len(containers)): - container_groups[id(containers[idx])].append(idx) - for c_indices in container_groups.values(): - group_channels = [use_channels[i] for i in c_indices] - spread = compute_nonconsecutive_channel_offsets( - container=containers[c_indices[0]], - use_channels=group_channels, - channel_spacings=channel_spacings, + n = len(use_channels) + order = sorted(range(n), key=lambda i: use_channels[i]) + result: List[ChannelBatch] = [] + + def backtrack(start: int, current: List[int]): + if current: + resolved = is_valid_batch( + current, use_channels, containers, channel_spacings, wrt_resource, + x_tolerance, resource_offsets, ) - if spread is not None: - for i, idx_val in enumerate(c_indices): - y_pos[idx_val] += spread[i].y + if resolved is None: + return + result.append(_build_channel_batch(current, resolved, use_channels, channel_spacings)) + for pos in range(start, n): + backtrack(pos + 1, current + [order[pos]]) + + backtrack(0, []) + return result - return [Coordinate(x, y, 0) for x, y in zip(x_pos, y_pos)] +def minimum_exact_cover( + n_jobs: int, + batches: List[ChannelBatch], +) -> List[ChannelBatch]: + """Pick the fewest batches that partition ``{0..n_jobs-1}`` (branch-and-bound). + + Returned list is a subset of *batches*. Assumes every job appears in at + least one batch (caller's responsibility) so a partition always exists. + """ + if n_jobs == 0: + return [] -# --- Public API --- + by_min: Dict[int, List[Tuple[FrozenSet[int], ChannelBatch]]] = defaultdict(list) + for b in batches: + js = frozenset(b.indices) + by_min[min(js)].append((js, b)) + # Try larger batches first at each pivot — finds a tight bound sooner. + for bucket in by_min.values(): + bucket.sort(key=lambda js_b: len(js_b[0]), reverse=True) + + all_jobs = frozenset(range(n_jobs)) + best: List[List[ChannelBatch]] = [batches[: n_jobs + 1]] # sentinel: unreachable length + + def recurse(remaining: FrozenSet[int], current: List[ChannelBatch]): + if not remaining: + if len(current) < len(best[0]): + best[0] = list(current) + return + if len(current) + 1 >= len(best[0]): + return + pivot = min(remaining) + for js, b in by_min[pivot]: + if js.issubset(remaining): + current.append(b) + recurse(remaining - js, current) + current.pop() + + recurse(all_jobs, []) + return best[0] def plan_batches( use_channels: List[int], - targets: List[Coordinate], + containers: List[Container], channel_spacings: List[float], + wrt_resource: Resource, x_tolerance: float, + resource_offsets: Optional[List[Coordinate]] = None, ) -> List[ChannelBatch]: - """Partition channel–target pairs into executable batches. - - Groups by X position (within *x_tolerance*), then within each X group partitions - into Y sub-batches respecting per-channel minimum spacing. Computes phantom channel - positions for intermediate channels between non-consecutive batch members. + """Container-aware, optimal batch planning (respects no-go zones). - Use ``resolve_container_targets`` to convert Container objects to Coordinates - before calling this function. + Decides the per-container spread *per candidate batch* by asking + :func:`compute_nonconsecutive_channel_offsets` whether the channels + targeting each container physically coexist in it given its geometry and + no-go zones. Pairs that behavior with minimum exact-cover partition + (:func:`minimum_exact_cover`) so the returned plan uses the fewest batches. Args: - use_channels: Channel indices being used (e.g. [0, 1, 2, 5, 6, 7]). - targets: Coordinate objects with absolute X/Y positions. One per entry - in *use_channels*. - channel_spacings: Minimum Y spacing per channel (mm), one entry per - channel on the instrument. - x_tolerance: Positions within this tolerance share an X group. + use_channels: Channel indices being used (parallel to *containers*). + containers: Target Container objects, one per channel. + channel_spacings: Per-channel minimum Y spacing (mm). + wrt_resource: Reference resource for computing absolute positions. + x_tolerance: Positions within this tolerance share a batch (pairwise). + resource_offsets: Optional explicit XYZ offsets from container centers. + When provided, auto-spread and no-go-zone checks are skipped (user is + authoritative). Returns: Flat list of ChannelBatch sorted by ascending X position. """ - if x_tolerance <= 0: raise ValueError(f"x_tolerance must be > 0, got {x_tolerance}.") - if len(use_channels) != len(targets): + if len(use_channels) != len(containers): raise ValueError( - f"use_channels and targets must have the same length, " - f"got {len(use_channels)} and {len(targets)}." + f"use_channels and containers must have the same length, " + f"got {len(use_channels)} and {len(containers)}." ) if len(use_channels) == 0: raise ValueError("use_channels must not be empty.") + if resource_offsets is not None and len(resource_offsets) != len(use_channels): + raise ValueError( + f"resource_offsets length must match use_channels, " + f"got {len(resource_offsets)} and {len(use_channels)}." + ) max_ch = max(use_channels) if len(channel_spacings) < max_ch + 1: raise ValueError( @@ -273,54 +387,20 @@ def plan_batches( f"(max channel index is {max_ch}), got {len(channel_spacings)}." ) - x_pos = [c.x for c in targets] - y_pos = [c.y for c in targets] - spacings = list(channel_spacings) - - # Group indices by X position. Sorts by X then merges adjacent positions - # within tolerance into the same group, so positions like 99.99 and 100.01 - # (0.02mm apart) are never split across groups. - sorted_by_x = sorted(range(len(x_pos)), key=lambda i: x_pos[i]) - x_groups: Dict[float, List[int]] = {} - current_key: Optional[float] = None - for i in sorted_by_x: - if current_key is None or abs(x_pos[i] - current_key) > x_tolerance: - current_key = x_pos[i] - x_groups.setdefault(current_key, []).append(i) - - # Within each X group, batch by Y position (greedy first-fit) - result: List[ChannelBatch] = [] - for _, x_group_indices in sorted(x_groups.items()): - group_x = x_pos[x_group_indices[0]] - channels_by_index = sorted(x_group_indices, key=lambda i: use_channels[i]) - y_batches: List[List[int]] = [] - - for idx in channels_by_index: - channel = use_channels[idx] - y = y_pos[idx] - - for batch in y_batches: - if channel in [use_channels[i] for i in batch]: - continue - hi_ch = use_channels[batch[-1]] - hi_y = y_pos[batch[-1]] - if hi_y - y >= _span_required(spacings, hi_ch, channel) - 1e-9: - batch.append(idx) - break - else: - y_batches.append([idx]) - - for batch in y_batches: - batch_channels = [use_channels[i] for i in batch] - y_positions: Dict[int, float] = {use_channels[i]: y_pos[i] for i in batch} - y_positions = _interpolate_phantoms(batch_channels, y_positions, spacings) - result.append( - ChannelBatch( - x_position=group_x, - indices=batch, - channels=batch_channels, - y_positions=y_positions, - ) - ) + valid = enumerate_valid_batches( + use_channels, containers, channel_spacings, wrt_resource, x_tolerance, resource_offsets, + ) - return result + # Every job must appear in at least one valid batch, else no partition exists. + covered = set().union(*(b.indices for b in valid)) if valid else set() + missing = set(range(len(use_channels))) - covered + if missing: + raise ValueError( + f"No valid batch contains job(s) {sorted(missing)} " + f"(channel(s) {[use_channels[j] for j in sorted(missing)]}); " + f"container(s) cannot accommodate them." + ) + + partition = minimum_exact_cover(len(use_channels), valid) + partition.sort(key=lambda b: b.x_position) + return partition diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 89de9c89dfd..661ac7e01bd 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -1,128 +1,274 @@ -"""Tests for pipette_batch_scheduling module. - -Tests cover: mixed channel spacing, phantom interpolation, coordinate batching, -and container-to-coordinate resolution (resolve_container_targets). -""" +"""Tests for pipette_batch_scheduling module.""" import unittest -from typing import List +from typing import Iterable, List, Optional, Tuple from unittest.mock import MagicMock, patch from pylabrobot.liquid_handling.pipette_batch_scheduling import ( + ChannelBatch, _span_required, + enumerate_valid_batches, + is_valid_batch, + minimum_exact_cover, plan_batches, - resolve_container_targets, ) from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.resource import Resource -def _coords(x_pos: List[float], y_pos: List[float]) -> List[Coordinate]: - """Build Coordinate targets from parallel x/y lists.""" - return [Coordinate(x, y, 0) for x, y in zip(x_pos, y_pos)] +def _mock_container(cx: float, cy: float, size_y: float = 3.0, + name: str = "well", no_go_zones: Iterable = ()) -> Container: + """Build a mock Container at (cx, cy). + + Default ``size_y=3.0`` mm is below ``2 * MIN_SPACING_EDGE = 4mm`` so + ``compute_nonconsecutive_channel_offsets`` returns ``None`` without reaching + ``compute_channel_offsets`` (which reads real Container internals). Tests + that need multi-channel spread pass a larger ``size_y`` and ``@patch`` the + offsets function. + """ + c = MagicMock(spec=Container) + c.name = name + c.get_absolute_size_y.return_value = size_y + c.get_location_wrt = MagicMock(return_value=Coordinate(cx, cy, 0)) + c.no_go_zones = list(no_go_zones) + return c + + +def _mock_deck() -> Resource: + return MagicMock(spec=Resource) + + +def _containers(xs: List[float], ys: List[float]) -> List[Container]: + return [_mock_container(x, y) for x, y in zip(xs, ys)] + + +def _stub_batch(indices: Iterable[int]) -> ChannelBatch: + """Build a ChannelBatch with only indices populated (cover-algorithm tests).""" + return ChannelBatch( + x_position=0.0, + indices=list(indices), + channels=list(indices), + y_positions={}, + ) + + +def _index_sets(batches: List[ChannelBatch]) -> List[frozenset]: + return [frozenset(b.indices) for b in batches] -class TestMixedChannelSpacing(unittest.TestCase): - """Pairwise spacing with non-uniform channel sizes (e.g. 1mL + 5mL).""" +DECK = _mock_deck() + + +class TestSpanRequired(unittest.TestCase): + """Rounded pairwise spacing sums.""" SPACINGS = [8.98, 8.98, 17.96, 17.96] - def test_pairwise_rounding(self): - # max(8.98, 17.96) = 17.96 -> ceil(179.6)/10 = 18.0 - self.assertAlmostEqual(_span_required(self.SPACINGS, 1, 2), 18.0) + def test_uniform(self): # max(8.98, 8.98) = 8.98 -> ceil(89.8)/10 = 9.0 self.assertAlmostEqual(_span_required(self.SPACINGS, 0, 1), 9.0) - def test_mixed_spacing_boundary(self): - # 18.0mm needed between ch1 (1mL) and ch2 (5mL) - batches = plan_batches( - [1, 2], _coords([100.0] * 2, [217.9, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 2) - batches = plan_batches( - [1, 2], _coords([100.0] * 2, [218.0, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 1) + def test_mixed(self): + # max(8.98, 17.96) = 17.96 -> ceil(179.6)/10 = 18.0 + self.assertAlmostEqual(_span_required(self.SPACINGS, 1, 2), 18.0) + + +class TestIsValidBatch(unittest.TestCase): + """Container-aware batch-level validity predicate.""" + + S = [9.0] * 8 + + def test_empty_and_singleton_valid(self): + c = _mock_container(100.0, 200.0) + self.assertEqual(is_valid_batch([], [0], [c], self.S, DECK, x_tolerance=0.1), {}) + result = is_valid_batch([0], [0], [c], self.S, DECK, x_tolerance=0.1) + self.assertIsNotNone(result) + self.assertAlmostEqual(result[0].x, 100.0) + self.assertAlmostEqual(result[0].y, 200.0) + + def test_duplicate_channels_invalid(self): + cs = _containers([100.0, 100.0], [200.0, 180.0]) + self.assertIsNone(is_valid_batch([0, 1], [3, 3], cs, self.S, DECK, x_tolerance=0.1)) + + def test_x_tolerance_respected(self): + cs = _containers([100.0, 100.08], [209.0, 200.0]) + self.assertIsNotNone(is_valid_batch([0, 1], [0, 1], cs, self.S, DECK, x_tolerance=0.1)) + cs = _containers([100.0, 100.2], [209.0, 200.0]) + self.assertIsNone(is_valid_batch([0, 1], [0, 1], cs, self.S, DECK, x_tolerance=0.1)) + + def test_spacing_boundary(self): + cs = _containers([100.0, 100.0], [209.0, 200.0]) + self.assertIsNotNone(is_valid_batch([0, 1], [0, 1], cs, self.S, DECK, x_tolerance=0.1)) + cs = _containers([100.0, 100.0], [208.9, 200.0]) + self.assertIsNone(is_valid_batch([0, 1], [0, 1], cs, self.S, DECK, x_tolerance=0.1)) + + def test_monotone_y_enforced(self): + # Higher channel must be at lower Y. + cs = _containers([100.0, 100.0], [200.0, 209.0]) + self.assertIsNone(is_valid_batch([0, 1], [0, 1], cs, self.S, DECK, x_tolerance=0.1)) def test_pairwise_sum_not_uniform_product(self): - # ch0->ch3: 9.0 + 18.0 + 18.0 = 45.0mm pairwise, NOT 3 * 18.0 = 54.0mm uniform - batches = plan_batches( - [0, 3], _coords([100.0] * 2, [250.0, 200.0]), self.SPACINGS, x_tolerance=0.1 + # ch0→ch3 with mixed [8.98, 8.98, 17.96, 17.96]: 9+18+18 = 45mm pairwise. + sp = [8.98, 8.98, 17.96, 17.96] + cs = _containers([100.0, 100.0], [245.0, 200.0]) # 45mm apart + self.assertIsNotNone(is_valid_batch([0, 1], [0, 3], cs, sp, DECK, x_tolerance=0.1)) + cs = _containers([100.0, 100.0], [244.9, 200.0]) # 44.9mm — short + self.assertIsNone(is_valid_batch([0, 1], [0, 3], cs, sp, DECK, x_tolerance=0.1)) + + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling." + "compute_nonconsecutive_channel_offsets") + def test_container_fit_failure_rejects_batch(self, mock_offsets): + mock_offsets.return_value = None + c = _mock_container(100.0, 200.0, size_y=3.0) + self.assertIsNone(is_valid_batch([0, 1], [0, 1], [c, c], self.S, DECK, x_tolerance=0.1)) + + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling." + "compute_nonconsecutive_channel_offsets") + def test_container_fit_sets_spread_positions(self, mock_offsets): + mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] + c = _mock_container(100.0, 200.0, size_y=50.0) + resolved = is_valid_batch([0, 1], [0, 1], [c, c], self.S, DECK, x_tolerance=0.1) + self.assertIsNotNone(resolved) + self.assertAlmostEqual(resolved[0].y, 204.5) + self.assertAlmostEqual(resolved[1].y, 195.5) + + def test_resource_offsets_override_auto_spread(self): + # When explicit offsets are provided, compute_nonconsecutive_channel_offsets is not called. + c = _mock_container(100.0, 200.0) + offsets = [Coordinate(0, 10.0, 0), Coordinate(0, -10.0, 0)] + resolved = is_valid_batch( + [0, 1], [0, 1], [c, c], self.S, DECK, x_tolerance=0.1, resource_offsets=offsets, ) - self.assertEqual(len(batches), 1) + self.assertIsNotNone(resolved) + self.assertAlmostEqual(resolved[0].y, 210.0) + self.assertAlmostEqual(resolved[1].y, 190.0) - def test_mixed_phantoms_use_pairwise_spacing(self): - batches = plan_batches( - [0, 3], _coords([100.0] * 2, [245.0, 200.0]), self.SPACINGS, x_tolerance=0.1 - ) - self.assertEqual(len(batches), 1) - y = batches[0].y_positions - # ch0->ch1: 9.0mm, ch1->ch2: 18.0mm - self.assertAlmostEqual(y[1], 245.0 - 9.0) - self.assertAlmostEqual(y[2], 245.0 - 9.0 - 18.0) +class TestEnumerateValidBatches(unittest.TestCase): + """Backtracking enumeration.""" + + S = [9.0] * 8 -class TestCoreBatching(unittest.TestCase): - """Fundamental X grouping, Y batching, and validation.""" + def test_singletons_always_present(self): + cs = _containers([100.0, 200.0, 300.0], [200.0, 200.0, 200.0]) + batches = enumerate_valid_batches([0, 1, 2], cs, self.S, DECK, x_tolerance=0.1) + keys = _index_sets(batches) + for i in range(3): + self.assertIn(frozenset([i]), keys) + + def test_enumerates_all_compatible_subsets(self): + # 3 channels, all pairwise compatible at 9mm spacing. + cs = _containers([100.0] * 3, [218.0, 209.0, 200.0]) + batches = enumerate_valid_batches([0, 1, 2], cs, self.S, DECK, x_tolerance=0.1) + expected = { + frozenset([0]), frozenset([1]), frozenset([2]), + frozenset([0, 1]), frozenset([1, 2]), frozenset([0, 2]), + frozenset([0, 1, 2]), + } + self.assertEqual(set(_index_sets(batches)), expected) + + def test_excludes_invalid_pair(self): + cs = _containers([100.0] * 3, [210.0, 205.0, 200.0]) # 0↔1 gap only 5mm + batches = enumerate_valid_batches([0, 1, 2], cs, self.S, DECK, x_tolerance=0.1) + keys = set(_index_sets(batches)) + self.assertNotIn(frozenset([0, 1]), keys) + self.assertNotIn(frozenset([0, 1, 2]), keys) + + def test_different_x_never_share(self): + cs = _containers([100.0, 200.0], [209.0, 200.0]) + batches = enumerate_valid_batches([0, 1], cs, self.S, DECK, x_tolerance=0.1) + self.assertNotIn(frozenset([0, 1]), _index_sets(batches)) + + +class TestMinimumExactCover(unittest.TestCase): + """Branch-and-bound minimum partition.""" + + def test_single_job(self): + cover = minimum_exact_cover(1, [_stub_batch([0])]) + self.assertEqual(_index_sets(cover), [frozenset([0])]) + + def test_prefers_larger_batch(self): + batches = [_stub_batch(ix) for ix in ([0], [1], [2], [0, 1], [0, 1, 2])] + cover = minimum_exact_cover(3, batches) + self.assertEqual(_index_sets(cover), [frozenset([0, 1, 2])]) + + def test_forces_two_when_no_triple_valid(self): + batches = [_stub_batch(ix) for ix in ([0], [1], [2], [0, 1], [1, 2])] + cover = minimum_exact_cover(3, batches) + self.assertEqual(len(cover), 2) + self.assertEqual(frozenset().union(*_index_sets(cover)), frozenset([0, 1, 2])) + + def test_falls_back_to_singletons(self): + batches = [_stub_batch([0]), _stub_batch([1])] + self.assertEqual(len(minimum_exact_cover(2, batches)), 2) + + def test_greedy_largest_first_beaten_by_branch_and_bound(self): + # Greedy takes {0,1,2,3} first, then {4},{5} → 3 batches. + # Optimum is {0,1,2} + {3,4,5} = 2 batches. + batches = [_stub_batch(ix) for ix in + ([0, 1, 2, 3], [0, 1, 2], [3, 4, 5], [0], [1], [2], [3], [4], [5])] + cover = minimum_exact_cover(6, batches) + self.assertEqual(len(cover), 2) + self.assertEqual(frozenset().union(*_index_sets(cover)), frozenset(range(6))) + + +class TestPlanBatches(unittest.TestCase): + """End-to-end planning: X grouping, Y batching, phantoms, errors.""" S = [9.0] * 8 def test_spacing_boundary(self): - # Exactly 9mm -> one batch - batches = plan_batches([0, 1], _coords([100.0] * 2, [209.0, 200.0]), self.S, x_tolerance=0.1) + batches = plan_batches([0, 1], _containers([100.0] * 2, [209.0, 200.0]), + self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 1) - # 0.1mm short -> two batches - batches = plan_batches([0, 1], _coords([100.0] * 2, [208.9, 200.0]), self.S, x_tolerance=0.1) + batches = plan_batches([0, 1], _containers([100.0] * 2, [208.9, 200.0]), + self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 2) def test_same_y_serializes(self): - batches = plan_batches([0, 1, 2], _coords([100.0] * 3, [200.0] * 3), self.S, x_tolerance=0.1) + batches = plan_batches([0, 1, 2], _containers([100.0] * 3, [200.0] * 3), + self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 3) def test_x_tolerance_boundary(self): - # Within tolerance -> one group - batches = plan_batches( - [0, 1], _coords([100.0, 100.05], [270.0, 261.0]), self.S, x_tolerance=0.1 - ) + batches = plan_batches([0, 1], _containers([100.0, 100.05], [270.0, 261.0]), + self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 1) - # Outside tolerance -> two groups - batches = plan_batches([0, 1], _coords([100.0, 100.2], [270.0, 270.0]), self.S, x_tolerance=0.1) + batches = plan_batches([0, 1], _containers([100.0, 100.2], [270.0, 270.0]), + self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 2) def test_x_groups_sorted_ascending(self): - batches = plan_batches( - [0, 1, 2], _coords([300.0, 100.0, 200.0], [270.0] * 3), self.S, x_tolerance=0.1 - ) + batches = plan_batches([0, 1, 2], _containers([300.0, 100.0, 200.0], [270.0] * 3), + self.S, DECK, x_tolerance=0.1) xs = [b.x_position for b in batches] self.assertEqual(xs, sorted(xs)) def test_empty_raises(self): with self.assertRaises(ValueError): - plan_batches([], [], self.S, x_tolerance=0.1) + plan_batches([], [], self.S, DECK, x_tolerance=0.1) def test_mismatched_lengths_raises(self): with self.assertRaises(ValueError): - plan_batches([0, 1], _coords([100.0], [200.0]), self.S, x_tolerance=0.1) + plan_batches([0, 1], _containers([100.0], [200.0]), self.S, DECK, x_tolerance=0.1) def test_duplicate_channels_serialized(self): - batches = plan_batches([0, 0], _coords([100.0] * 2, [200.0] * 2), self.S, x_tolerance=0.1) + batches = plan_batches([0, 0], _containers([100.0] * 2, [200.0] * 2), + self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 2) def test_duplicate_channels_three_ops(self): - batches = plan_batches([0, 0, 0], _coords([100.0] * 3, [200.0] * 3), self.S, x_tolerance=0.1) + batches = plan_batches([0, 0, 0], _containers([100.0] * 3, [200.0] * 3), + self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 3) - -class TestPhantomInterpolation(unittest.TestCase): - """Phantom channels between non-consecutive batch members.""" - def test_phantoms_interpolated_at_spacing(self): + # 6 channels in one batch with non-consecutive channel indices. batches = plan_batches( [0, 1, 2, 5, 6, 7], - _coords([100.0] * 6, [300.0, 291.0, 282.0, 255.0, 246.0, 237.0]), - [9.0] * 8, - x_tolerance=0.1, + _containers([100.0] * 6, [300.0, 291.0, 282.0, 255.0, 246.0, 237.0]), + [9.0] * 8, DECK, x_tolerance=0.1, ) self.assertEqual(len(batches), 1) y = batches[0].y_positions @@ -132,88 +278,82 @@ def test_phantoms_interpolated_at_spacing(self): self.assertAlmostEqual(y[4], 282.0 - 18.0) def test_phantoms_only_within_batch(self): - # Split into 2 batches — no phantoms across batches - batches = plan_batches([0, 3], _coords([100.0] * 2, [200.0, 250.0]), [9.0] * 4, x_tolerance=0.1) + # Two separate batches — no phantoms bridging them. + batches = plan_batches([0, 3], _containers([100.0] * 2, [200.0, 250.0]), + [9.0] * 4, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 2) for batch in batches: self.assertEqual(len(batch.y_positions), 1) - -class TestCoordinateTargets(unittest.TestCase): - """plan_batches with Coordinate targets (no containers).""" - - def test_coordinate_x_grouping_and_y_batching(self): - batches = plan_batches( - [0, 1, 2, 3], - _coords([100.0, 100.0, 200.0, 200.0], [200.0, 200.0, 270.0, 261.0]), - [9.0] * 4, - x_tolerance=0.1, - ) - x100 = [b for b in batches if abs(b.x_position - 100.0) < 0.01] - x200 = [b for b in batches if abs(b.x_position - 200.0) < 0.01] - self.assertEqual(len(x100), 2) # same Y -> serialized - self.assertEqual(len(x200), 1) # 9mm apart -> parallel - def test_indices_map_back_correctly(self): use_channels = [3, 7, 0] - batches = plan_batches( - use_channels, _coords([100.0] * 3, [261.0, 237.0, 270.0]), [9.0] * 8, x_tolerance=0.1 - ) + batches = plan_batches(use_channels, _containers([100.0] * 3, [261.0, 237.0, 270.0]), + [9.0] * 8, DECK, x_tolerance=0.1) all_indices = [idx for b in batches for idx in b.indices] self.assertEqual(sorted(all_indices), [0, 1, 2]) for batch in batches: for idx, ch in zip(batch.indices, batch.channels): self.assertEqual(use_channels[idx], ch) + def test_mixed_spacing_boundary(self): + # 1mL (ch0,1) + 5mL (ch2,3): 18mm required between ch1 and ch2. + sp = [8.98, 8.98, 17.96, 17.96] + batches = plan_batches([1, 2], _containers([100.0] * 2, [217.9, 200.0]), + sp, DECK, x_tolerance=0.1) + self.assertEqual(len(batches), 2) + batches = plan_batches([1, 2], _containers([100.0] * 2, [218.0, 200.0]), + sp, DECK, x_tolerance=0.1) + self.assertEqual(len(batches), 1) -class TestContainerTargets(unittest.TestCase): - """resolve_container_targets + plan_batches with Container auto-spreading.""" - - S = [9.0] * 8 - - def _mock_container(self, cx: float, cy: float, size_y: float = 10.0, name: str = "well"): - c = MagicMock(spec=Container) - c.get_absolute_size_y.return_value = size_y - c.name = name - c.get_location_wrt = MagicMock(return_value=Coordinate(cx, cy, 0)) - return c - - def _mock_deck(self): - return MagicMock(spec=Resource) + def test_mixed_phantoms_use_pairwise_spacing(self): + sp = [8.98, 8.98, 17.96, 17.96] + batches = plan_batches([0, 3], _containers([100.0] * 2, [245.0, 200.0]), + sp, DECK, x_tolerance=0.1) + self.assertEqual(len(batches), 1) + y = batches[0].y_positions + # ch0→ch1: 9.0, ch1→ch2: 18.0 + self.assertAlmostEqual(y[1], 245.0 - 9.0) + self.assertAlmostEqual(y[2], 245.0 - 9.0 - 18.0) - @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") - def test_same_container_auto_spreads(self, mock_offsets): + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling." + "compute_nonconsecutive_channel_offsets") + def test_auto_spread_same_container(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] - trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") - deck = self._mock_deck() - targets = resolve_container_targets([trough, trough], [0, 1], self.S, deck) - batches = plan_batches([0, 1], targets, self.S, x_tolerance=0.1) + trough = _mock_container(100.0, 200.0, size_y=50.0, name="trough") + batches = plan_batches([0, 1], [trough, trough], self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 1) y = batches[0].y_positions - self.assertAlmostEqual(y[0], 200.0 + 4.5) - self.assertAlmostEqual(y[1], 200.0 - 4.5) - - def test_same_narrow_container_serialized(self): - well = self._mock_container(100.0, 200.0, size_y=5.0, name="narrow_well") - deck = self._mock_deck() - targets = resolve_container_targets([well, well], [0, 1], self.S, deck) - batches = plan_batches([0, 1], targets, self.S, x_tolerance=0.1) + self.assertAlmostEqual(y[0], 204.5) + self.assertAlmostEqual(y[1], 195.5) + + def test_narrow_container_serializes(self): + # size_y=3 means compute_nonconsecutive_channel_offsets returns None → singletons only. + well = _mock_container(100.0, 200.0, size_y=3.0, name="narrow") + batches = plan_batches([0, 1], [well, well], self.S, DECK, x_tolerance=0.1) self.assertEqual(len(batches), 2) - @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") - def test_resource_offsets_skips_auto_spreading(self, mock_offsets): - trough = self._mock_container(100.0, 200.0, size_y=50.0, name="trough") - deck = self._mock_deck() - user_offsets = [Coordinate(0, 10.0, 0), Coordinate(0, -10.0, 0)] - targets = resolve_container_targets( - [trough, trough], [0, 1], self.S, deck, resource_offsets=user_offsets - ) - mock_offsets.assert_not_called() - batches = plan_batches([0, 1], targets, self.S, x_tolerance=0.1) - self.assertEqual(len(batches), 1) - y = batches[0].y_positions - self.assertAlmostEqual(y[0], 210.0) - self.assertAlmostEqual(y[1], 190.0) + +class TestPlanBatchesNoGoZones(unittest.TestCase): + """Per-batch container-fit decisions let plan_batches batch subsets that a + fixed up-front spread cannot (the motivating case for no-go zones).""" + + S = [9.0] * 8 + + @patch("pylabrobot.liquid_handling.pipette_batch_scheduling." + "compute_nonconsecutive_channel_offsets") + def test_fits_pair_but_not_triple_in_container(self, mock_offsets): + # Container fits any adjacent pair but not three channels at once. + def fake_offsets(container, channels, spacings): + if len(channels) <= 2 and channels == sorted(channels) \ + and channels[-1] - channels[0] <= 1: + return [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)][: len(channels)] + return None + + mock_offsets.side_effect = fake_offsets + c = _mock_container(100.0, 200.0, size_y=12.0, name="small") + batches = plan_batches([0, 1, 2], [c, c, c], self.S, DECK, x_tolerance=0.1) + self.assertEqual(len(batches), 2) + self.assertEqual(sorted(len(b.channels) for b in batches), [1, 2]) if __name__ == "__main__": From 525955717af494bf40acfe7b6a77531ccb522296 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 14 Apr 2026 18:36:55 +0100 Subject: [PATCH 34/39] Rewrite pipette_batch_scheduling module docstring with problem classification --- .../pipette_batch_scheduling.py | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 75287e1c9ee..216f9b5c3d7 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -1,11 +1,26 @@ -"""Pipette orchestration: resolve container positions and partition into executable batches. - -Multi-channel liquid handlers have physical constraints (single X carriage, minimum -Y spacing, descending Y order by channel index) that limit which channels can act -simultaneously. - - targets = resolve_container_targets(containers, use_channels, channel_spacings, wrt_resource) - batches = plan_batches(use_channels, targets, channel_spacings, x_tolerance=0.1) +"""Plan the fewest X/Y moves that position each channel at its target. + +Multi-channel heads share one X carriage, enforce minimum pairwise Y spacing, strictly +descending Y by channel index, and per-container geometry (including no-go zones). +Given a list of (channel, target container) assignments, this module groups them into batches +where each batch is a set of channels whose targets can all be reached in one X/Y move. +A Z-axis operation (e.g. LLD probe, aspirate, dispense, ...) is then supplied as a callback +by the caller. + +This is formally a Minimum Exact Cover problem (equivalently, Set Partitioning in OR terminology, +or minimum hypergraph coloring in graph theory): pairwise constraints alone reduce to +graph coloring; container fit with no-go zones is k-ary, making it hypergraph coloring. +Hence the enumerate-then-partition pipeline rather than 2-ary graph coloring. Intended +for n <= ~16 channels; planning is O(2^n * n^2) in the worst case, with the branch-and- +bound partition solver typically fast on the structured instances this module sees. + + batches = plan_batches( + use_channels=[0, 1, 2, 5, 6, 7], + containers=[w0, w1, w2, w5, w6, w7], + channel_spacings=backend._channels_minimum_y_spacing, + wrt_resource=backend.deck, + x_tolerance=0.1, + ) await backend.execute_batched(func=my_z_callback, batches=batches) """ @@ -217,7 +232,9 @@ def is_valid_batch( if len(cjobs) == 1 and not getattr(c, "no_go_zones", ()): continue # single channel, no no-go zones — center is fine. offsets = compute_nonconsecutive_channel_offsets( - c, [use_channels[j] for j in cjobs], channel_spacings, + c, + [use_channels[j] for j in cjobs], + channel_spacings, ) if offsets is None: return None @@ -283,8 +300,13 @@ def enumerate_valid_batches( def backtrack(start: int, current: List[int]): if current: resolved = is_valid_batch( - current, use_channels, containers, channel_spacings, wrt_resource, - x_tolerance, resource_offsets, + current, + use_channels, + containers, + channel_spacings, + wrt_resource, + x_tolerance, + resource_offsets, ) if resolved is None: return @@ -388,7 +410,12 @@ def plan_batches( ) valid = enumerate_valid_batches( - use_channels, containers, channel_spacings, wrt_resource, x_tolerance, resource_offsets, + use_channels, + containers, + channel_spacings, + wrt_resource, + x_tolerance, + resource_offsets, ) # Every job must appear in at least one valid batch, else no partition exists. From 57e47dc6ee6b7ca92d7bfeea36c41e2ed4042434 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 14 Apr 2026 18:43:59 +0100 Subject: [PATCH 35/39] Narrow `is_valid_batch` return in tests and drop unused imports --- .../pipette_batch_scheduling_tests.py | 130 +++++++++++------- 1 file changed, 82 insertions(+), 48 deletions(-) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py index 661ac7e01bd..0ef97cc23d9 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py @@ -1,7 +1,7 @@ """Tests for pipette_batch_scheduling module.""" import unittest -from typing import Iterable, List, Optional, Tuple +from typing import Iterable, List from unittest.mock import MagicMock, patch from pylabrobot.liquid_handling.pipette_batch_scheduling import ( @@ -17,8 +17,9 @@ from pylabrobot.resources.resource import Resource -def _mock_container(cx: float, cy: float, size_y: float = 3.0, - name: str = "well", no_go_zones: Iterable = ()) -> Container: +def _mock_container( + cx: float, cy: float, size_y: float = 3.0, name: str = "well", no_go_zones: Iterable = () +) -> Container: """Build a mock Container at (cx, cy). Default ``size_y=3.0`` mm is below ``2 * MIN_SPACING_EDGE = 4mm`` so @@ -83,7 +84,7 @@ def test_empty_and_singleton_valid(self): c = _mock_container(100.0, 200.0) self.assertEqual(is_valid_batch([], [0], [c], self.S, DECK, x_tolerance=0.1), {}) result = is_valid_batch([0], [0], [c], self.S, DECK, x_tolerance=0.1) - self.assertIsNotNone(result) + assert result is not None self.assertAlmostEqual(result[0].x, 100.0) self.assertAlmostEqual(result[0].y, 200.0) @@ -116,20 +117,22 @@ def test_pairwise_sum_not_uniform_product(self): cs = _containers([100.0, 100.0], [244.9, 200.0]) # 44.9mm — short self.assertIsNone(is_valid_batch([0, 1], [0, 3], cs, sp, DECK, x_tolerance=0.1)) - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling." - "compute_nonconsecutive_channel_offsets") + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" + ) def test_container_fit_failure_rejects_batch(self, mock_offsets): mock_offsets.return_value = None c = _mock_container(100.0, 200.0, size_y=3.0) self.assertIsNone(is_valid_batch([0, 1], [0, 1], [c, c], self.S, DECK, x_tolerance=0.1)) - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling." - "compute_nonconsecutive_channel_offsets") + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" + ) def test_container_fit_sets_spread_positions(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] c = _mock_container(100.0, 200.0, size_y=50.0) resolved = is_valid_batch([0, 1], [0, 1], [c, c], self.S, DECK, x_tolerance=0.1) - self.assertIsNotNone(resolved) + assert resolved is not None self.assertAlmostEqual(resolved[0].y, 204.5) self.assertAlmostEqual(resolved[1].y, 195.5) @@ -138,9 +141,15 @@ def test_resource_offsets_override_auto_spread(self): c = _mock_container(100.0, 200.0) offsets = [Coordinate(0, 10.0, 0), Coordinate(0, -10.0, 0)] resolved = is_valid_batch( - [0, 1], [0, 1], [c, c], self.S, DECK, x_tolerance=0.1, resource_offsets=offsets, + [0, 1], + [0, 1], + [c, c], + self.S, + DECK, + x_tolerance=0.1, + resource_offsets=offsets, ) - self.assertIsNotNone(resolved) + assert resolved is not None self.assertAlmostEqual(resolved[0].y, 210.0) self.assertAlmostEqual(resolved[1].y, 190.0) @@ -162,8 +171,12 @@ def test_enumerates_all_compatible_subsets(self): cs = _containers([100.0] * 3, [218.0, 209.0, 200.0]) batches = enumerate_valid_batches([0, 1, 2], cs, self.S, DECK, x_tolerance=0.1) expected = { - frozenset([0]), frozenset([1]), frozenset([2]), - frozenset([0, 1]), frozenset([1, 2]), frozenset([0, 2]), + frozenset([0]), + frozenset([1]), + frozenset([2]), + frozenset([0, 1]), + frozenset([1, 2]), + frozenset([0, 2]), frozenset([0, 1, 2]), } self.assertEqual(set(_index_sets(batches)), expected) @@ -206,8 +219,9 @@ def test_falls_back_to_singletons(self): def test_greedy_largest_first_beaten_by_branch_and_bound(self): # Greedy takes {0,1,2,3} first, then {4},{5} → 3 batches. # Optimum is {0,1,2} + {3,4,5} = 2 batches. - batches = [_stub_batch(ix) for ix in - ([0, 1, 2, 3], [0, 1, 2], [3, 4, 5], [0], [1], [2], [3], [4], [5])] + batches = [ + _stub_batch(ix) for ix in ([0, 1, 2, 3], [0, 1, 2], [3, 4, 5], [0], [1], [2], [3], [4], [5]) + ] cover = minimum_exact_cover(6, batches) self.assertEqual(len(cover), 2) self.assertEqual(frozenset().union(*_index_sets(cover)), frozenset(range(6))) @@ -219,29 +233,35 @@ class TestPlanBatches(unittest.TestCase): S = [9.0] * 8 def test_spacing_boundary(self): - batches = plan_batches([0, 1], _containers([100.0] * 2, [209.0, 200.0]), - self.S, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 1], _containers([100.0] * 2, [209.0, 200.0]), self.S, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) - batches = plan_batches([0, 1], _containers([100.0] * 2, [208.9, 200.0]), - self.S, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 1], _containers([100.0] * 2, [208.9, 200.0]), self.S, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 2) def test_same_y_serializes(self): - batches = plan_batches([0, 1, 2], _containers([100.0] * 3, [200.0] * 3), - self.S, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 1, 2], _containers([100.0] * 3, [200.0] * 3), self.S, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 3) def test_x_tolerance_boundary(self): - batches = plan_batches([0, 1], _containers([100.0, 100.05], [270.0, 261.0]), - self.S, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 1], _containers([100.0, 100.05], [270.0, 261.0]), self.S, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) - batches = plan_batches([0, 1], _containers([100.0, 100.2], [270.0, 270.0]), - self.S, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 1], _containers([100.0, 100.2], [270.0, 270.0]), self.S, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 2) def test_x_groups_sorted_ascending(self): - batches = plan_batches([0, 1, 2], _containers([300.0, 100.0, 200.0], [270.0] * 3), - self.S, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 1, 2], _containers([300.0, 100.0, 200.0], [270.0] * 3), self.S, DECK, x_tolerance=0.1 + ) xs = [b.x_position for b in batches] self.assertEqual(xs, sorted(xs)) @@ -254,13 +274,15 @@ def test_mismatched_lengths_raises(self): plan_batches([0, 1], _containers([100.0], [200.0]), self.S, DECK, x_tolerance=0.1) def test_duplicate_channels_serialized(self): - batches = plan_batches([0, 0], _containers([100.0] * 2, [200.0] * 2), - self.S, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 0], _containers([100.0] * 2, [200.0] * 2), self.S, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 2) def test_duplicate_channels_three_ops(self): - batches = plan_batches([0, 0, 0], _containers([100.0] * 3, [200.0] * 3), - self.S, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 0, 0], _containers([100.0] * 3, [200.0] * 3), self.S, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 3) def test_phantoms_interpolated_at_spacing(self): @@ -268,7 +290,9 @@ def test_phantoms_interpolated_at_spacing(self): batches = plan_batches( [0, 1, 2, 5, 6, 7], _containers([100.0] * 6, [300.0, 291.0, 282.0, 255.0, 246.0, 237.0]), - [9.0] * 8, DECK, x_tolerance=0.1, + [9.0] * 8, + DECK, + x_tolerance=0.1, ) self.assertEqual(len(batches), 1) y = batches[0].y_positions @@ -279,16 +303,22 @@ def test_phantoms_interpolated_at_spacing(self): def test_phantoms_only_within_batch(self): # Two separate batches — no phantoms bridging them. - batches = plan_batches([0, 3], _containers([100.0] * 2, [200.0, 250.0]), - [9.0] * 4, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 3], _containers([100.0] * 2, [200.0, 250.0]), [9.0] * 4, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 2) for batch in batches: self.assertEqual(len(batch.y_positions), 1) def test_indices_map_back_correctly(self): use_channels = [3, 7, 0] - batches = plan_batches(use_channels, _containers([100.0] * 3, [261.0, 237.0, 270.0]), - [9.0] * 8, DECK, x_tolerance=0.1) + batches = plan_batches( + use_channels, + _containers([100.0] * 3, [261.0, 237.0, 270.0]), + [9.0] * 8, + DECK, + x_tolerance=0.1, + ) all_indices = [idx for b in batches for idx in b.indices] self.assertEqual(sorted(all_indices), [0, 1, 2]) for batch in batches: @@ -298,25 +328,29 @@ def test_indices_map_back_correctly(self): def test_mixed_spacing_boundary(self): # 1mL (ch0,1) + 5mL (ch2,3): 18mm required between ch1 and ch2. sp = [8.98, 8.98, 17.96, 17.96] - batches = plan_batches([1, 2], _containers([100.0] * 2, [217.9, 200.0]), - sp, DECK, x_tolerance=0.1) + batches = plan_batches( + [1, 2], _containers([100.0] * 2, [217.9, 200.0]), sp, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 2) - batches = plan_batches([1, 2], _containers([100.0] * 2, [218.0, 200.0]), - sp, DECK, x_tolerance=0.1) + batches = plan_batches( + [1, 2], _containers([100.0] * 2, [218.0, 200.0]), sp, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) def test_mixed_phantoms_use_pairwise_spacing(self): sp = [8.98, 8.98, 17.96, 17.96] - batches = plan_batches([0, 3], _containers([100.0] * 2, [245.0, 200.0]), - sp, DECK, x_tolerance=0.1) + batches = plan_batches( + [0, 3], _containers([100.0] * 2, [245.0, 200.0]), sp, DECK, x_tolerance=0.1 + ) self.assertEqual(len(batches), 1) y = batches[0].y_positions # ch0→ch1: 9.0, ch1→ch2: 18.0 self.assertAlmostEqual(y[1], 245.0 - 9.0) self.assertAlmostEqual(y[2], 245.0 - 9.0 - 18.0) - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling." - "compute_nonconsecutive_channel_offsets") + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" + ) def test_auto_spread_same_container(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] trough = _mock_container(100.0, 200.0, size_y=50.0, name="trough") @@ -339,13 +373,13 @@ class TestPlanBatchesNoGoZones(unittest.TestCase): S = [9.0] * 8 - @patch("pylabrobot.liquid_handling.pipette_batch_scheduling." - "compute_nonconsecutive_channel_offsets") + @patch( + "pylabrobot.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" + ) def test_fits_pair_but_not_triple_in_container(self, mock_offsets): # Container fits any adjacent pair but not three channels at once. def fake_offsets(container, channels, spacings): - if len(channels) <= 2 and channels == sorted(channels) \ - and channels[-1] - channels[0] <= 1: + if len(channels) <= 2 and channels == sorted(channels) and channels[-1] - channels[0] <= 1: return [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)][: len(channels)] return None From dbc6d95e1a21bdfc0725236b2542ecb005afd787 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 17 Apr 2026 17:22:48 -0700 Subject: [PATCH 36/39] Review fixes: log_batches, deprecate move_to_z_safety_after, restore spacing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename print_batches → log_batches (uses logger.info, no custom label) - Remove verbose parameter from execute_batched and probe_liquid_heights - Remove LLDMode alias from chatterbox, remove chatterbox probe_liquid_heights override - Deprecate move_to_z_safety_after (warn-only), migrate internal callers to z_position_at_end_of_command - Restore TestChannelsMinimumYSpacing tests (can_reach_position, position_channels_in_y_direction) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backends/hamilton/STAR_backend.py | 45 ++++---- .../backends/hamilton/STAR_chatterbox.py | 21 +--- .../backends/hamilton/STAR_tests.py | 102 ++++++++++++++++++ .../pipette_batch_scheduling.py | 18 ++-- 4 files changed, 136 insertions(+), 50 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 5ab10a1f36d..e95489ed528 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -50,7 +50,7 @@ from pylabrobot.liquid_handling.pipette_batch_scheduling import ( ChannelBatch, plan_batches, - print_batches, + log_batches, validate_channel_selections, ) from pylabrobot.liquid_handling.standard import ( @@ -2062,7 +2062,6 @@ async def execute_batched( func: Callable[[ChannelBatch], Awaitable[T]], batches: List[ChannelBatch], min_traverse_height_during_command: Optional[float] = None, - verbose: bool = False, ) -> List[T]: """Execute a Z-axis callback across pre-planned batches with X/Y positioning. @@ -2076,13 +2075,11 @@ async def execute_batched( batches: Pre-planned batches from ``plan_batches()``. min_traverse_height_during_command: Absolute Z height (mm) for inter-batch channel raises. ``None`` uses full Z safety. - verbose: If True, print the batch execution plan before running. Returns: List of results from each batch callback, in batch order. """ - if verbose: - print_batches(batches, label="execute_batched plan") + log_batches(batches) results: List[T] = [] try: prev_batch: Optional[ChannelBatch] = None @@ -2257,13 +2254,13 @@ async def probe_liquid_heights( lld_mode: Union[LLDMode, List[LLDMode], None] = None, search_speed: float = 10.0, n_replicates: int = 1, - move_to_z_safety_after: bool = True, # Traverse height parameters (None = full Z safety, float = absolute Z position in mm) min_traverse_height_at_beginning_of_command: Optional[float] = None, min_traverse_height_during_command: Optional[float] = None, z_position_at_end_of_command: Optional[float] = None, x_grouping_tolerance: Optional[float] = None, - verbose: bool = False, + # Deprecated + move_to_z_safety_after: Optional[bool] = None, ) -> List[float]: """Probe liquid surface heights in containers using liquid level detection. @@ -2286,9 +2283,6 @@ async def probe_liquid_heights( GAMMA to all containers. search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. n_replicates: Number of measurements per channel. Default 1. - move_to_z_safety_after: Whether to move channels to safe Z height after probing. - Set to False when probing is immediately followed by another Z operation (e.g. - aspirate) to avoid unnecessary Z travel. Default True. min_traverse_height_at_beginning_of_command: Absolute Z height (mm) to move involved channels to before the first batch. Must clear all deck obstacles since channels travel laterally at this height. None (default) uses full Z safety. @@ -2298,8 +2292,7 @@ async def probe_liquid_heights( probing. None (default) uses full Z safety. x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). - verbose: If True, print the batch execution plan before running. - + move_to_z_safety_after: Deprecated. Use ``z_position_at_end_of_command`` instead. Returns: Mean of measured liquid heights for each container (mm from cavity bottom). @@ -2309,6 +2302,16 @@ async def probe_liquid_heights( RuntimeError: If any specified channel lacks a tip. """ + if move_to_z_safety_after is not None: + warnings.warn( + "The 'move_to_z_safety_after' parameter is deprecated and will be removed in a " + "future release. Use 'z_position_at_end_of_command' with an appropriate Z height " + "instead. If not set, the default behavior will be to move to full Z safety after " + "the command.", + DeprecationWarning, + stacklevel=2, + ) + if n_replicates < 1: raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") if use_channels is not None and len(use_channels) != len(set(use_channels)): @@ -2365,7 +2368,6 @@ async def probe_liquid_heights( ), batches=batches, min_traverse_height_during_command=min_traverse_height_during_command, - verbose=verbose, ) absolute_heights_measurements: Dict[int, List[Optional[float]]] = {} @@ -2398,13 +2400,12 @@ async def probe_liquid_heights( + "\n".join(inconsistent_channels) ) - if move_to_z_safety_after: - if z_position_at_end_of_command is None: - await self.move_all_channels_in_z_safety() - else: - await self.position_channels_in_z_direction( - {ch: z_position_at_end_of_command for ch in use_channels} - ) + if z_position_at_end_of_command is not None: + await self.position_channels_in_z_direction( + {ch: z_position_at_end_of_command for ch in use_channels} + ) + else: + await self.move_all_channels_in_z_safety() return relative_to_well @@ -2895,7 +2896,7 @@ async def aspirate( containers=[op.resource for op in ops], use_channels=use_channels, resource_offsets=[op.offset for op in ops], - move_to_z_safety_after=False, + z_position_at_end_of_command=100, ) # override minimum traversal height because we don't want to move channels up. we are already above the liquid. @@ -3257,7 +3258,7 @@ async def dispense( containers=[op.resource for op in ops], use_channels=use_channels, resource_offsets=[op.offset for op in ops], - move_to_z_safety_after=False, + z_position_at_end_of_command=100, ) # override minimum traversal height because we don't want to move channels up. we are already above the liquid. diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 1728e7e7895..04134a61f69 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -15,8 +15,6 @@ from pylabrobot.resources.container import Container from pylabrobot.resources.well import Well -# Type alias for nested enum (for cleaner signatures) -LLDMode = STARBackend.LLDMode _DEFAULT_MACHINE_CONFIGURATION = MachineConfiguration( pip_type_1000ul=True, @@ -322,23 +320,6 @@ async def request_tip_len_on_channel(self, channel_idx: int) -> float: async def position_channels_in_y_direction(self, ys, make_space=True): print("positioning channels in y:", ys, "make_space:", make_space) - async def probe_liquid_heights( - self, - containers, - use_channels=None, - resource_offsets=None, - lld_mode=None, - search_speed=10.0, - n_replicates=1, - min_traverse_height_at_beginning_of_command=None, - min_traverse_height_during_command=None, - z_position_at_end_of_command=None, - move_to_z_safety_after=None, - ) -> List[float]: - """Return liquid heights from the volume tracker using each container's - height-from-volume function. No physical probing in simulation.""" - return [c.compute_height_from_volume(c.tracker.get_used_volume()) for c in containers] - async def request_pip_height_last_lld(self): return list(range(12)) @@ -349,7 +330,7 @@ async def _run_lld_on_channel_batch( tip_lengths: List[float], z_cavity_bottom: List[float], z_top: List[float], - lld_mode: List[LLDMode], + lld_mode: List["STARBackend.LLDMode"], search_speed: float, n_replicates: int, ) -> Dict[int, List[Optional[float]]]: diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 1e81f76cb90..85c3adf500b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -1534,6 +1534,108 @@ async def test_1000uL_tips(self): tip_rack.unassign() +class TestChannelsMinimumYSpacing(unittest.IsolatedAsyncioTestCase): + """Test that different channel spacing configurations produce different behavior. + + Real firmware VY responses captured from hardware (GitHub issue #822): + - 4-channel 18mm single-rail: PVYidyc194 388 1 (yc[1]=388 → 18.0mm) + - 8-channel 9mm standard: PVYidyc000 194 0 (yc[1]=194 → 9.0mm) + """ + + # -- can_reach_position: reachability shrinks with wider spacing ---------------- + + async def test_can_reach_4ch_18mm_rejects_position_reachable_at_9mm(self): + """A position reachable by channel 0 at 9mm spacing is unreachable at 18mm spacing. + + Channel 0 (backmost) min_y = left_arm_min_y_position + sum(spacings[1..3]) + At 9mm: 6 + 9*3 = 33 → y=33 reachable + At 18mm: 6 + 18*3 = 60 → y=33 unreachable + """ + backend = STARBackend() + backend._num_channels = 4 + backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + + backend._channels_minimum_y_spacing = [9.0] * 4 + self.assertTrue(backend.can_reach_position(0, Coordinate(100, 33, 100))) + + backend._channels_minimum_y_spacing = [18.0] * 4 + self.assertFalse(backend.can_reach_position(0, Coordinate(100, 33, 100))) + + async def test_can_reach_4ch_18mm_rejects_back_channel_too_far_back(self): + """At 18mm spacing, the backmost channel has a lower max_y than at 9mm. + + Channel 3 (frontmost) max_y = pip_maximal_y_position - sum(spacings[0..2]) + At 9mm: 606.5 - 9*3 = 579.5 → y=574 reachable + At 18mm: 606.5 - 18*3 = 552.5 → y=574 unreachable + """ + backend = STARBackend() + backend._num_channels = 4 + backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + + backend._channels_minimum_y_spacing = [9.0] * 4 + self.assertTrue(backend.can_reach_position(3, Coordinate(100, 574, 100))) + + backend._channels_minimum_y_spacing = [18.0] * 4 + self.assertFalse(backend.can_reach_position(3, Coordinate(100, 574, 100))) + + # -- position_channels_in_y_direction: validation rejects tight positions ------- + + def _make_star_backend(self, num_channels, spacings): + """Helper: create a STARBackend with given channel count and spacings, mocking I/O.""" + backend = STARBackend() + backend._num_channels = num_channels + backend._channels_minimum_y_spacing = list(spacings) + backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION + backend.id_ = 0 + backend._write_and_read_command = unittest.mock.AsyncMock() + backend.get_channels_y_positions = unittest.mock.AsyncMock() + return backend + + async def test_position_channels_rejects_9mm_gap_when_spacing_is_18mm(self): + """With make_space=False, channels 9mm apart pass validation at 9mm but are rejected at 18mm.""" + spread_positions = {0: 100.0, 1: 91.0, 2: 82.0, 3: 73.0} + + # At 9mm: channels spaced 9mm apart → valid, JY command is sent. + backend_9 = self._make_star_backend(4, [9.0] * 4) + backend_9.get_channels_y_positions.return_value = dict(spread_positions) + await backend_9.position_channels_in_y_direction(spread_positions, make_space=False) + self.assertTrue(backend_9._write_and_read_command.called) + + # At 18mm: same positions → rejected. + backend_18 = self._make_star_backend(4, [18.0] * 4) + backend_18.get_channels_y_positions.return_value = dict(spread_positions) + with self.assertRaises(ValueError): + await backend_18.position_channels_in_y_direction(spread_positions, make_space=False) + + async def test_position_channels_make_space_spreads_wider_at_18mm(self): + """make_space=True pushes non-target channels further apart at 18mm than at 9mm. + + Move only channel 2 to y=40. make_space adjusts channels 3 (in front of channel 2) + to respect minimum spacing. At 9mm it pushes channel 3 to 31, at 18mm to 22. + """ + current = {0: 300.0, 1: 200.0, 2: 100.0, 3: 50.0} + requested = {2: 40.0} + + # At 9mm: channel 3 must be ≤ 40 - 9 = 31. + backend_9 = self._make_star_backend(4, [9.0] * 4) + backend_9.get_channels_y_positions.return_value = dict(current) + await backend_9.position_channels_in_y_direction(dict(requested), make_space=True) + cmd_9mm = backend_9._write_and_read_command.call_args.kwargs["cmd"] + # Channel 3 pushed to 31.0 → 310 increments. + self.assertIn("0310", cmd_9mm) + + # At 18mm: channel 3 must be ≤ 40 - 18 = 22. + backend_18 = self._make_star_backend(4, [18.0] * 4) + backend_18.get_channels_y_positions.return_value = dict(current) + await backend_18.position_channels_in_y_direction(dict(requested), make_space=True) + cmd_18mm = backend_18._write_and_read_command.call_args.kwargs["cmd"] + # Channel 3 pushed to 22.0 → 220 increments. + self.assertIn("0220", cmd_18mm) + + # The JY commands must differ. + self.assertNotEqual(cmd_9mm, cmd_18mm) + + class TestProbeLiquidHeights(unittest.IsolatedAsyncioTestCase): """Tests for probe_liquid_heights: detection dispatch, replicates, error handling.""" diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 216f9b5c3d7..045c219f6a4 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -24,11 +24,14 @@ await backend.execute_batched(func=my_z_callback, batches=batches) """ +import logging import math from collections import defaultdict from dataclasses import dataclass, field from typing import Collection, Dict, FrozenSet, List, Optional, Tuple +logger = logging.getLogger(__name__) + from pylabrobot.liquid_handling.channel_positioning import ( compute_nonconsecutive_channel_offsets, ) @@ -53,13 +56,12 @@ class ChannelBatch: y_positions: Dict[int, float] = field(default_factory=dict) # includes phantoms -def print_batches( +def log_batches( batches: List[ChannelBatch], use_channels: Optional[List[int]] = None, containers: Optional[List["Container"]] = None, - label: str = "plan", ) -> None: - """Print a tree view of the batch execution plan. + """Log a tree view of the batch execution plan. Groups batches by X position and shows Y batches nested within each X group. Active channels are marked with ``*``, phantoms with a space. @@ -70,7 +72,6 @@ def print_batches( container names are not shown next to active channels. containers: Container objects (parallel with *use_channels*). If omitted, container names are not shown next to active channels. - label: Header label for the tree. """ ch_to_container = ( @@ -84,27 +85,28 @@ def print_batches( x_key = round(b.x_position, 1) x_groups.setdefault(x_key, []).append(b) - print(f"{label}:") + lines = ["plan:"] xg_keys = list(x_groups.keys()) for xg_i, x_key in enumerate(xg_keys): xg_batches = x_groups[x_key] is_last_xg = xg_i == len(xg_keys) - 1 xg_branch = "└" if is_last_xg else "├" xg_cont = " " if is_last_xg else "│" - print(f" {xg_branch}── x-group {xg_i + 1} (x={x_key:.1f} mm)") + lines.append(f" {xg_branch}── x-group {xg_i + 1} (x={x_key:.1f} mm)") for yb_i, b in enumerate(xg_batches): is_last_yb = yb_i == len(xg_batches) - 1 yb_branch = "└" if is_last_yb else "├" yb_cont = " " if is_last_yb else "│" - print(f" {xg_cont} {yb_branch}── y-batch {yb_i + 1}") + lines.append(f" {xg_cont} {yb_branch}── y-batch {yb_i + 1}") for ch in sorted(b.y_positions.keys()): is_last_ch = ch == max(b.y_positions.keys()) ch_branch = "└" if is_last_ch else "├" active = "*" if ch in b.channels else " " container_name = f" ({ch_to_container[ch].name})" if ch in ch_to_container else "" - print( + lines.append( f" {xg_cont} {yb_cont} {ch_branch}── {active}ch{ch}: y={b.y_positions[ch]:.1f} mm{container_name}" ) + logger.info("\n".join(lines)) # --- Spacing helpers --- From 58d473dcda634b5e267e26e4298fccb60b03bbd6 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 21 Apr 2026 22:22:57 -0700 Subject: [PATCH 37/39] Review fixes: canonical x_position, support duplicate channels, deprecate move_to_z_safety_after on probe_liquid_volumes - plan_batches: snap x_position to one representative per X bucket so log_batches and execute_batched can compare by equality without drift from per-batch averaging - log_batches: drop round(x, 1), use itertools.groupby on sorted batches - execute_batched: use math.isclose for the X-move guard - probe_liquid_heights: remove duplicate-channel rejection, key batch results by job index so the same physical channel can probe multiple containers per call - probe_liquid_volumes: add z_position_at_end_of_command, deprecate move_to_z_safety_after (stops spamming the heights-level deprecation warning) - STAR assertion at max_y_search_pos_increments: report mm value, drop bogus _channels_minimum_y_spacing[0] term from the upper-bound label - pipette_batch_scheduling: move logger below imports to satisfy ruff E402 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backends/hamilton/STAR_backend.py | 50 +++++++++++-------- .../backends/hamilton/STAR_chatterbox.py | 6 +-- .../backends/hamilton/STAR_tests.py | 24 +++++++++ .../pipette_batch_scheduling.py | 32 ++++++++---- 4 files changed, 79 insertions(+), 33 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 6bae730b770..4e89dfaed32 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -3,6 +3,7 @@ import enum import functools import logging +import math import re import sys import warnings @@ -2132,7 +2133,7 @@ async def execute_batched( {ch: min_traverse_height_during_command for ch in prev_batch.channels} ) - if prev_batch is None or batch.x_position != prev_batch.x_position: + if prev_batch is None or not math.isclose(batch.x_position, prev_batch.x_position): await self.move_channel_x(0, batch.x_position) await self.position_channels_in_y_direction(batch.y_positions) @@ -2223,9 +2224,10 @@ async def _run_lld_on_channel_batch( *lld_mode* is indexed by original container position (``batch.indices[i]``), so channels within a single batch may use different detection modes concurrently. - Returns absolute heights (one list per channel, ``None`` entries for replicates - where no liquid was detected). The caller subtracts ``z_cavity_bottom`` to get - heights relative to container bottom. + Returns absolute heights keyed by job index (``batch.indices[i]``), so duplicate + channels across batches don't collide. One list per job, with ``None`` entries for + replicates where no liquid was detected. The caller subtracts ``z_cavity_bottom`` + to get heights relative to container bottom. """ def _detect_func(mode: "STARBackend.LLDMode") -> Callable[..., Any]: @@ -2243,7 +2245,7 @@ def _detect_func(mode: "STARBackend.LLDMode") -> Callable[..., Any]: for i in batch.indices ] - measurements: Dict[int, List[Optional[float]]] = {ch: [] for ch in batch.channels} + measurements: Dict[int, List[Optional[float]]] = {orig_idx: [] for orig_idx in batch.indices} for _ in range(n_replicates): results = await asyncio.gather( @@ -2282,7 +2284,7 @@ def _detect_func(mode: "STARBackend.LLDMode") -> Callable[..., Any]: raise result else: height = current_absolute_liquid_heights[ch_idx] - measurements[ch_idx].append(height) + measurements[orig_idx].append(height) return measurements @@ -2354,12 +2356,6 @@ async def probe_liquid_heights( if n_replicates < 1: raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") - if use_channels is not None and len(use_channels) != len(set(use_channels)): - raise ValueError( - f"Duplicate channels in use_channels {use_channels}: each physical channel " - f"can only probe one container per call. To probe more containers than available " - f"channels, call probe_liquid_heights multiple times in sequence." - ) if lld_mode is None: lld_mode = [self.LLDMode.GAMMA] * len(containers) @@ -2412,15 +2408,15 @@ async def probe_liquid_heights( absolute_heights_measurements: Dict[int, List[Optional[float]]] = {} for batch_measurements in batch_results: - for ch, heights in batch_measurements.items(): - absolute_heights_measurements.setdefault(ch, []).extend(heights) + for orig_idx, heights in batch_measurements.items(): + absolute_heights_measurements.setdefault(orig_idx, []).extend(heights) # Compute liquid heights relative to well bottom relative_to_well: List[float] = [] inconsistent_channels: List[str] = [] for idx, (ch, container) in enumerate(zip(use_channels, containers)): - measurements = absolute_heights_measurements[ch] + measurements = absolute_heights_measurements[idx] valid = [m for m in measurements if m is not None] cavity_bottom = z_cavity_bottom[idx] @@ -2457,7 +2453,9 @@ async def probe_liquid_volumes( lld_mode: LLDMode = LLDMode.GAMMA, search_speed: float = 10.0, n_replicates: int = 3, - move_to_z_safety_after: bool = True, + z_position_at_end_of_command: Optional[float] = None, + # Deprecated + move_to_z_safety_after: Optional[bool] = None, ) -> List[float]: """Probe liquid volumes in containers by measuring heights and converting to volumes. @@ -2472,7 +2470,9 @@ async def probe_liquid_volumes( lld_mode: Detection mode - LLDMode(1) for capacitive, LLDMode(2) for pressure-based. Defaults to capacitive. search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. n_replicates: Number of measurements per channel. Default 3. - move_to_z_safety_after: Whether to move channels to safe Z height after probing. Default True. + z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after + probing. None (default) uses full Z safety. + move_to_z_safety_after: Deprecated. Use ``z_position_at_end_of_command`` instead. Returns: Volumes in each container (uL). @@ -2485,6 +2485,16 @@ async def probe_liquid_volumes( - All containers must support height-volume functions. Volume calculation uses Container.compute_volume_from_height() """ + if move_to_z_safety_after is not None: + warnings.warn( + "The 'move_to_z_safety_after' parameter is deprecated and will be removed in a " + "future release. Use 'z_position_at_end_of_command' with an appropriate Z height " + "instead. If not set, the default behavior will be to move to full Z safety after " + "the command.", + DeprecationWarning, + stacklevel=2, + ) + if any(not resource.supports_compute_height_volume_functions() for resource in containers): raise ValueError( "probe_liquid_volumes can only be used with containers that support height<->volume functions." @@ -2497,7 +2507,7 @@ async def probe_liquid_volumes( lld_mode=lld_mode, search_speed=search_speed, n_replicates=n_replicates, - move_to_z_safety_after=move_to_z_safety_after, + z_position_at_end_of_command=z_position_at_end_of_command, ) return [ @@ -10752,8 +10762,8 @@ async def clld_probe_y_position_using_channel( # Machine-compatibility check of calculated parameters assert 0 <= max_y_search_pos_increments <= 13_714, ( "Maximum y search position must be between 0 and " - + f"{STARBackend.y_drive_increment_to_mm(13_714) + self._channels_minimum_y_spacing[0]:.1f} mm, " - + f"is {STARBackend.y_drive_increment_to_mm(max_y_search_pos_increments):.1f} mm" + + f"{STARBackend.y_drive_increment_to_mm(13_714):.1f} mm, " + + f"is {max_y_search_pos:.1f} mm" ) assert 20 <= channel_speed_increments <= 8_000, ( f"LLD search speed must be between \n{STARBackend.y_drive_increment_to_mm(20)}" diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 04134a61f69..23a75e4b39c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -340,13 +340,13 @@ async def _run_lld_on_channel_batch( containers report ``cavity_bottom + compute_height_from_volume(volume)`` so the parent ``probe_liquid_heights`` can subtract ``z_cavity_bottom`` consistently. """ - measurements: Dict[int, List[Optional[float]]] = {ch: [] for ch in batch.channels} - for local_idx, (ch, orig_idx) in enumerate(zip(batch.channels, batch.indices)): + measurements: Dict[int, List[Optional[float]]] = {} + for orig_idx in batch.indices: container = containers[orig_idx] volume = container.tracker.get_used_volume() if volume == 0: absolute_height = z_cavity_bottom[orig_idx] else: absolute_height = z_cavity_bottom[orig_idx] + container.compute_height_from_volume(volume) - measurements[ch] = [absolute_height] * n_replicates + measurements[orig_idx] = [absolute_height] * n_replicates return measurements diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 85c3adf500b..5a020c09d59 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -1836,6 +1836,30 @@ async def test_pressure_lld_mode(self): entered["plld"].assert_awaited_once() + async def test_duplicate_channels_serialize_measurements(self): + """Same physical channel probing two wells in one call: results don't collide.""" + well_a = self.plate.get_item("A1") + well_b = self.plate.get_item("B1") + self._put_tips_on_channels([0]) + + mocks = self._standard_mocks() + with contextlib.ExitStack() as stack: + for v in mocks.values(): + stack.enter_context(v) + result = await self.STAR.probe_liquid_heights( + containers=[well_a, well_b], + use_channels=[0, 0], + ) + + # Two jobs, one result each, keyed by job index not channel. + self.assertEqual(len(result), 2) + self.assertAlmostEqual( + result[0], 0 - well_a.get_absolute_location("c", "c", "cavity_bottom").z + ) + self.assertAlmostEqual( + result[1], 0 - well_b.get_absolute_location("c", "c", "cavity_bottom").z + ) + class TestChannelsMinimumYSpacing(unittest.IsolatedAsyncioTestCase): """Test that different channel spacing configurations produce different behavior. diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 045c219f6a4..566587f1ce5 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -24,14 +24,13 @@ await backend.execute_batched(func=my_z_callback, batches=batches) """ +import itertools import logging import math from collections import defaultdict from dataclasses import dataclass, field from typing import Collection, Dict, FrozenSet, List, Optional, Tuple -logger = logging.getLogger(__name__) - from pylabrobot.liquid_handling.channel_positioning import ( compute_nonconsecutive_channel_offsets, ) @@ -39,6 +38,8 @@ from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.resource import Resource +logger = logging.getLogger(__name__) + # --- Data types --- @@ -80,16 +81,14 @@ def log_batches( else {} ) - x_groups: Dict[float, list] = {} - for b in batches: - x_key = round(b.x_position, 1) - x_groups.setdefault(x_key, []).append(b) + x_groups = [ + (x_key, list(group)) + for x_key, group in itertools.groupby(batches, key=lambda b: b.x_position) + ] lines = ["plan:"] - xg_keys = list(x_groups.keys()) - for xg_i, x_key in enumerate(xg_keys): - xg_batches = x_groups[x_key] - is_last_xg = xg_i == len(xg_keys) - 1 + for xg_i, (x_key, xg_batches) in enumerate(x_groups): + is_last_xg = xg_i == len(x_groups) - 1 xg_branch = "└" if is_last_xg else "├" xg_cont = " " if is_last_xg else "│" lines.append(f" {xg_branch}── x-group {xg_i + 1} (x={x_key:.1f} mm)") @@ -431,5 +430,18 @@ def plan_batches( ) partition = minimum_exact_cover(len(use_channels), valid) + + # Snap batches sharing an X bucket to one representative x_position so downstream + # consumers can compare by equality instead of tolerance. Each batch's x_position + # starts as the mean of its own jobs' x-coords, so averages across batches in the + # same bucket drift within x_tolerance; collapse them to a shared value here. + buckets: Dict[int, List[ChannelBatch]] = defaultdict(list) + for b in partition: + buckets[math.floor(b.x_position / x_tolerance)].append(b) + for group in buckets.values(): + shared_x = sum(b.x_position for b in group) / len(group) + for b in group: + b.x_position = shared_x + partition.sort(key=lambda b: b.x_position) return partition From d6048a44d955928fa3f01aa9bd13fc1a4dc267f8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 23 Apr 2026 22:42:55 -0700 Subject: [PATCH 38/39] Remove duplicate TestChannelsMinimumYSpacing class in STAR_tests The class was defined twice after a merge, causing ruff F811 and mypy no-redef errors. Kept the copy positioned with related tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backends/hamilton/STAR_tests.py | 103 ------------------ 1 file changed, 103 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 5a020c09d59..a8edc4292f2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -1859,106 +1859,3 @@ async def test_duplicate_channels_serialize_measurements(self): self.assertAlmostEqual( result[1], 0 - well_b.get_absolute_location("c", "c", "cavity_bottom").z ) - - -class TestChannelsMinimumYSpacing(unittest.IsolatedAsyncioTestCase): - """Test that different channel spacing configurations produce different behavior. - - Real firmware VY responses captured from hardware (GitHub issue #822): - - 4-channel 18mm single-rail: PVYidyc194 388 1 (yc[1]=388 → 18.0mm) - - 8-channel 9mm standard: PVYidyc000 194 0 (yc[1]=194 → 9.0mm) - """ - - # -- can_reach_position: reachability shrinks with wider spacing ---------------- - - async def test_can_reach_4ch_18mm_rejects_position_reachable_at_9mm(self): - """A position reachable by channel 0 at 9mm spacing is unreachable at 18mm spacing. - - Channel 0 (backmost) max_y = 601.6 - sum(spacings[0..0]) = 601.6 - 0 = 601.6 (same) - Channel 0 (backmost) min_y = 6 + sum(spacings[1..3]) - At 9mm: 6 + 9*3 = 33 → y=33 reachable - At 18mm: 6 + 18*3 = 60 → y=33 unreachable - """ - backend = STARBackend() - backend._num_channels = 4 - backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION - - backend._channels_minimum_y_spacing = [9.0] * 4 - self.assertTrue(backend.can_reach_position(0, Coordinate(100, 33, 100))) - - backend._channels_minimum_y_spacing = [18.0] * 4 - self.assertFalse(backend.can_reach_position(0, Coordinate(100, 33, 100))) - - async def test_can_reach_4ch_18mm_rejects_back_channel_too_far_back(self): - """At 18mm spacing, the backmost channel has a lower max_y than at 9mm. - - Channel 3 (frontmost) max_y = pip_maximal_y_position - sum(spacings[0..2]) - At 9mm: 606.5 - 9*3 = 579.5 → y=574 reachable - At 18mm: 606.5 - 18*3 = 552.5 → y=574 unreachable - """ - backend = STARBackend() - backend._num_channels = 4 - backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION - - backend._channels_minimum_y_spacing = [9.0] * 4 - self.assertTrue(backend.can_reach_position(3, Coordinate(100, 574, 100))) - - backend._channels_minimum_y_spacing = [18.0] * 4 - self.assertFalse(backend.can_reach_position(3, Coordinate(100, 574, 100))) - - # -- position_channels_in_y_direction: validation rejects tight positions ------- - - def _make_star_backend(self, num_channels, spacings): - """Helper: create a STARBackend with given channel count and spacings, mocking I/O.""" - backend = STARBackend() - backend._num_channels = num_channels - backend._channels_minimum_y_spacing = list(spacings) - backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION - backend.id_ = 0 - backend._write_and_read_command = unittest.mock.AsyncMock() - backend.get_channels_y_positions = unittest.mock.AsyncMock() - return backend - - async def test_position_channels_rejects_9mm_gap_when_spacing_is_18mm(self): - """With make_space=False, channels 9mm apart pass validation at 9mm but are rejected at 18mm.""" - spread_positions = {0: 100.0, 1: 91.0, 2: 82.0, 3: 73.0} - - # At 9mm: channels spaced 9mm apart → valid, JY command is sent. - backend_9 = self._make_star_backend(4, [9.0] * 4) - backend_9.get_channels_y_positions.return_value = dict(spread_positions) - await backend_9.position_channels_in_y_direction(spread_positions, make_space=False) - self.assertTrue(backend_9._write_and_read_command.called) - - # At 18mm: same positions → rejected. - backend_18 = self._make_star_backend(4, [18.0] * 4) - backend_18.get_channels_y_positions.return_value = dict(spread_positions) - with self.assertRaises(ValueError): - await backend_18.position_channels_in_y_direction(spread_positions, make_space=False) - - async def test_position_channels_make_space_spreads_wider_at_18mm(self): - """make_space=True pushes non-target channels further apart at 18mm than at 9mm. - - Move only channel 2 to y=40. make_space adjusts channels 3 (in front of channel 2) - to respect minimum spacing. At 9mm it pushes channel 3 to 31, at 18mm to 22. - """ - current = {0: 300.0, 1: 200.0, 2: 100.0, 3: 50.0} - requested = {2: 40.0} - - # At 9mm: channel 3 must be ≤ 40 - 9 = 31. - backend_9 = self._make_star_backend(4, [9.0] * 4) - backend_9.get_channels_y_positions.return_value = dict(current) - await backend_9.position_channels_in_y_direction(dict(requested), make_space=True) - cmd_9mm = backend_9._write_and_read_command.call_args.kwargs["cmd"] - # Channel 3 pushed to 31.0 → 310 increments. - self.assertIn("0310", cmd_9mm) - - # At 18mm: channel 3 must be ≤ 40 - 18 = 22. - backend_18 = self._make_star_backend(4, [18.0] * 4) - backend_18.get_channels_y_positions.return_value = dict(current) - await backend_18.position_channels_in_y_direction(dict(requested), make_space=True) - cmd_18mm = backend_18._write_and_read_command.call_args.kwargs["cmd"] - # Channel 3 pushed to 22.0 → 220 increments. - self.assertIn("0220", cmd_18mm) - - # The JY commands must differ. - self.assertNotEqual(cmd_9mm, cmd_18mm) From 0f7a58390598c9c886b7675e8155079a1cb78857 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 24 Apr 2026 14:44:58 -0700 Subject: [PATCH 39/39] Apply ruff format and import sort Co-Authored-By: Claude Opus 4.7 (1M context) --- .../liquid_handling/backends/hamilton/STAR_backend.py | 2 +- .../liquid_handling/backends/hamilton/STAR_chatterbox.py | 1 - .../liquid_handling/backends/hamilton/STAR_tests.py | 8 ++------ pylabrobot/liquid_handling/pipette_batch_scheduling.py | 3 +-- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 01827fdca7b..3d0857461dd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -50,8 +50,8 @@ ) from pylabrobot.liquid_handling.pipette_batch_scheduling import ( ChannelBatch, - plan_batches, log_batches, + plan_batches, validate_channel_selections, ) from pylabrobot.liquid_handling.standard import ( diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index 23a75e4b39c..28624096ba8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -15,7 +15,6 @@ from pylabrobot.resources.container import Container from pylabrobot.resources.well import Well - _DEFAULT_MACHINE_CONFIGURATION = MachineConfiguration( pip_type_1000ul=True, kb_iswap_installed=True, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index a8edc4292f2..2bb9a8b0152 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -1853,9 +1853,5 @@ async def test_duplicate_channels_serialize_measurements(self): # Two jobs, one result each, keyed by job index not channel. self.assertEqual(len(result), 2) - self.assertAlmostEqual( - result[0], 0 - well_a.get_absolute_location("c", "c", "cavity_bottom").z - ) - self.assertAlmostEqual( - result[1], 0 - well_b.get_absolute_location("c", "c", "cavity_bottom").z - ) + self.assertAlmostEqual(result[0], 0 - well_a.get_absolute_location("c", "c", "cavity_bottom").z) + self.assertAlmostEqual(result[1], 0 - well_b.get_absolute_location("c", "c", "cavity_bottom").z) diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/liquid_handling/pipette_batch_scheduling.py index 566587f1ce5..a6f35e576b6 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/liquid_handling/pipette_batch_scheduling.py @@ -82,8 +82,7 @@ def log_batches( ) x_groups = [ - (x_key, list(group)) - for x_key, group in itertools.groupby(batches, key=lambda b: b.x_position) + (x_key, list(group)) for x_key, group in itertools.groupby(batches, key=lambda b: b.x_position) ] lines = ["plan:"]