Method step reference¶
Audience: method authors writing *.method.toml files; plugin authors adding new step kinds.
Scope: every built-in step kind: schema, validation rules, on-failure semantics, what gets emitted into the bundle.
The step types live in src/capa/experiment/method.py; execution lives in src/capa/experiment/executor.py. The MethodExecutor walks Method.steps in order; the recipe runner is a one-line wrapper that hands the method to the executor.
Step kinds at a glance¶
| Step | What it does | Has duration? | Issues a command? | Custom-handler? |
|---|---|---|---|---|
hold |
Command a setpoint, then wait | yes (or end_condition) | yes | no |
ramp |
Linear ramp between setpoints | yes | yes (~10 Hz drip) | no |
setpoint |
Issue a setpoint and continue immediately | no | yes | no |
wait |
Wait for a condition or duration | yes (or end_condition) | no | no |
prompt |
Block until operator confirms | optional timeout | no | no |
acquire |
Record without changing controls | yes | no | no |
safe_shutdown |
Drive channels to safe values | optional | yes (one per cool_target) | no |
custom |
Plugin-defined behavior | depends | depends | yes |
Every step also accepts two _StepBase fields:
| Field | Notes |
|---|---|
notes |
Free-text. Not rendered anywhere today; recorded in method.step.entered events for analysts. |
safety_overrides |
Tuple of AlarmOverride. Parsed and preserved today; active safety-rule enforcement is reserved for the safety monitor path. |
The discriminator is the kind field. Pydantic dispatches on it at deserialisation, so an unknown kind fails fast with a clear error message rather than silently mis-parsing.
Every step emits three events¶
Whatever the step kind, MethodExecutor._dispatch writes:
| Event | When | Severity |
|---|---|---|
method.step.entered |
Top of dispatch | info |
method.step.exited |
Step returned normally | info |
method.step.failed |
Step raised | error |
These carry step_index, step_kind, and (when applicable) target — enough that a bundle audit can walk the run step by step without consulting the original method file.
Setpoint-issuing steps (hold, ramp, setpoint, safe_shutdown) also emit a method.command.issued event per write, carrying channel, device, value, step_kind, accepted, issued_by, and authorization_id. Every device write is attributable.
hold¶
Command a setpoint and wait for duration_s or end_condition.
[[steps]]
kind = "hold"
duration_s = 600.0
value = 600.0
notes = "main exposure"
[steps.target]
name = "heater.setpoint"
| Field | Required | Notes |
|---|---|---|
target |
yes | ChannelRef — the channel to write. |
value |
yes | The setpoint to command. |
duration_s |
one-of | Seconds to hold. ≥ 0. |
end_condition |
one-of | EndCondition that ends the hold early. |
At least one of duration_s and end_condition must be set — validated at model construction. Both set: whichever fires first wins. Both unset: rejected with "hold step needs either duration_s or end_condition".
The setpoint write happens first, the wait runs after. So a hold with duration_s=0 is functionally a setpoint step that takes the dispatch round-trip and exits.
ramp¶
Drip setpoints linearly from a start value to an end value.
[[steps]]
kind = "ramp"
end_value = 600.0
rate_per_second = 0.5 # 30 °C/min
[steps.target]
name = "heater.setpoint"
| Field | Required | Notes |
|---|---|---|
target |
yes | ChannelRef. |
start_value |
no | None means "ramp from the current setpoint" — the executor reads the latest databus value. |
end_value |
yes | Final setpoint. |
rate_per_second |
one-of | Slope. |
duration_s |
one-of | Total ramp time. > 0. |
At least one of rate_per_second and duration_s must be set — validated at model construction. If both are present, duration_s controls execution and rate_per_second is retained as method metadata; the executor does not validate that they agree. If duration_s is absent, the executor derives it from rate_per_second and the endpoints.
Execution details:
- Setpoints drip at
DEFAULT_RAMP_TICK_HZ = 10 Hz(the hardware closes the loop; capa just provides the setpoint trajectory). start_value=None+ no live sample on the databus → executor logs a warning, jumps immediately toend_value(degenerate ramp). Rare but recoverable.- A ramp with
rate_per_second=0is rejected at runtime withMethodExecutorError. - Each tick respects
external_stop— the operator's Abort interrupts a ramp within ~100 ms.
setpoint¶
Issue a setpoint and continue immediately. No wait, no end condition.
Three required fields: kind, target, value. Useful for synchronised multi-channel sequences where the timing comes from a subsequent wait rather than from each setpoint having its own duration.
wait¶
Wait for a condition, a duration, or whichever fires first.
[[steps]]
kind = "wait"
duration_s = 30.0 # safety cap
timeout_s = 35.0
on_timeout = "abort"
[steps.end_condition]
channel = "mass_loss_fraction"
op = ">"
value = 0.1
| Field | Required | Notes |
|---|---|---|
end_condition |
one-of | EndCondition — what to wait for on the databus. |
duration_s |
one-of | Maximum wait time. ≥ 0. |
timeout_s |
no | Engine-side deadline. > 0. Distinct from duration_s. |
on_timeout |
no | "warn" (default), "abort", or "safe_shutdown". |
At least one of duration_s and end_condition must be set.
The semantics of duration_s vs timeout_s:
duration_sis the wait itself — when this elapses, the wait exits normally (nomethod.wait.timeoutevent).timeout_sis the deadline — if the wait hasn't exited by then, the executor writes amethod.wait.timeoutevent and applieson_timeout.
The end-condition watcher subscribes to the databus and evaluates each sample for the target channel. A condition exits the wait as soon as a matching sample arrives.
on_timeout policies:
| Value | Behavior |
|---|---|
"warn" |
Write a severity=warning timeout event; continue to next step. |
"abort" |
Write a severity=error event; raise MethodExecutorError, crashing the run (bundle is still sealed). |
"safe_shutdown" |
Write a warning event; set external_stop, requesting the procedure/conductor shutdown path. |
prompt¶
Block until the operator confirms via the Run-tab UI.
[[steps]]
kind = "prompt"
title = "Insert sample"
message = "Place specimen in the holder and close the door. Click OK when ready."
timeout_s = 600.0
| Field | Required | Notes |
|---|---|---|
title |
no | Default "Operator confirmation". |
message |
yes | Body text shown to the operator. |
timeout_s |
no | Optional deadline. > 0. |
Lifecycle:
method.prompt.shownevent written withtitle,message,timeout_sin metadata.- The executor polls
ctx.metadata["_prompt_confirmed"]at 10 Hz; the Run tab sets it when the operator clicks Confirm. - On confirmation:
method.prompt.acknowledgedwritten; step exits. - On timeout:
method.prompt.unansweredwritten withreason="timeout"; raisesMethodExecutorError. - On
external_stop:method.prompt.unansweredwritten withreason="external_stop"; step exits without raising (the run is shutting down anyway).
Headless prompts¶
For tests, batch runs, and other non-interactive paths, the recipe runner sets auto_acknowledge_prompts=True on the executor. With that:
- Prompts auto-acknowledge after a 0-second yield.
method.prompt.acknowledgedcarriesby: "auto_acknowledge"in metadata.
Without auto_acknowledge_prompts and with no UI wired up, a headless run hits PROMPT_HEADLESS_DEFAULT_TIMEOUT_S = 30 s as the soft default and times out. This is deliberately not infinite — a CI job hanging on an unconfigured prompt would be invisible failure.
acquire¶
Record without changing any control output for duration_s.
One required field beyond kind: duration_s (> 0). No setpoint commands, no condition checks, no databus subscriptions — just a sleep that respects external_stop.
Useful as an explicit "this is a tagged measurement window" marker. The method.step.entered / method.step.exited events bracket the window so downstream analysis can pull the channel data between those timestamps.
safe_shutdown¶
Drive channels to safe values and (optionally) wait. Procedures can include this explicitly in a method, or run it from their own cleanup path via MethodExecutor.run_segment(...).
[[steps]]
kind = "safe_shutdown"
duration_s = 30.0
[steps.cool_target]
"heater.setpoint" = 25.0
"purge.flow" = 0.0
| Field | Required | Notes |
|---|---|---|
cool_target |
no | {channel_name: setpoint} map. |
duration_s |
no | Time to hold the cool targets before continuing. ≥ 0. |
Semantics that are easy to miss:
- A missing channel during cooldown logs a warning but does not abort the rest of the cooldown. The other targets still receive their safe values. This is by design: cleanup should be best-effort rather than first-failure-stops-everything.
- The post-command wait is breakable by
external_stop. A second Abort during cooldown wakes the step immediately. duration_sdefaulting toNoneskips the wait entirely — the step issues the commands and returns.
See safety/shutdown-sequence.md for how safe_shutdown fits into the broader shutdown contract.
custom¶
Plugin-defined step kind. Dispatched at runtime to a handler registered on the executor by the loading procedure.
[[steps]]
kind = "custom"
handler_id = "my_plugin.balance_zero"
[steps.params]
target_window_s = 30.0
acceptable_drift_g = 0.01
| Field | Required | Notes |
|---|---|---|
handler_id |
yes | The id to dispatch to. |
params |
no | Free-form dict. The plugin's own Pydantic schema validates these at handler invocation. |
The handler signature is documented in Extending: custom method steps. Briefly: a coroutine (executor, step, ctx) -> None registered into executor.custom_handlers before run_to_completion is called.
An unregistered handler_id raises MethodExecutorError at dispatch time — methods reach custom steps without crashing the run as a whole until they actually try to execute one.
Supporting types¶
ChannelRef¶
A typed pointer to a channel. Single name field today; the model is kept around so a future addressing scheme (device.parameter, indexed channels) can extend it without changing every step that points at a channel.
EndCondition¶
| Field | Notes |
|---|---|
channel |
Channel to watch on the databus. |
op |
One of >, >=, <, <=, ==. |
value |
The threshold. |
The executor subscribes to the channel and tests each emission. == is allowed but rarely useful for floating-point channels — operators almost always want >= against a slightly relaxed value instead.
AlarmOverride¶
[[steps.safety_overrides]]
alarm_id = "heater_high_temp"
threshold = 850.0 # widen during a hot step
| Field | Notes |
|---|---|
alarm_id |
The alarm to override. |
threshold |
New threshold value. Optional. |
disable |
Boolean. When true, the alarm is suppressed entirely for this step. Default false. |
Per-step alarm-band overrides are parsed and preserved, but the current runtime does not yet apply them to an active safety monitor. A future SafetyMonitor can use these fields to widen a high-temp band during one hot step and restore the default threshold at method.step.exited.
Method total duration¶
Method.total_duration_s() returns the sum of every step's duration_s if all steps have one. It returns None when at least one step has no fixed duration — a hold with only an end_condition, a wait with no time bound, an unbounded prompt. The camera disk-space preflight uses this — when the duration is unknowable, the preflight falls back to a configured default rather than refusing to arm.
Tunables¶
Three named constants from executor.py — they belong as constants so a perf-regression test can pin behaviour.
| Constant | Default | Where used |
|---|---|---|
DEFAULT_RAMP_TICK_HZ |
10.0 | Setpoint-update cadence for ramp steps. |
DEFAULT_WAIT_POLL_HZ |
20.0 | Legacy/exported compatibility constant. Current waits are databus-event driven. |
PROMPT_HEADLESS_DEFAULT_TIMEOUT_S |
30.0 | Soft default when a headless run hits a prompt step without auto_acknowledge_prompts. |
See also¶
- Procedure: recipe runner — the procedure that walks
Method.steps. - Custom method steps — writing a
customhandler. - What is a procedure — where methods sit in the three-axis split.
- Method TOML — the file-format perspective.
- Safety: shutdown sequence — how
safe_shutdownintegrates with the safety contract.