Actions
An action is one step of work at one device. It is the smallest thing you write: methods put actions in order, labware threads put methods in order, and a workflow ties the threads together.
Your first action
@orca.action(device=shaker, inputs=[plate])
async def shake(ctx: ActionContext):
await ctx.device().shake(duration=30, speed=500)
Two arguments cover the common case:
deviceis where the action runs. It is one of the devices from your topology, and Orca holds a reservation on it for the whole action.inputslists the labware this action needs present.
The body is an async function, and ctx.device() hands you the device. Actions are where you call driver methods.
Actions and operations
Each await ctx.device().<method>(...) in the body is one operation: one device call, such as aspirate, dispense, shake or run_protocol. An action can issue many, all under its one device reservation. The shake action above issues one; the transfer action below issues four.
The action is the unit Orca schedules and reserves a device for. The operation is the smallest unit of device work. The distinction shows up in two places an operator meets:
- Ops history records one
TrackingRecordper action, holding that action's ordered operation list.orca ops-history get <execution>shows the operation count per action;orca ops-history search --operation aspiratefilters to records containing a given device-operation type. - Recovery is operation-aware. When one operation fails on a paused action,
retry-opre-runs only that call and leaves the rest of the action body suspended at itsawait.retryre-runs the whole action body from the top. See Recovery.
Calling the device
Narrow the handle to a driver interface to get dispatch and autocomplete on it:
from orca.devices.device_interfaces import ILiquidHandler
@orca.action(device=liquid_handler, inputs=[plate, tips])
async def transfer(ctx: ActionContext):
lh = ctx.device(ILiquidHandler)
plate = ctx.plate("sample_plate")
spots = await ctx.next_tips("tips", 8)
await lh.pick_up_tips(spots)
await lh.aspirate([plate.well("A1")], [50.0])
await lh.dispense([plate.well("B1")], [50.0])
await lh.discard_tips()
Ask the tip rack which tips it still holds rather than naming positions. A rack left on the deck between runs has no full first column by the second run, and a hard-coded A1 picks air. next_tips raises when the rack cannot supply that many, and when nothing has ever said what the rack holds.
The interfaces you can narrow to are listed in Devices.
What an action body can call
| Call | Returns or does |
|---|---|
ctx.device() | A handle for this action's device. |
ctx.device(InterfaceClass) | The same handle, typed to a driver interface for autocomplete. |
ctx.labware(name) | The LabwareInstance assigned to this action under that template name. |
ctx.labware(name, kind) | The same, narrowed to PlateInstance, TipRackInstance or TroughInstance; raises TypeError on mismatch. |
ctx.plate(name) | The underlying PLR IPlate. |
ctx.tip_rack(name) | The underlying PLR ITipRack. |
ctx.trough(name) | The underlying PLR ITrough. |
await ctx.next_tips(name, count) | The next count tip spots the rack still holds, column-major. Async. |
await ctx.param(name) | A variable's resolved value. Async. |
await ctx.param(name, kind) | The same, narrowed to float, int, str or bool; raises TypeError on mismatch. Async. |
await ctx.manual_step(instruction, timeout_hours=0) | Show an operator instruction and block until it is confirmed. Async. |
Full detail, including the calls covered under Advanced below, is in the Context API reference.
Advanced
Everything below is for actions that need more than a device and its labware: a different failure policy, an anchor an operator can insert against, a vendor protocol the engine cannot see into, or coordination with other threads.
All the action parameters
@orca.action(
device=sealer,
inputs=[plate],
outputs=[plate],
failure_policy=FailurePolicy.PAUSE,
tag="critical_seal",
deck_positions={plate: "carrier-7-0"},
well_selectors={"plate": quadrant("tl")},
declares=DeclaredTracking(...),
)
async def seal(ctx: ActionContext):
...
| Parameter | Type | Default | Effect |
|---|---|---|---|
device | Device or ResourcePool | required | Where the action runs. A bare Device is wrapped in a single-member pool. |
inputs | list[LabwareTemplate | AnyLabwareTemplate] | required | The labware this action needs present. An input with no live thread spawns the thread registered for it by wf.thread; an input with no registration is left to whatever else supplies it. |
outputs | same | inputs | The labware this action produces. Defaults to inputs. |
failure_policy | FailurePolicy | PAUSE | What the engine does when the body raises. See Failure policies. |
tag | str | None | Anchor name for mid-run insertion. |
deck_positions | dict[LabwareTemplate, str] | None | For liquid handlers: which deck slot each labware goes in. Keyed by the template object. |
well_selectors | dict[str, WellSelector] | None | Named well selectors the body reads back. Keyed by labware name. |
declares | DeclaredTracking | None | What a closed-protocol action asserts it did, since the engine cannot observe it. |
tag is an anchor, not a label
An operator inserting an action into a running thread anchors it by tag. Before("critical_seal") holds the insert until an action with that tag comes past on the stream; After("critical_seal") also accepts the action just consumed, so anchoring to the step a paused thread stands on works. Untagged actions cannot be anchored. Tags are not checked for uniqueness, and matching is scoped to the assigned method's action lane, so first match wins. See CLI: control.
declares fills the gap a closed protocol leaves
When an action hands work to a vendor protocol file, the engine sees one opaque call and learns nothing about what moved. DeclaredTracking is the author saying it:
from orca.state.records import DeclaredTracking, DeclaredVolumeTransfer
declares=DeclaredTracking(
volume_transferred=[
DeclaredVolumeTransfer(source="reservoir", target="plate_1", volume_ul=100.0),
],
wells_used={"plate_1": ["A1", "B1"]},
tips_used={"tips_beads": ["A1"]},
)
The fields are wells_used, volume_transferred, tips_used, operations and initial_state, all optional. An action driving PLR directly does not need it: the driver reports the state and the engine records it.
well_selectors are read back, not applied
A selector is a declaration the body fetches with ctx.get_well_selector(name) and acts on. The engine does not narrow anything on its own. ctx.get_well_selector(name) returns the WellSelector declared for that labware, or all_wells(). Constructors live in orca.resource_models.well_selector: all_wells(), quadrant("tl" | "tr" | "bl" | "br"), well_range("A1", "H1") and well_list([...]).
Pipetting parameters
Liquid-handler calls take two optional PipettingProfile arguments, liquid_class and technique. Both are folded over the deployment defaults, lowest precedence first: defaults, then liquid_class, then technique. The driver receives one resolved record, so no driver has to choose between layers.
from cheshire_drivers.pipetting import MixParams, PipettingProfile
await lh.aspirate(
ctx.trough("bead_reservoir"), [100.0] * 8,
technique=PipettingProfile(mix=MixParams(volume=80.0, repetitions=5, flow_rate=150.0)),
)
Events from actions
Events are how actions in different threads coordinate. Emit:
await ctx.emit("qc_result", value="pass", data={"avg_absorbance": 0.5})
Wait:
qc_value, qc_data = await ctx.wait_for("qc_result", timeout=60.0)
await ctx.emit(name, value=, data=) publishes an event to the other threads and to the runtime event surface. await ctx.wait_for(name, timeout=) waits for a named event and returns (value, data). Both are async.
The same publish reaches the runtime event surface as CUSTOM.qc_result.EMITTED, so sinks, archives and the events API see it. A thread can branch on the value with orca.branch(...), see Workflows.
Which contribution this is
ctx.pool_index(receiver_name) gives the 0-based index of this action's contribution to the named receiver's current instance. It raises ValueError if the action does not contribute to that receiver. See Labware threads for what a contribution is.
See also
- Methods: putting actions in order.
- Devices: the device types and driver interfaces you can call.
- Context API reference: every call an action body can make.
- Failure policies: what happens when an action body raises.
- Recovery: what an operator can do with a paused action.