Per-channel tip discard positions on deck instead of computed offsets

Currently, discard_tips() in liquid_handler.py works like this:

  trash = self.deck.get_trash_area()  # Single Trash resource                                                                                                                                                                                                                                                                                
  trash_offsets = get_tight_single_resource_liquid_op_offsets(trash, num_channels=n)                                                                                                                                                                                                                                                         
  return await self.drop_tips(tip_spots=[trash] * n, offsets=offsets, ...)                                                                                                                                                                                                                                                                   

This computes Y offsets at runtime to spread tips across a single trash resource using MIN_SPACING_BETWEEN_CHANNELS = 9mm. The deck only knows about one trash location; the individual per-channel positions are calculated on the fly.

The Nimbus approach

@Cody made a great PR adding the Nimbus (Nimbus Integration: TCP Backend and Interface Introspection by cmoscy · Pull Request #740 · PyLabRobot/pylabrobot · GitHub) where he took a different approach to this:

The Nimbus deck defines individual waste positions for each channel as first-class resources:

  # nimbus_decks.py
  waste_positions_hamilton = [                                                                                                                                                                                                                                                                                                               
      Coordinate(x=553.746, y=19.863, z=131.389),   # default_long_1                                                                                                                                                                                                                                                                         
      Coordinate(x=553.746, y=1.880, z=131.389),    # default_long_2                                                                                                                                                                                                                                                                         
      Coordinate(x=553.746, y=-76.149, z=131.389),  # default_long_3                                                                                                                                                                                                                                                                         
      ...                                                                                                                                                                                                                                                                                                                                    
      Coordinate(x=553.746, y=-237.532, z=131.389), # default_long_8                                                                                                                                                                                                                                                                         
  ]                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                             
  for i, locations in enumerate(locations, start=1):                                                                                                                                                                                                                                                                       
      waste_position = Trash(name=f"default_long_{i}", ...)                                                                                                                                                                                                                                                                                  
      waste_block.assign_child_resource(waste_position, location=location)                                                                                                                                                                                                                                                                

The backend then looks up the correct resource per channel:

  # nimbus_backend.py line 1263                                                                                                                                                                                                                                                                                                              
  waste_pos_name = f"{self.deck.waste_type}_{channel_idx + 1}"                                                                                                                                                                                                                                                                               
  waste_pos = self.deck.get_resource(waste_pos_name)                                                                                                                                                                                                                                                                                         

No offset computation needed. Channel 0 → default_long_1, channel 1 → default_long_2, etc.

Proposal

The current approach in PLR bakes assumptions into the offset calculation that may not match the actual hardware waste geometry. The Nimbus approach makes the waste positions explicit and queryable, just like tip rack spots.

I want to use Cody’s Nimbus approach everywhere in PLR. Specifically:

  1. Add per-channel waste resources to all decks. Like Nimbus, STARLetDeck and others should define Trash resources for each channel position (e.g. trash_1, trash_2, … trash_16).
  2. Update discard_tips() to use per-channel resources. Instead of:
    tip_spots=[trash] * n, offsets=computed_offsets it becomes: tip_spots=[deck.get_resource(f"trash_{ch+1}") for ch in use_channels] (something like that)
  3. Remove get_tight_single_resource_liquid_op_offsets from discard path. This function would only be needed for edge cases where users truly want to drop to an arbitrary single resource.
4 Likes

A great idea, can also resolve tip dropoff y,x positions for C0DI /initialize_pip()

1 Like