Skip to content

Make STARBackend.probe_liquid_heights() Smart#876

Open
BioCam wants to merge 30 commits intoPyLabRobot:mainfrom
BioCam:make-probe_liquid_heights-smart
Open

Make STARBackend.probe_liquid_heights() Smart#876
BioCam wants to merge 30 commits intoPyLabRobot:mainfrom
BioCam:make-probe_liquid_heights-smart

Conversation

@BioCam
Copy link
Collaborator

@BioCam BioCam commented Feb 4, 2026

The Problem

probe_liquid_heights had several limitations that prevented it from handling real-world channel/container configurations:

  • Single X position only — probing containers at different X positions raised NotImplementedError, even though the method could handle them sequentially.
  • Channel-index-unaware offsets — when multiple channels targeted the same container (e.g. a trough), Y offsets were computed for "N channels" without knowing which channels. Non-consecutive
    channels like [0,1,2,5,6,7] were spaced as if they were [0,1,2,3,4,5], ignoring the physical gap required by phantom intermediate channels (3 and 4). If the container was too small to fit all
    channels, this raised an unhandled ValueError instead of falling back gracefully.
  • No Y-aware batching — channels that couldn't physically coexist (due to 9mm minimum spacing, descending-Y-order requirements, or unpositioned intermediate channels between non-consecutive batch
    members) would raise errors mid-command instead of being automatically partitioned into compatible batches.
  • No Z control — every lateral movement went to full Z safety with no option for faster traversal at a known safe height, and the final Z raise could not be customized or skipped.
  • No duplicate channel validation — passing the same channel twice could cause undefined behavior.

This PR

Makes probe_liquid_heights "smart" 🤓, i.e. automatically handle any channel/container configuration:

  1. Grouping by X — containers at different X positions are grouped (with 0.1mm float tolerance) and probed sequentially (single X carriage constraint).
  2. Y sub-batching with intermediate channel positioning — within each X group, channels are sorted by index and greedily partitioned into compatible sub-batches respecting the 9mm minimum spacing ×
    channel index difference. Phantom channels between non-consecutive batch members are explicitly positioned at minimum spacing to prevent firmware errors. Each sub-batch is probed in parallel.
  3. Channel-aware offsets — when all channels target the same container, Y offsets are computed for the full channel span (min to max, including phantoms) using
    get_wide_single_resource_liquid_op_offsets, then only the actual channel offsets are kept. Channels like [0,1,2,5,6,7] now correctly fit in one batch. If the container is too small, offsets fall
    back to center and Y sub-batching automatically serializes the channels that can't coexist.
  4. Z control — three new traverse height parameters allow customizing Z positioning at each stage, all defaulting to full Z safety. A move_to_z_safety_after flag allows skipping the final Z raise
    entirely. On any error, all channels return to full Z safety regardless of these settings.
Parameter Default Effect
min_traverse_height_at_beginning_of_command None → full Z safety Z height for involved channels before first detection
min_traverse_height_during_command None → full Z safety Z height between X groups and Y sub-batches
z_position_at_end_of_command None → full Z safety Z height for involved channels after all detections
move_to_z_safety_after True Skip final Z raise entirely when False
  1. allow_duplicate_channels — defaults to False with a clear error message; aspirate/dispense pass True since they may legitimately probe the same container multiple times.

No breaking changes — single-X, consecutive-channel usage produces identical behavior.

Pseudocode: full method flow

[!NOTE]

1. Defaults & Offsets

  • Default use_channels to [0, 1, ..., N-1] if not provided
  • If no resource_offsets given and all containers are the same instance:
    • Calculate the full channel span (including any phantom channels between non-consecutive ones)
    • If the container is wide enough to fit that span:
      • Spread all channels (real + phantom) wide across the container
      • Keep only the offsets for the actual channels being used
      • If odd span, shift everything +4.5mm to avoid the container center
    • Otherwise, fall back to center offsets — Y sub-batching will serialize

2. Validation

  • LLD mode must be GAMMA or PRESSURE
  • Containers, channels, and offsets must be equal length
  • No duplicate channels (unless explicitly allowed)
  • All channels must have tips attached
  • Query tip lengths sequentially (shared C0 module)

3. Initial Z Raise

  • Move all channels to full Z safety
  • Then optionally lower only the involved channels to min_traverse_height_at_beginning_of_command

4. Compute Target Positions

  • For each container + offset pair, calculate the absolute X and Y target

5. Group by X

  • Group targets by X position (with 0.1mm float tolerance)
  • One X carriage → groups are processed sequentially

6. Select Detection Function

  • GAMMA → capacitive liquid level detection (cLLD)
  • PRESSURE → pressure-based liquid level detection (pLLD)

7. Detection Loop

For each X group:

  • Raise previous group's channels (Z safety or custom traverse height)
  • Move X carriage to group's X position

Partition the group into Y sub-batches:

  • Sort by channel index ascending
  • Greedily assign each channel to the first batch where it fits
    (i.e. enough Y gap for the channel index difference × 9mm minimum spacing)

For each Y sub-batch:

  • Raise previous sub-batch's channels (Z safety or custom traverse height)
  • Position batch channels + any intermediate phantoms in Y
  • Compute Z search bounds (container top → cavity bottom, adjusted for tip length)
  • Run detection n_replicates times:
    • Probe all batch channels in parallel
    • Read detected heights
    • Record each result (height or None if no liquid found)

On any error → move all channels to full Z safety and re-raise

8. Aggregate Results

  • For each channel, average its valid measurements
  • Convert absolute heights to relative (above cavity bottom)
  • If a channel found liquid on some replicates but not others → raise inconsistency error

9. Final Z Raise

  • If move_to_z_safety_after:
    • Move involved channels to z_position_at_end_of_command, or full Z safety if not set

Return relative heights.

Copy link
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 enhances the probe_liquid_heights() method to automatically handle complex channel and container configurations that previously required manual batching or raised errors. The changes enable the method to intelligently group containers by X position, partition channels into compatible Y sub-batches, compute channel-aware offsets for non-consecutive channel configurations, and provide fine-grained Z-axis control during probing operations.

Changes:

  • Added automatic X-position grouping and Y sub-batching to handle any channel/container configuration
  • Implemented channel-index-aware offset calculation for non-consecutive channels (e.g., [0,1,2,5,6,7])
  • Added three new traverse height parameters and a flag to control Z positioning at different stages
  • Added duplicate channel validation with an override flag for legitimate multi-probe scenarios
  • Exposed all cLLD and pLLD detection parameters as method arguments

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

@BioCam
Copy link
Collaborator Author

BioCam commented Feb 6, 2026

This is step 3 from #822 (comment)

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,
Copy link
Member

Choose a reason for hiding this comment

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

how about two dataclasses, one for all plld and one for all clld parameters? this will be nicer to pass around, and also we can use it in STARBackend.aspirate and stuff in the future without having to repeat this whole list

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The problem I see with packaging everything up into dataclasses is that developers then need to perform an ever-growing list of new imports: just to perform an aspiration with pre-mix e.g. requires import Mix, import plld_parameters/import clld_parameters.
This repeats for error handling where plld/clld might switch depending on error handling logic (of only the subset of channels that have failed)

...it's not impossible, but it does add a lot of extra cognitive load on the developer and increases code written, even though, in most cases, I only want to switch 1-3 arguments between different commands

Copy link
Collaborator Author

@BioCam BioCam Feb 18, 2026

Choose a reason for hiding this comment

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

but this change should either way be its own PR, because this PR's responsibility is creating the new probing logic for complex well - channel mappings
(and acts as a test for how to scale this to all liquid handler actions across PLR)

Copy link
Member

Choose a reason for hiding this comment

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

so you mean moving parameters out of this method?

it is the topic for a separate PR, but for parameters something like plld=False or plld=True (use default values) or plld=PLLDParams(x=1) (y=2 default) makes sense. these parameters naturally go together (you either use clld or plld or both), so you don't want to allow specifying some parameters which are not even used. That is in addition to this then being easily shared across function calls because there will be more than one function using pLLD with this set of parameters. even in this function, it would really simplify things because extra_kwargs can largely be deleted.

Copy link
Collaborator Author

@BioCam BioCam Feb 20, 2026

Choose a reason for hiding this comment

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

I mean, this PR does not modify any liquid level detection behaviour - this PR is about action management:
it focuses only on fixing the current issue of being restricted to probing in only one x dimension, and the bug of not being able to automatically decide between sequential probing inside a container vs parallel probing inside a container based on the container's size_y and the total pipette spacing available.

(and the wider goal of this PR is to test this new "smart"/automatic batching algorithm, because it is applicable to all multi-pipette commands but needs a safe test case -> probing, as opposed to aspirate/dispense)

Happy to discuss modifying the liquid level detection parameter bundling in a separate PR

Copy link
Member

Choose a reason for hiding this comment

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

why is this deleted? unrelated change and also breaking

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

thank you for catching this! that is a bug, returned it now

Comment on lines +2023 to +2033
# Raise channels before moving X carriage (tips may be lowered from previous group)
if not is_first_x_group:
assert prev_indices is not None
if min_traverse_height_during_command is None:
await self.move_all_channels_in_z_safety()
else:
prev_channels = [use_channels[i] for i in prev_indices]
await self.position_channels_in_z_direction(
{ch: min_traverse_height_during_command for ch in prev_channels}
)
await self.move_channel_x(0, group_x)
Copy link
Member

Choose a reason for hiding this comment

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

why is is_first_x_group needed? the exact same code is executed above before entering this loop. this can be simplified by just putting it in the loop. (the only difference would be when x_groups is empty, but I would say the correct behavior in that case is to simply do nothing which is what this refactor would do.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't see where the exact same code is executed above?

is_first_x_group is needed to set min_traverse_height_during_command - this is a powerful parameter to save time in between movements of the same batch (i.e. usually above the same plate; no need to move all the way to safe z height and waste time).

@BioCam
Copy link
Collaborator Author

BioCam commented Feb 24, 2026

One of the toughest parts of this pipette/container "orchestration" is to encapsulate it so it can be applied to the main features I am going to work on next:

  • aspirate
  • dispense

Please let me know if you see a way that doesn't require re-implenentation of this behaviour for these commands, keeping their specific requirements in mind.

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