From d8babe30a7e3ef92d088beb609d38c1a16751ef7 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 12 Mar 2026 22:53:58 +0000 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] `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/10] 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.