TIP_CAR_288_C00 holds three 96-tip racks — 288 tips, which is where the name comes from.
2
Tip tracking is off by default, like volume tracking (chapter 1). With it off, pick_up_tips never updates the rack model and every spot reports has_tip() == True forever. The recipes below need it on.
3
linear_tip_spot_generator logs one INFO line per spot to pylabrobot.resources (Saved tip idx to disk: 8). Raising the level keeps the streaming recipe’s output readable — in a real run those lines are the audit trail of which tip went where.
4
A plate to pipette into, so the streaming recipe below can run a real pick-up → aspirate → dispense → discard cycle rather than just naming spots.
10.1 Drop, return, or discard
Put a used tip somewhere. There are three calls, and they differ in destination.
for spot in ("A1", "A2", "A3"):print(f"{spot}: has_tip = {rack_a.get_item(spot).has_tip()}")
return_tips() puts each tip back in the spot it came from. It takes no destination — PLR recorded the origin at pick-up.
2
discard_tips() sends tips to the deck’s trash. The rack spot stays empty.
3
drop_tips(spots) takes an explicit destination, which can be any tip spot or a Trash. A2 is free because the previous line discarded its tip.
Call
Destination
allow_nonzero_volume default
drop_tips(tip_spots)
whatever you pass
False
return_tips()
the spot each tip came from
False
discard_tips()
the deck’s trash
True
Gotcha: the destination spot must be empty.
With tip tracking on, dropping into a spot that already holds a tip raises from the spot’s tracker:
HasTipError: Tip spot already has a tip.
A rack starts full, so drop_tips(rack["B3"]) fails on a fresh rack — the destination has to be a spot something was taken from, or one marked empty via set_tip_state. This is the tip-spot HasTipError, the suppressible one of the two in chapter 10.
WarningGotcha: the allow_nonzero_volume default is not the same across the three
drop_tips and return_tips default to False and raise if the tip still holds liquid:
RuntimeError: Cannot drop tip with volume 50.0
discard_tips defaults to True and discards the tip with the liquid still in it. A tip returned to a rack still holding liquid is a contamination path; a discarded one is not.
See also: the same RuntimeError appears in chapter 10 when a dispense fails and leaves the tip loaded; *96 variants (return_tips96, discard_tips96) work the same way for the 96-head.
10.2 Scope tips to a block
Pick up tips and put them away without writing the put-away call.
async with, not with. use_tips is decorated @contextlib.asynccontextmanager — unlike use_channels (chapter 6), which is synchronous.
print("mounted inside the block:", mounted_inside)print("mounted after the block: ", mounted_after)print("A1 has_tip (discarded): ", rack_b.get_item("A1").has_tip())print("A2 has_tip (returned): ", rack_b.get_item("A2").has_tip())
1
The default is discard=True, so the tip went to the trash and the spot is empty.
2
discard=False returns the tips to their origin spots instead, so the spot is full again.
mounted inside the block: True
mounted after the block: False
A1 has_tip (discarded): False
A2 has_tip (returned): True
Python you need here
Two context manager protocols exist: __enter__/__exit__ for with, and __aenter__/__aexit__ for async with. Using the wrong keyword raises TypeError: ... does not support the asynchronous context manager protocol (or its synchronous counterpart).
10.3 Stream tips across several racks
Run a protocol that needs more tips than one rack holds, without tracking rack boundaries by hand.
spots = F.get_all_tip_spots([rack_a, rack_b, rack_c])print("total spots:", len(spots))gen = F.linear_tip_spot_generator( spots, cache_file_path="tip_index.json",)batch =await gen.get(8)print("batch:", [s.name.split("_")[-1] for s in batch])print("tips left:", gen.get_num_tips_left())
1
get_all_tip_spots flattens a list of racks into one ordered list of spots.
2
linear_tip_spot_generator walks that list in order. With cache_file_path it writes its position to disk after every spot.
3
get(n) returns the next n spots. The generator is async — get is awaited, and iterating it directly uses async for.
The generator crosses rack boundaries without being told they exist:
gen2 = F.linear_tip_spot_generator(spots)gen2.set_index(94)crossing =await gen2.get(4)for spot in crossing:print(f" {spot.parent.name}{spot.name.split('_')[-1]}")
1
set_index jumps to a position — here, near the end of the first 96-spot rack.
rack_a G12
rack_a H12
rack_b A1
rack_b B1
Driving a protocol from the generator.
This is the whole point: the loop body never names a rack or a spot, and never counts. Each round asks for eight more spots, and the generator has already moved past them by the time the pick-up runs.
Start of rack_c, the one rack the recipes above have not taken tips from — the earlier cells left gaps in rack_a that a fresh generator walking from 0 would trip over with NoTipError, since the generator tracks position, not occupancy. A real run starts at 0 or resumes from its cache file.
2
Three rounds of eight. Twenty-four tips, no bookkeeping.
3
discard_tips() rather than return_tips(): the generator hands out each spot exactly once, so returning a used tip to a spot the generator has already passed would leave it in the rack forever, unreachable and indistinguishable from a fresh one.
print("index now at: ", len(spots) - gen3.get_num_tips_left(), "(started at 192)")print("tips left: ", gen3.get_num_tips_left())print("mounted after the loop:", [t isnotNonefor t in lh.get_mounted_tips()][:2])
index now at: 216 (started at 192)
tips left: 72
mounted after the loop: [False, False]
Consuming one spot at a time.
The generator is an async iterator, so async for walks it spot by spot — useful when the channel count varies round to round, or when one tip serves one sample.
gen4 = F.linear_tip_spot_generator(spots[:3])asyncfor spot in gen4:print(spot.parent.name, spot.name.split("_")[-1])print("loop ended cleanly")
1
A three-spot slice, so the loop terminates in the output rather than after 288 lines.
2
The loop ends on StopAsyncIteration, which async for swallows — see the gotcha below.
rack_a A1
rack_a B1
rack_a C1
loop ended cleanly
Gotcha: exhaustion is StopAsyncIteration, not a PLR error.
With repeat=False (the default), asking for a spot past the end raises StopAsyncIteration — Python’s iterator protocol, not one of the portable errors from chapter 10. Inside async for it ends the loop silently; a bare await gen.__anext__() propagates it. get(n) asserts against get_num_tips_left() first, so it raises AssertionError instead.
repeat=True cycles back to the first spot, and makes get_num_tips_left() raise RuntimeError.
Resuming after a crash or a restart.
cache_file_path is what makes the generator survive the process. A new generator built on the same path reads the index back and continues from it — the run that died half-way does not start over on rack A, on top of tips it already used.
Same spot list, same cache path, brand new object — this is what the second process does.
2
The first cell in this recipe consumed 8 spots through this cache file. A fresh generator with no cache would report 0 here.
3
A2 of rack_a, not A1. gen3 and gen4 above passed no cache_file_path, so they wrote nothing and did not disturb this count.
index restored to: 8
next spot: rack_a A2
Two generators, one cache file
Nothing stops you pointing two live generators at the same path — they will overwrite each other’s index and hand out the same tips twice. One generator per cache file per run.
reset() sets the index back to 0 and writes that to the cache file, which is how you start a fresh run against a rack you have physically reloaded. Note the asymmetry with set_index, which also persists on the next spot handed out — neither of them touches the rack model, so a reset generator will happily hand out spots the trackers still believe are empty.
The randomized variant.
F.randomized_tip_spot_generator(tip_spots, K, cache_file_path=...) picks spots at random while refusing any used in the last K samples. It has the same get(n) interface, the same disk cache, and the same reset(), and raises RuntimeError when every spot is in the recent window.
Traversing one rack, in a direction you choose.
A TipRack is an ItemizedResource, so traverse (chapter 5) works on it exactly as it does on a plate — it is not a plate-only method. Use it when the order matters and one rack is enough; use the generator when you need to cross racks or resume across processes.
for batch in rack_b.traverse(batch_size=8, start="top_left", direction="down"):print([s.name.split("_")[-1] for s in batch])breaksnake = rack_b.traverse(8, "top_left", "snake_down")next(snake)print("snake, batch 2:", [s.name.split("_")[-1] for s innext(snake)])
1
Same signature as the plate version: batch_size first, start required.
2
traverse is a lazy generator, so one batch costs one batch.
3
snake_down runs A1→H1, then H2→A2 — the shortest gantry path when the head moves column to column. Nothing about tip racks changes it.
Gotcha: traverse reads the layout, not the trackers.
It yields every spot in the grid whether or not the spot still holds a tip. Filtering by has_tip() is the next recipe. The generators in this recipe have the same blind spot — position is theirs, occupancy is the rack’s.
See also: the batching helpers in chapter 7 chunk worklist rows to channel count.
10.4 Set what the rack model believes
Set which spots the model believes hold tips, after loading a partly used rack or a manual change.
set_tip_state takes a dict of identifier → bool, or a flat list of 96 bools.
3
fill() marks every spot as holding a tip.
after empty(), A1: False
A1: True B1: True C1: False
after fill(), C1: True
Turning the rack’s trackers off entirely.
rack.disable_tip_trackers() and rack.enable_tip_trackers() switch tracking for one rack rather than globally. A rack with disabled trackers accepts pick-ups from empty spots without raising.
See also:no_tip_tracking() and the two distinct HasTipErrors in chapter 10.
10.5 Find the next free tips from the rack itself
Work out where the tips are when you have no saved index — a rack that was partly used, or a session that reconnected to a machine mid-protocol.
A rack somebody already took ten tips from: column 1, plus the top of column 2. set_tip_statesets rather than mutates, so it does not care which of those spots the recipes above already emptied — TipSpot.empty() in a loop would raise NoTipError on the first one that is already free. In a real session this state arrives from load_state_from_file (chapter 11) or from probe_tip_inventorybelow.
free = [s for s in rack_b.get_all_items() if s.has_tip()]print("spots with tips:", len(free))print("next 8:", [s.name.split("_")[-1] for s in free[:8]])print(rack_b.summary())
1
get_all_items() returns every TipSpot in fill order; has_tip() asks that spot’s tracker. This is the whole “find the next tip” algorithm — PLR has no get_next_tip() to call.
2
Slice off as many as you have channels and hand them straight to pick_up_tips.
3
summary() prints the grid: V for a spot holding a tip, - for an empty one. TipRack overrides _occupied_func to read the tracker, so this is the fastest way to eyeball where a rack stands. (plate.summary() uses the same machinery for wells — chapter 5.)
spots with tips: 86
next 8: ['C2', 'D2', 'E2', 'F2', 'G2', 'H2', 'A3', 'B3']
TipRack(name='rack_b', size_x=127.76, size_y=85.48, size_z=64.49, location=Coordinate(000.000, 000.000, 000.000))
1 2 3 4 5 6 7 8 9 10 11 12
A: - - V V V V V V V V V V
B: - - V V V V V V V V V V
C: - V V V V V V V V V V V
D: - V V V V V V V V V V V
E: - V V V V V V V V V V V
F: - V V V V V V V V V V V
G: - V V V V V V V V V V V
H: - V V V V V V V V V V V
12x8 TipRack
has_tip() is only as good as what the trackers were told. If tracking was off for part of the run (it is off by default — see this chapter’s setup), or a rack was unloaded by hand, the model is fiction and the filter faithfully returns fiction. probe_tip_inventorybelow is the version that asks the hardware — on a backend that can answer.
See also:linear_tip_spot_generatorabove is the alternative when you do have continuity between runs — it tracks position in a file rather than re-deriving it from occupancy, which is the more robust of the two across a crash mid-pick-up.
10.6 What a tip is
A Tip carries the dimensions the backend needs, and the frontend uses them to decide whether a channel can take it.
Vendor subclasses add what their firmware needs. HamiltonTip takes a TipSize (LOW_VOLUME, STANDARD_VOLUME, HIGH_VOLUME, CORE_384_HEAD_TIP, XL) and a TipPickupMethod (OUT_OF_RACK or OUT_OF_WASH_LIQUID), which is what a STAR sends in its tip-type command.
See also: a TipSpot builds its tip on demand through make_tip(), which is how a rack definition specifies what it holds (chapter 17).
10.7 Move tips between racks
Relocate tips without pipetting — consolidating by hand, or filling a rack from a nested one.
for spot in rack_c["A7:D7"]: spot.empty()await lh.move_tips( source_tip_spots=rack_a["A7:D7"], dest_tip_spots=rack_c["A7:D7"],)
1
The destination has to be free. A rack starts full, so dropping into an occupied spot raises the tip-spot HasTipError — TipSpot.empty() marks one free in the model.
2
Source and destination are lists of tip spots, paired positionally.
consolidate_tip_inventorybelow is the planned version of this: it works out which partial racks to merge and in what order.
Nested tip racks.NestedTipRack is a TipRack with a stacking_z_height, for racks that stack inside one another (Hamilton NTR). It behaves as a tip rack in every other respect — hamilton_96_tiprack_50uL_NTR is one.
10.8 Tell PLR what is on the head
The channel head is model state like anything else, and can be set directly.
await lh.pick_up_tips(rack_a["A8"])mounted_tip = lh.get_mounted_tips()[0]mounted_before = [t isnotNonefor t in lh.get_mounted_tips()]lh.clear_head_state()mounted_after = [t isnotNonefor t in lh.get_mounted_tips()]lh.update_head_state({0: mounted_tip})mounted_restored = [t isnotNonefor t in lh.get_mounted_tips()]await lh.drop_tips(rack_a["A8"])
1
The Tip now on channel 0. Reading it from the head rather than the rack matters: with tracking on, the spot no longer holds it, so rack_a.get_tip("A8") raises NoTipError.
2
clear_head_state() forgets every mounted tip.
3
update_head_state({channel: tip}) sets specific channels. Passing None as the tip marks a channel empty.
4
Restoring the model made the tip droppable again. Had the state stayed cleared, this would raise NoTipError, and the next pick-up would raise HasTipError: Channel has tip.
after pick_up: [True, False]
after clear: [False, False]
after update: [True, False]
This is the tip equivalent of the off-deck bookkeeping in chapter 9: use it when a tip was fitted or removed without a PLR call, or when reconnecting to a machine that already has tips on.
Nothing physical happens. clear_head_state() on a machine with tips still fitted leaves the model claiming empty channels, and the next pick_up_tips will try to fit a second tip.
10.9 Probe and consolidate tip inventory
Read which spots hold tips from the machine, and pack partly used racks into fewer racks.
for spot, has_tip in presence.items():print(f"{spot}: {has_tip}")
Returns Dict[str, bool] keyed by tip spot name. The default probing function is probe_tip_presence_via_pickup, which attempts a pick-up and reads which channels failed.
Moves tips between partly filled racks so that as few racks as possible hold tips, grouped by tip model. Uses the first eight channels unless use_channels says otherwise.
WarningGotcha: probing cannot find discrepancies on the chatterbox
The probe reports presence by attempting a pick-up, and a simulated pick-up always succeeds, so every spot the model already believes is full comes back True.
For a spot the model believes is empty, the tip tracker raises NoTipError before the backend is reached — and NoTipError is not a ChannelizedError, so it propagates instead of being recorded as “absent”:
NoTipError: Tip spot does not have a tip.
Since only the STAR backend raises ChannelizedError (chapter 10), a probe that reports real presence is a Hamilton operation. Against the chatterbox it can only confirm the model against itself.
See also:F.get_all_tip_spots([...]) builds the spot list to hand to probe_tip_inventory; lh.move_tips relocates tips between spots without the consolidation planning.
10.10 What to remember
Three put-away calls: drop_tips(spots) to a destination you name, return_tips() to the origin, discard_tips() to the trash.
allow_nonzero_volume defaults to False for drop and return, True for discard.
use_tips is async with; use_channels is with.
F.linear_tip_spot_generator(F.get_all_tip_spots([...]), cache_file_path=...) streams across racks and resumes across processes. Exhaustion is StopAsyncIteration.
The streaming loop body is fresh = await gen.get(8) → pick_up_tips(fresh) → work → discard_tips(). async for walks one spot at a time; reset() starts a run over and persists.
rack.traverse(batch_size, start, direction) works on tip racks too — a TipRack is an ItemizedResource. It reads the layout, never the trackers.
With no saved index, the next free tips are [s for s in rack.get_all_items() if s.has_tip()]; rack.summary() prints the same thing as a V/- grid.
rack.empty() / fill() / set_tip_state({...}) write the model directly.
Tip tracking is off by default; with it off the rack model never changes.
probe_tip_inventory reports real presence only on a backend that raises ChannelizedError.
A Tip carries maximal_volume, total_tip_length, fitting_depth, and has_filter; HamiltonTip adds TipSize and TipPickupMethod.
lh.move_tips relocates tips directly; clear_head_state and update_head_state set what the head is believed to hold, and move nothing.