Skip to content

Encapsulate pipette batch scheduling into backend-agnostic module#949

Open
BioCam wants to merge 20 commits intoPyLabRobot:mainfrom
BioCam:encapsulate-pipette-batch-scheduling
Open

Encapsulate pipette batch scheduling into backend-agnostic module#949
BioCam wants to merge 20 commits intoPyLabRobot:mainfrom
BioCam:encapsulate-pipette-batch-scheduling

Conversation

@BioCam
Copy link
Copy Markdown
Collaborator

@BioCam BioCam commented Mar 20, 2026

Extracts batch scheduling logic from STARBackend into pipette_batch_scheduling.py - a standalone, backend-agnostic module with full test coverage.

The Problem

probe_liquid_heights relied on several tightly coupled private methods (execute_batched, _probe_liquid_heights_batch, _compute_channels_in_resource_locations, _move_to_traverse_height) embedded in STARBackend. The scheduling algorithm (X grouping, Y sub-batching) was inseparable from Hamilton-specific hardware calls, making it untestable in isolation and not easily reusable by other backends.

While per-channel spacing support was already merged (PRs #862, #870, #915), the scheduling layer had gaps: non-consecutive channel batches (e.g. [0,1,2,5,6,7]) left intermediate physical channels 3,4 unpositioned, Y batch feasibility used a single-pair check rather than full pairwise span validation, and there was no lookahead between batches.

PR Content/Solution

New module: pipette_batch_scheduling.py

plan_batches() partitions channel-position pairs into executable batches. Backend-agnostic - depends only on channel indices, positions, and spacing constraints.

New capabilities

  • Phantom channel interpolation - when a batch contains non-consecutive channels (e.g. [0,2,5]), the physically intermediate channels (1,3,4) are explicitly positioned at correct pairwise spacing. Previously they were left wherever they happened to be.
  • Pairwise span validation - batch feasibility checks the full span between outermost channels via _span_required (sum of adjacent pairwise spacings), not just the gap between the candidate and the batch's lowest-Y member. This matters for mixed-channel instruments where spacing(ch0->ch3) is s(0,1) + s(1,2) + s(2,3), not 3 * max(spacings).
  • Transition optimisation/lookahead - between batches, idle channels are pre-positioned toward their next-needed Y coordinate by scanning forward through upcoming batches. This is a pure performance optimization that reduces Y travel time; it does not affect correctness.
  • Configurable X grouping - replaces round(x, 1) (Python's banker's rounding) with math.floor(x / tolerance) * tolerance and exposes x_grouping_tolerance as a parameter.

Structural changes

  • probe_liquid_heights rewritten - replaces the 5-method delegation chain (execute_batched with callback closure -> _probe_liquid_heights_batch -> _compute_channels_in_resource_locations -> _move_to_traverse_height) with a single linear flow calling plan_batches(). The method is longer (262 vs 124 lines) but reads top-to-bottom without jumping between methods or files.
  • Replaces planning.py - the old module provided X grouping and Y sub-batching but lacked phantom interpolation, span validation, and transition lookahead. Deleted along with its tests.
  • Bug fix - spacings list sizing now covers num_channels (not just max(use_channels)+1), preventing IndexError in transition optimization.

Not in scope

Detection parameter exposure (cLLD/pLLD kwargs) is intentionally deferred to a follow-up PR to keep this change focused on scheduling encapsulation.

Preview

a small taste of the new functionalities (GitHub doesn't allow larger videos)

WellPlateScene_with_logo.mp4

BioCam and others added 7 commits March 12, 2026 22:53
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 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extracts pipette channel batching/scheduling logic from the Hamilton STARBackend into a new backend-agnostic module, and rewrites probe_liquid_heights to use the new planner while adding tests for the scheduling algorithm.

Changes:

  • Added pipette_batch_scheduling.py with plan_batches() (X grouping + Y sub-batching), phantom-channel interpolation, pairwise span validation, and optional batch-transition lookahead optimization.
  • Rewrote STARBackend.probe_liquid_heights() to use plan_batches() and the new helper utilities (input validation, offset computation, absolute position computation).
  • Replaced the old Hamilton-only planning.py and its tests with new dedicated unit tests for the new scheduling module.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
pylabrobot/liquid_handling/pipette_batch_scheduling.py New backend-agnostic batching/scheduling module (plan_batches, transition optimization, helpers).
pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py New unit tests covering spacing logic, phantom interpolation, batching, and transition optimization.
pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py Integrates new planner into probe_liquid_heights, adjusts spacing-related logic, removes legacy batching helpers.
pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py Adds missing mock methods and a simulation-friendly probe_liquid_heights implementation using shared validation.
pylabrobot/liquid_handling/backends/hamilton/planning.py Removed legacy Hamilton-only batching module.
pylabrobot/liquid_handling/backends/hamilton/planning_tests.py Removed tests for the deleted legacy planner.
pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py Refactors tests to exercise the new probe_liquid_heights flow (and rehomes some test classes).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 11473 to 11478
if spread == "wide":
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),
)
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In pierce_foil(spread='wide'), min_spacing is now max(self._channels_minimum_y_spacing) (global max across the whole head). This can overspread channels unnecessarily (or even beyond the well/plate geometry) when the operation uses only a subset of channels with smaller pairwise constraints. Restore the previous behavior by computing spacing based on the piercing_channels subset (e.g., maximum required adjacent-pair spacing across the involved physical span).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with the removal of execute_batched, the code becomes a lot less modular. and we do want to use execute_batched for other commands in the future

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting, the current execute_batched seemed very limited to probing actions, which is why I removed it, but you're saying that it actually acts as an abstraction layer for any function that is meant to be called after the channels have moved to their target locations - I really like this

I will work on an implementation

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I returned it and tried to make it even more adaptive:

Screenshot 2026-03-22 at 20 15 18

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with small adjustment to this workflow we can use it for...

  • probing (ztouch, cLLD, pLLD)
  • aspirate
  • dispense

BioCam and others added 2 commits March 22, 2026 14:40
…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
Comment on lines -2297 to +2114
# Deprecated
move_to_z_safety_after: Optional[bool] = None,
# X grouping tolerance (mm) — containers within this distance share an X group
x_grouping_tolerance: Optional[float] = None,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment should go in doctoring

Comment on lines +343 to +345
)
if len(use_channels) != len(set(use_channels)):
raise ValueError("use_channels must not contain duplicates.")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicate channels were supported in the past

Comment on lines +321 to +330
def validate_channel_selections(
containers: List[Container],
use_channels: Optional[List[int]],
num_channels: int,
) -> List[int]:
"""Validate and normalize channel selection.

If *use_channels* is ``None``, defaults to ``[0, 1, ..., len(containers)-1]``.

Returns:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function should probably live outside of batch scheduling, and can also be used elsewhere in LH

Comment on lines +2185 to +2191
# Compute Z positions
z_cavity_bottom: List[float] = []
z_top: List[float] = []
for resource in containers:
z_cavity_bottom.append(resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z)
z_top.append(resource.get_location_wrt(self.deck, "c", "c", "t").z)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list comprehension?

await self.position_channels_in_z_direction(
{channel: traverse_height for channel in channels}
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this pattern is recurring, even with liquid probing. why delete the helper?

Comment on lines +2253 to +2257
elif isinstance(result, Exception):
raise result
else:
height = current_absolute_liquid_heights[ch_idx]
absolute_heights_measurements[ch_idx].append(height)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this batch function is modifying code outside its scope, namely absolute_heights_measurements

Comment on lines +74 to +75
is_last_xg = xg_i == len(xg_keys) - 1
xg_branch = "\u2514" if is_last_xg else "\u251c"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are these unicode things?

Comment on lines +156 to +172
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 += _min_spacing_between(spacings, phantom - 1, phantom)
if phantom not in y_positions:
y_positions[phantom] = y_positions[ch_lo] - cumulative

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can also be used in position_channels_in_y_direction

functions that modify input parameters are dangerous, it is better to return a new list and not modify the original

@rickwierenga
Copy link
Copy Markdown
Member

Phantom channel interpolation - when a batch contains non-consecutive channels (e.g. [0,2,5]), the physically intermediate channels (1,3,4) are explicitly positioned at correct pairwise spacing. Previously they were left wherever they happened to be.

that is not entirely true since position_channels_in_y_direction would make space for them, but with this PR the positions are computed during planning which is nicer

Comment on lines -2181 to -2187
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how come this function is no longer needed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants