Labware threads
A labware thread is the route one piece of labware takes through the system: which methods run on it, and where it starts and ends. A method is a sequence of steps; a thread says which methods happen to one plate, one rack or one trough. Each thread carries one labware template, and threads run in parallel.
Your first thread
@orca.thread(labware=sample_plate, start="stacker", end="waste")
async def sample_thread(ctx: ThreadContext):
yield shake_step
yield seal_step
yield read_step
One plate, three methods, in that order.
The body is an async generator and it yields methods. A single action is accepted wherever a method would go, and so is a list of them, which is scheduled in order. The body can also yield the flow primitives orca.join, orca.on, orca.branch and orca.park in the same position a method would go; all four are under Advanced.
| Parameter | Effect |
|---|---|
labware | The labware template this thread carries. Required. |
start | Where the labware comes from. Required. |
end | Where it goes when the thread is done. Required. |
Where a thread starts and ends
A bare string names a location: a device name, a plate pad, a stacker. start="stacker" means the plate comes from the location called stacker, and end="waste" means the thread ends at waste.
In the bare-string form both ends are handled by a person. You place the labware at the start location, and you take it away at the end. In simulation both happen automatically. On real hardware the thread waits for you: it parks at the start until the labware is registered at that location, and parks again at the end until it is removed.
Advanced covers the other ways labware can arrive and leave, such as a stacker dispensing a fresh plate, or a reagent trough that stays on the deck between executions.
Advanced
Everything below is for workflows where several pieces of labware converge on one step, or where labware lives on the deck between executions. A single plate moving through a few methods needs none of it.
| Parameter | Default | Effect |
|---|---|---|
contributes_to | None | Labware-template names of the receivers this thread feeds. |
required | True | Whether every submitted group must include this thread. |
immovable | False | Declares that no thread will ever move this labware. |
The full form of start and end: a Location, a location-name string, or a (location, sentinel) tuple. end also takes a list of interchangeable candidate spots, or a (candidates, sentinel) tuple.
contributes_to
contributes_to holds labware template names, not thread-function names. Write the receiver's labware= template name.
@orca.thread(labware=plate_1, start="stacker_3", end="waste",
contributes_to=["final_plate"])
async def plate_1_thread(ctx: ThreadContext):
...
yield combine_plates # feeds the `final_plate` receiver
It does not trigger spawning. It builds the feeder graph the engine uses to decide when a receiver's slot closes, which is what ends a while ctx.has_more_work() loop. The slot stays open while any thread that transitively feeds it up the contributes_to chain is still live, not just its direct feeders, so a live upstream thread that will still spawn a feeder holds it open.
required
required=True means every submitted labware group must carry a member naming this thread. Set required=False for a thread that only spawns for groups that ask for it by name, such as an optional spike-in control. See Submissions.
immovable
immovable=True tells the deadlock detector that this thread's labware is a terminal blocker: another thread that requests a location holding it fails fast with UnresolvableDeadlockError instead of retrying forever. Use it for deck-resident reagents. An immovable thread cannot be a wf.start() entry; the build refuses it.
Start and end sentinels
start and end take a bare location name (the common case above) or a (location, sentinel) tuple that says how the labware appears or departs. A bare string means MANUAL_PLACE at start and MANUAL_REMOVE at end.
| Sentinel | Side | Behavior |
|---|---|---|
MANUAL_PLACE | start | Operator places the labware. Default for a bare-string start. Sim modes fulfil it automatically; LIVE parks the thread at AWAITING_MANUAL_PLACE until the operator registers the labware at the location. |
DISPENSE | start | A stacker, hotel or other IPlateSource device physically dispenses the next plate, and the engine writes the slot after the device call. |
REUSE_EXISTING | start | Adopt whatever labware is already at the location, creating one only on first use. Persists it across executions, so a reagent trough or calibration plate survives. Cannot be a wf.start() entry. |
MANUAL_REMOVE | end | Operator removes the labware. Default for a bare-string end. Sim modes dispose automatically; LIVE parks the thread at AWAITING_MANUAL_REMOVE. |
LEAVE_IN_PLACE | end | The thread finishes without disposing, so the labware stays for the next execution to adopt. Pair it with REUSE_EXISTING. |
Import them from orca.spawn:
from orca.spawn import DISPENSE, MANUAL_PLACE, MANUAL_REMOVE, REUSE_EXISTING, LEAVE_IN_PLACE
A sentinel written where a location belongs is refused with a message naming the form that works, including when the sentinel belongs to the other side.
Example shapes:
# Operator-handled sample plate (the bare-string default)
@orca.thread(labware=sample_plate, start="pad_1", end="pad_1")
async def sample_thread(ctx: ThreadContext):
...
# Plate dispensed from a stacker
@orca.thread(labware=sample_plate, start=("stacker", DISPENSE), end="waste")
async def stacker_thread(ctx: ThreadContext):
...
# Persistent reagent trough that lives on the deck across executions
@orca.thread(
labware=reagent_trough,
start=("mlstar_1/carrier-25-0", REUSE_EXISTING),
end=("mlstar_1/carrier-25-0", LEAVE_IN_PLACE),
)
async def trough_thread(ctx: ThreadContext):
while ctx.has_more_work():
yield orca.join(allows=[transfer_step])
Two entry threads may wait on the same manual place slot. Each asks the ledger for its own labware, so a second LIVE submission of the same workflow is admitted rather than refused. One register-labware binds the longest-waiting expectation, which means the operator cannot choose which submission their plate feeds. A submission whose entry slot physically holds labware is still refused, with StartLocationsOccupiedError.
Deck-resident labware that runs out
REUSE_EXISTING plus LEAVE_IN_PLACE describes labware that stays put and gets adopted again. A consumable, a tip rack most of all, eventually has nothing left to give, and the sentinels bend in exactly one place for that.
- A used-up receiver is removed, whatever
LEAVE_IN_PLACEsays. A receiver that ended because its labware was spent goes by the thread's declared removal mechanism instead of taking the skip. Leaving a spent rack standing is precisely what the next receiver would adopt. - A replacement receiver does not adopt. Only a receiver that is not replacing spent labware binds to what stands at the start location. A replacement exists because that labware was used up, so it asks for a fresh one by the declared spawn mechanism. Under LIVE both halves are operator steps,
AWAITING_MANUAL_REMOVEthenAWAITING_MANUAL_PLACE; in sim both are immediate. - A rack that was already spent when the run started refuses the first contribution. The first contribution into a slot asks the adopted labware whether it can supply. If the ledger says it is short, the contributor pauses with an instruction naming both halves of the fix: take it off, put a fresh one there, state what it holds.
RETRYre-runs the spawn and succeeds once that is done. The engine refuses rather than minting a replacement, because a reuse-bound thread has no route in: a placement would be asked for at a site the spent labware still occupies, and no thread owns it to take it off.
Unknown is not empty. Labware nothing has ever described answers that it can continue, so it binds and the operator settles what it holds. Only labware the ledger positively reports as short is refused.
An end can name several interchangeable spots
end also accepts a list. Every candidate is requested and the thread ends at whichever one is granted:
@orca.thread(
labware=result_plate,
start=("stacker_4", DISPENSE),
end=[f"hotel_shelf_{i}" for i in range(1, 13)],
)
async def result_thread(ctx: ThreadContext):
...
Route score picks the winner, not the order you wrote the candidates in. This is how concurrent executions land their result plates on distinct shelves instead of queueing behind one, where in LIVE each plate would block on the previous plate's operator removal. An empty list is refused, and a sentinel inside the list is refused; a sentinel goes on the tuple, end=([...], LEAVE_IN_PLACE).
A thread that ends at a device-internal deck site ("lh/carrier-25-0") must use LEAVE_IN_PLACE. Thread completion routes labware to the device handoff rather than the specific site, so any other end intent would dispose against an empty site and never clear the labware.
orca.join
A contributor thread yields orca.join to participate in another thread's method instead of running one of its own. This is the usual shape for tips, troughs and pooled receivers:
@orca.thread(labware=tips, start="stacker", end="waste")
async def tips_thread(ctx: ThreadContext):
yield delid
yield orca.join(allows=[cherry_pick_step, dilute_step])
| Form | Meaning |
|---|---|
orca.join() | Join whatever method spawned this thread. |
orca.join(some_method) | Join that specific method; any other raises ValueError. |
orca.join(allows=[m1, m2]) | Join from spawn context, validated against the list. |
A receiver that takes many contributions loops on ctx.has_more_work():
@orca.thread(labware=final_plate, start=("stacker_4", DISPENSE), end="plate_hotel")
async def final_plate_thread(ctx: ThreadContext):
while ctx.has_more_work():
yield orca.join(allows=[transfer_to_read_plate])
yield read
One join per contribution: one for a single submission, N for an N-plate batch. Three things end the loop: the slot closing, which the contributes_to feeder graph decides; the slot filling to max_contributions; and the receiver's own labware running out. That last one matters for a tip rack, where max_contributions is the column ceiling and the rack usually runs dry first. The receiver leaves, whatever room the slot had left, and the contribution it could not serve is handed off to its successor.
See Methods for what the owner and the contributors each do once they meet.
orca.on and orca.branch
orca.on(event_name, timeout=None) blocks the thread until the named event has been published. orca.branch(event_name, branches, timeout=None) waits, then runs the method list keyed by the published value, with "else" as the fallback. Both read from counter zero, so they see the latest publish whether or not the thread was waiting when it fired. See Workflows.
orca.park
orca.park(location) suspends the thread at an author-declared spot until the engine wakes it:
yield orca.park("reservoir_pad")
yield orca.park([f"hotel_pad_{i}" for i in range(1, 13)])
The rules are narrow and worth knowing:
- A park completes only at a spot you declared. The engine never relocates a park, and it never invents a spot for one.
- A list declares interchangeable spots. All are requested at once and the park completes at whichever is granted. Route score drives that choice, not the order you listed them.
- Parked labware is never relocated by the engine. It stays where it parked until the thread's own next step moves it.
- Work arriving in the thread's slot cancels the trip. If a dispatch lands while the park move is still unresolved, the park is abandoned and the thread serves the work from wherever it is standing. A pad it had already reached is a legitimate resting spot. Without this, a thread whose park slot is occupied would retry the blocked move forever while the dispatch that needs it waits.
- A device name resolves to that device's site.
orca.park("shaker_1")parks on the shaker's site, the same waystart=andend=resolve a device name.
See also
- Workflows: composing threads into a workflow.
- Methods: what a thread yields.
- Labware: defining the templates a thread carries.
- Submissions: groups,
requiredthreads, and how a run is started. - Context API reference: what a thread body can call.