Skip to content

Alarms and Interlocks

How to wire up interlocks and alarms in Cloud-Native DCS, with four worked examples that cover the most common pharma-batch protection patterns:

  1. Device-level interlock — an output function block forced to its safe state while a trip condition is true, evaluated every scan on the edge. The equipment-protection layer. Start here.
  2. Boolean fault signal with debounce — discrete-valve MISMATCH with travel-time hold-off. The textbook "limit-switch fault" alarm. Its companion CMD_BLOCKED covers the separate case of a command an interlock refused, which is not a fault (ADR 0054).
  3. Analog deviation with both deadband and debounce — flow-rate or temperature deviating from setpoint, ignoring transients.
  4. Phase-level safety guard — a phase transition that sequences the procedure into a safe-hold on an over-pressure event, paired with a parallel alarm CR for the audit trail.

For the underlying CRD reference and condition-type catalog, see Alarm Management.

BPCS-layer protection — an independent SIS is a PHA/LOPA call

In IEC 61511 terms, Cloud-Native DCS is a Basic Process Control System (BPCS). It is not a Safety Instrumented System (SIS) and is not rated for SIL 1/2/3/4 functions under IEC 61508 / IEC 61511. The protections documented on this page (device interlocks in the FB runtime, phase SFC transition guards, and AlarmDefinition.exceptionAction) are BPCS-layer protection functions. Under IEC 61511 Clause 9.2 a BPCS interlock or alarm can be credited as an independent protection layer. Whether a given hazard (emergency shutdown, overpressure trip, LEL/combustible-gas trip, relief-valve integrity, burner management, or any function whose failure causes injury or loss of containment) also requires an independent SIS is settled per process by your process hazard analysis / LOPA. An independent SIS is a separate, independently certified safety PLC or hardwired trip system. Make that protection-layer assignment at design time. See architecture.md for the full framing.

Integrators: see the API Reference for the REST equivalents of the actions on this page.

What "interlock" means in this project

ISA-88 Part 1 names "interlocking" as one of the flavours of basic control (Clause 3.1.5 / Clause 5.2) but does not give it a stand-alone definition. Cloud-Native DCS uses the term in the conventional process-control sense:

Interlock — logic that prevents an equipment entity from entering or remaining in a state when a defined condition is true.

An interlock in that sense must hold in every operating situation: while a recipe runs, while the unit is idle, while an operator drives outputs in manual mode, and while the edge is cut off from the control plane. Only logic that executes in the function block scan on the edge node satisfies all four, which is why the device layer is the interlock proper and the other two mechanisms on this page are its procedural and audit companions (ADR 0007).

Cloud-Native DCS layers protection in three tiers, listed in the order you should reach for them:

Layer Where it lives Latency Survives control-plane partition Audit Role
Device interlock (Pattern 0 below) The output block's ILCK input / interlockAddress parameter, evaluated in the FB scan on the edge One scan (100–200 ms), always-on, mode-independent Yes The historized ILCK tag; a trip that defeats a command also raises CMD_BLOCKED The interlock. Equipment-scoped protection that forces one output to its safe state while the trip condition is true.
Phase SFC transition guard (Pattern 3 below) A priority: -1, interlock: true transition in the phase template's chart, leading to a safe-hold step; the SFC engine runs in the procedural operator One SFC scan, only while the phase executes Partition-tolerant at the edge (ADR 0008) None on its own (pair with a parallel alarm) Procedural safe-state sequencing. Ordered, multi-output response (vent first, then cut heat) that parks the procedure in a resumable held step.
AlarmDefinition exceptionAction (Patterns 1, 2 below) An AlarmDefinition CR with exceptionAction: Hold/Stop/Abort 10 s poll interval + any debounceSeconds (≈ 10–60 s typical) No Full ISA-18.2 / 21 CFR Part 11 audit trail Annunciation + batch coordination. The official record of the event, plus the batch-level Hold.

A safety-critical condition (over-pressure, runaway exotherm) typically gets all three. The device interlock forces the hazardous output to its safe state within one scan no matter what. The phase guard sequences the wider procedural response. The AlarmDefinition provides the operator-visible record and the batch-level Hold. The reaction-time contract for the alarm paths is documented in Alarm Management → Reaction time (ADR-0002). The layering decision is recorded in ADR 0007.

The procedural safe-state layer is now partition-tolerant at the edge (ADR 0008). The phase guard above runs in the procedural operator, so on a control-plane partition it stalls mid-step. The equipment freezes wherever the sequence happened to be, which is not the same as safe. To close that gap the unit runtime can stage an armed hold chart (the running phase's holdingChart, or the UnitSpec.safeStateChart baseline when no phase is active) in an SFC engine embedded in the runtime. When the control-plane heartbeat watchdog fires, the embedded engine runs that chart locally to drive the same ordered safe-state response (close the charge valve, then stop the agitator, …) by writing the unit's own control-module tag space. The FB scan stays the sole I/O writer, and the device interlocks still sit underneath. An armed chart may only READ/WRITE the unit's own control modules, and may call only the builtins that need nothing beyond the node. The runtime and make lint-edge-armable enforce both halves of that constraint, and a chart failing either is refused at arming time. Learning it mid-partition is what the arming-time refusal prevents. This reproduces the DeltaV/Rockwell watchdog → HELD-with-local-holding default. Recovery is a normal ISA-88 Restart. It is never a silent resume.

All three layers depend on the FB scan continuing to run. A single block's runtime error (e.g. a transient driver write failure on one address) no longer halts the whole scan. The faulting block is skipped and flagged, the program reports Degraded, and every healthy output (the device interlocks included) keeps evaluating (ADR 0009). When a control program is deliberately removed, each output block is driven to its configured fail state once. failState: safeValue (the default) writes the block's safeValue, and failState: holdLast deliberately leaves the output at its last commanded value (IEC 62443 SR 3.6, #972). A truly unpowered fail state is the I/O hardware's own watchdog/fail-safe configuration. No commanded write can produce it. Ungraceful process death (pod crash, node loss) is backstopped by the remote-I/O module's own comm-loss watchdog configured at the deployment layer.

For multi-stage escalation policy (e.g. "Hold for 30 s; if not acknowledged, Stop; if still active 60 s later, Abort"), wrap the AlarmDefinition in a multi-stage escalation policy. See Multi-stage escalation at the end of Pattern 3. For most plants this is overkill, and the single-action exceptionAction shortcut is enough.

A glossary of the related terms (interlock, permissive, safe-hold step, exception action) lives in docs/isa-88/terms.md.

The two layers of alarming

Cloud-Native DCS distinguishes between two alarm sources, and you need both:

Source Where it lives Purpose
In-module deviation indicator Inside the control-module FB network (e.g. analog-control raises an internal DEV flag when CV ≠ FB outside a band) Real-time UI annunciation on the faceplate. Doesn't create an Alarm CR; doesn't enter the audit trail; doesn't hold the batch.
AlarmDefinition CR A kind: AlarmDefinition resource in the site namespace. The controller polls or watches the source and creates an Alarm CR when the condition fires. The official ISA-18.2 / 21 CFR Part 11 alarm record. Enters the audit trail. Optionally auto-issues an ISA-88 Hold/Stop/Abort to affected batches.

In-module indicators are configuration-free. They're a property of the template. AlarmDefinitions are explicit configuration. You decide which signals deserve an audit-traceable alarm and which exception action to take.

Rule of thumb: every signal that should pause a batch, page an operator, or appear in the batch production record needs an AlarmDefinition. The in-module indicator is purely a faceplate hint.

Which interface to use

AlarmDefinition CRs can be authored four equivalent ways. Pick whichever fits the workflow. The patterns below show both the inline YAML (so you can read the full spec at a glance) and a compact UI / CLI / API / YAML tab summary at each apply point:

  • UI/system → ControlModule / Unit / IOModule detail → Alarm Definitions section → + Add. See Alarm Management for the full flow.
  • CLIdcs apply -f alarm.yaml (or dcs alarm create ... for simple shapes).
  • API — PUT against /api/v1/sites/{site}/alarmdefinitions/{name}.
  • YAML — author locally and apply with dcs apply -f.

Editing a definition while the plant is running

An AlarmDefinition is a live protection. The alarm operator evaluates it against the running plant on every pass, so an edit moves the trip point of something that is watching a batch right now. PUT /api/v1/sites/{site}/alarmdefinitions/{name} refuses with 409 while the equipment spec.source names is executing. Only spec.message may still move. The condition, the severity and priority, spec.enabled and spec.exceptionAction all wait.

A chattering alarm has a better answer than a rewritten limit. Shelve it, which is ISA-18.2's mechanism for a nuisance during production. Shelving is available at the operator tier, and it leaves the rule intact and the record honest. See Shelve an alarm. Change the rule itself between batches, which is where ISA-18.2 puts a limit change anyway.


Pattern 0: device-level interlock (the equipment layer)

A device interlock lives on the output function block itself (DO or AO). While its trip condition is true, the block writes its configured safe value to the device in place of the commanded value, every scan, on the edge node. It does not matter whether a phase is running, what mode the unit is in, where the command came from (recipe, operator write, or stale override), or whether the control plane is reachable. The block's ILCK_ACTIVE output, surfaced as the ILCK tag on the library control modules, shows the trip on the faceplate and on the Device interlocks panel's ARMED/TRIPPED badge. It is an ordinary block output published on the runtime's 200 ms cycle (ADR 0025), so every trip is in the historian whether or not anything alarms on it.

Two trip sources, OR'd together:

Source How When to use
interlockAddress parameter The block reads a boolean trip signal from the given device address each scan (interlockInvert: "true" flips its sense; an unreadable or quality-Bad signal trips the interlock — fail-safe) Single-signal conditions, and enabling an interlock on an existing ControlModule instance without touching the template
ILCK input port Wire any boolean logic in the template's FB network into the port (AND/OR/NOT for compound conditions, SR for latching) Compound or latching conditions authored in a template

A wire into ILCK is resolved when the program loads. ILCK is a declared input port of both output blocks, so a connection that misspells it is refused at deploy and the ControlProgram reports Deployed=False (#1655). That refusal matters more here than anywhere else in a network. The bus matches a connection on block and port together, so a misspelled ILCK used to leave the block reading nothing at all. An interlock that is never asserted looks exactly like one that has never tripped.

The interlock is non-latching. When the trip condition clears, the commanded value flows again on the next scan. If your process requires a manual reset after a trip, latch the condition upstream with an SR block wired into ILCK.

Worked example: mix-tank outlet valve vs. inlet valve

The reference plant interlocks mt1-outlet-valve against the inlet valve's position feedback. The outlet must never open while the inlet is open (no bypass path from the charge line straight into the transfer line). The trip signal is the inlet valve's limit-switch address. Both valves sit on the same unit, so the outlet's DO block reads it directly through the unit's I/O routing.

/system → expand the site → click the ControlModule (mt1-outlet-valve) → ConfigurationEdit. The template's parameter list includes the three interlock parameters. Set:

  • interlockAddress: tank-sim:discrete.1 (the inlet valve's feedback address)
  • interlockInvert: false (trip while the signal is true, i.e. while the inlet is open)
  • safeValue: (leave blank — defaults to de-energized/closed)

Save. The compiled program redeploys to the unit runtime and the interlock is live on the next scan. The ILCK tag appears on the module's faceplate ("Tripped"/"Clear").

Control Module form with template tag bindings and parameter fields: the solenoid-valve template's parameters, including the interlock set, are edited here

The form where these parameters are set, live: template picked, per-instance bindings and parameters filled, and the instance running seconds after Create. The clip configures an analog sensor, and the interlock trio itself is not touched on camera.

dcs apply -f mt1-outlet-valve.yaml
apiVersion: physical.dcs.io/v1alpha1
kind: ControlModule
metadata:
  name: mt1-outlet-valve
  namespace: site-riverbend
spec:
  parentName: mix-tank-1
  parentKind: Unit
  templateRef: discrete-valve
  tagBindings:
    feedback: "tank-sim:discrete.3"
    state: "tank-sim:discrete.2"
  # Trip while the inlet valve's feedback says "open". Evaluated by the
  # outlet's DO block every scan on the edge — active in every mode,
  # with or without a running phase, and during control-plane partitions.
  parameters:
    interlockAddress: "tank-sim:discrete.1"

This exact configuration ships in examples/riverbend/07-controlmodules.yaml. Notice what does not ship beside it: a TagTrue: ILCK alarm. This interlock trips whenever the inlet is open, which is every charge. A rule on the bare trip would annunciate a protection doing its job, and with an exceptionAction it would hold the batch that opened the inlet in the first place. See Do not alarm a routine trip below.

What ships instead is a pair of rules, and the split between them is the whole of ADR 0054:

  • mt1-outlet-valve-mismatch, a TagTrue condition on MISMATCH with exceptionAction: Hold. It catches equipment failure (a stuck valve or a broken limit switch) and holds the batch, because a batch cannot proceed through either.
  • mt1-outlet-valve-command-blocked, a TagTrue condition on CMD_BLOCKED with no exceptionAction. It catches the interlock defeating a commanded open, and it annunciates without acting.

MISMATCH does not cover the refusal, and that is deliberate. It is measured against the effective command (the value the output block actually wrote, safe value included). While the interlock forces the valve shut, the valve is doing exactly what it was driven to, and no fault is reported. CMD_BLOCKED is true precisely when the interlock is forcing the safe value and the standing command disagrees with it. The device interlock protects, CMD_BLOCKED records the command it refused, and MISMATCH is left to mean only that the device did not go where it was driven.

The full lifecycle on exactly this valve pair (trip, gated time-boxed bypass, output following its command while bypassed, and the bypass expiring and re-arming on its own) runs end-to-end here:

The device-interlock lifecycle on mt1-outlet-valve: trip → gated, time-boxed bypass → auto-expiry and re-arm → the 21 CFR Part 11 record.

Do not alarm a routine trip

An interlock trip is not automatically an alarm condition. Ask one question before you write a TagTrue: ILCK rule: does normal sequencing exercise this trip source?

The trip source is… Example Alarm the bare trip?
Exercised by normal sequencing Outlet valve interlocked against the inlet, where the recipe opens the inlet on every charge No. The interlock arms on schedule. Alarming it annunciates a protection doing its job.
Abnormal by definition Heater interlocked against low vessel level, where a running batch should never be at low level Yes. A trip means something has already gone wrong, so it wants a timely operator response.

A routine trip alarmed anyway is a nuisance alarm, the failure mode ISA-18.2 alarm rationalization exists to prevent, and it does more damage than noise when the rule carries an exceptionAction. The alarm fires during a normal step, the exception action holds the batch executing that step, the holding chart reverses the step, the trip clears, and Restart walks straight back into it. The batch cannot get past the step that arms the interlock.

The abnormal case is covered without a rule on the bare trip. A trip that defeats a command shows up on CMD_BLOCKED, which is true only while the interlock is forcing the safe value against a standing command that disagrees with it. That is the alarm you want: it fires when the plant tried to do something the interlock forbade, and stays silent when the interlock is simply armed.

If you need the trip itself on the record, it is already there. ILCK is an ordinary block output on the runtime's 200 ms publish cycle, so the historian holds every transition.

Never give a refusal an exception action

A rule on CMD_BLOCKED carries no exceptionAction, and the reason is sharper than the nuisance-alarm argument above.

An exception action invokes the holding chart, and the holding chart drives the unit's safe state (ADR 0008). A safe state is free to move exactly the process conditions an interlock reads, and on this valve pair it does. The riverbend holding chart closes the inlet valve, and the inlet valve's feedback is the outlet interlock's trip signal. Give the refusal an exception action and you get a closed loop:

operator commands the outlet open
   → interlock refuses it, forces the valve shut
      → refusal alarms with exceptionAction: Hold
         → batch holds, holding chart drives the safe state
            → safe state closes the INLET valve
               → inlet closed is the interlock's trip condition
                  → interlock clears
                     → the maintained command is still standing
                        → the outlet opens, ~4 s after being refused

Nobody re-authorised the open. The plant's own response to the refusal removed the condition that justified it. This was measured on the shipped fermentation example (#1482) and it is why the two rules are split.

The general shape to check in your own interlock set: an interlock whose trip condition can be disturbed by an automatic response to that same trip. The product guarantees it will not clear a trip on the operator's behalf. No refusal it ships carries an exception action. It cannot guarantee that your holding, stopping, or aborting chart leaves your trip sources alone. When writing an interlock, ask what the unit's safe state does to the signal the interlock reads. If the safe state touches it, either the refusal must stay un-actioned (the default here) or the trip must be latched upstream with an SR block so clearing the condition is not enough to release it.

Note what this does not change: the interlock is non-latching and the command is maintained, so clearing the trip does release a standing command to the device (ADR 0007). That is ordinary DCS behaviour and it stays. What changed is that only a deliberate act gets you there.

Behavior details worth knowing

  • Operator writes cannot defeat it. A tag write to the command (e.g. CMD on a valve) lands as a runtime output override feeding the block's IN. The interlock is applied after IN is read, so the safe value still wins. This is precisely the manual-mode gap that phase guards cannot cover.
  • Bypassing it is a first-class, gated action. A device interlock can be bypassed through a dedicated, permission-gated path (interlock:bypass, engineer/admin by default). It is never a quiet override of the trip source. A bypass is time-boxed: it auto-expires on the edge and re-arms the interlock on its own (and re-arms on a runtime restart), suppressing the entire trip (both the wired ILCK and interlockAddress) for its window. It is annunciated via the block's ILCK_BYPASSED output (a BYPASSED badge on the ControlModule faceplate, showing the time remaining until auto-expiry) and is audited with the acting user, a required reason, and the expiry on set/clear (21 CFR Part 11). Wire an AlarmDefinition to ILCK_BYPASSED as the audit companion. See ADR 0010. To bypass: ControlModule detail → Device interlocksBypass…, or POST /api/v1/sites/{site}/controlmodules/{cm}/interlock/bypass.
  • Fail-safe on bad signal. If interlockAddress cannot be read or reads quality-Bad, the interlock trips. Pair the IOModule with StateEquals: Fault/Offline alarms (checklist item 3) so the cause is annunciated.
  • PID loops need the integrator held — and tracked. When an AO downstream of a PID is forced, wire the trip signal into the PID's TRK input. Otherwise the integrator winds up against the uncorrectable error and bumps the output on release. Freezing alone is not enough if the process moves while forced: also wire the AO's OUT (the value actually written) into the PID's TRK_VAL input. The PID then back-calculates its integrator so its output tracks what the device really sees, and the loop resumes bumplessly from the real output on release. The library pid-loop and pid-cascade templates wire both edges from write_out already, so this is automatic for any instance that enables the device interlock.
  • Scope is one unit. The trip signal must be readable through the unit's own I/O. Cross-unit conditions belong to the phase and alarm layers (see ADR 0007).
  • CMD_BLOCKED will follow — MISMATCH will not. On the library valves, a trip while the recipe commands "open" raises CMD_BLOCKED, which is the record of the refused command. It does not raise MISMATCH: the valve went exactly where the block drove it, and calling that a device fault is what let a refusal hold a batch (ADR 0054). A valve that fails to reach the forced safe position is a real fault and does raise MISMATCH, interlocked or not.

Pattern 1: discrete-valve MISMATCH (boolean fault with debounce)

Every binary on/off valve in the reference plant uses the discrete-valve control module, which exposes a MISMATCH boolean tag. It's true whenever the position the block drove the valve to and the field feedback (FB) disagree. During normal operation the valve takes 500 ms to 2 s to stroke, so MISMATCH is briefly true on every state change. An alarm rule that fires immediately on MISMATCH = true would nuisance-trip every batch.

The driven position is CMD on an uninterlocked valve, which is every valve that does not set interlockAddress. Where the two differ (an interlock forcing the safe value against a contrary command), the difference belongs to CMD_BLOCKED (ADR 0054).

Derived state words don't alarm — their Boolean companions do

A module with a derived string STATE tag (ADR 0018, e.g. solenoid-valve's Fault/Open/Closed/Traveling) keeps its alarm duty on Boolean tags computed from the same subnetwork: alarm conditions evaluate Boolean/numeric tags. String equality is not among them. To annunciate an alarm-worthy state, wrap the Boolean companion (e.g. the both_limits output behind Fault, or MISMATCH itself) with an AlarmDefinition. The state word is the operator's reading, and the Boolean companion is the alarm source.

The wrong way

The mistake is the same in either interface: no debounce. In the UI it's an empty/zero Debounce seconds field on the Alarm Definitions form. In YAML it's an omitted condition.debounceSeconds. Both produce an alarm that fires on the first scan cycle where MISMATCH is true, which happens during every normal valve stroke.

/system → ControlModule detail (r1-wfi-valve) → Alarm Definitions card → + Add. Fill:

  • Condition type: Tag True
  • Tag name: MISMATCH
  • Severity: High
  • Message: WFI valve fault
  • Debounce seconds: (leave blank / 0) ← the mistake

Click Create → the definition is live and will nuisance-trip on the next batch. Don't actually do this on a shared cluster.

ControlModule detail: Alarm Definitions + Add form on a Tag True condition. The Debounce seconds field sits empty, which is the anti-pattern called out in this section

# DON'T do this — it nuisance-trips on every normal valve stroke.
apiVersion: alarm.dcs.io/v1alpha1
kind: AlarmDefinition
metadata:
  name: r1-wfi-valve-mismatch-bad
  namespace: site-riverbend
spec:
  source: { kind: ControlModule, name: r1-wfi-valve }
  condition:
    type: TagTrue
    tagName: MISMATCH
    # No debounce — fires within one reconcile cycle of the bool going true.
  type: Equipment
  severity: High
  message: "WFI valve fault"

The right way

Add condition.debounceSeconds so the alarm only fires when MISMATCH has been continuously true for longer than the valve's normal travel time:

/system → expand the site in the left sidebar → click the ControlModule (r1-wfi-valve in this example). Scroll to the Alarm Definitions card → click + Add. Fill:

  • Condition type: Tag True
  • Tag name: MISMATCH
  • Severity: High
  • Message: Reactor WFI valve CMD/FB mismatch
  • Debounce seconds: 3 ← the fix — rides through normal travel

Click Create. Each row carries Duplicate and Delete actions. There is no in-place edit. To change a rule, re-apply its YAML.

ControlModule detail: Alarm Definitions card with the + Add form open on Tag True, showing the condition-type picker, a tag-name field with no threshold, and the debounce-seconds field this pattern fills in

This pattern's whole surface: the condition catalog morphing the form, the debounce field filled deliberately, and the Duplicate/Delete ceremony that stands in for in-place edit. The same form serves Patterns 2 and 3's variant stills.

dcs apply -f r1-wfi-valve-mismatch.yaml
apiVersion: alarm.dcs.io/v1alpha1
kind: AlarmDefinition
metadata:
  name: r1-wfi-valve-mismatch
  namespace: site-riverbend
  labels:
    alarm.dcs.io/source-kind: ControlModule
    alarm.dcs.io/source-name: r1-wfi-valve
spec:
  source:
    kind: ControlModule
    name: r1-wfi-valve
  condition:
    type: TagTrue
    tagName: MISMATCH
    debounceSeconds: 3      # ride through normal travel
  type: Equipment
  severity: High
  message: "Reactor WFI valve CMD/FB mismatch  suspected stuck valve or feedback fault"
  priority: 2
  exceptionAction: Hold     # auto-pause any running batch using this unit

Key points:

  • type: TagTrue — fires when the boolean tag becomes logically true. Boolean tags are stored as floats in the runtime tag store, and the controller treats value >= 0.5 as true to tolerate numerical noise.
  • debounceSeconds: 3 — the alarm only fires after MISMATCH has been continuously observed true for 3 seconds. Three seconds covers a typical pneumatic stroke (500 ms to 2 s) plus a safety margin. For slow valves, raise to 5–10 s. For real hardware, measure your valve's travel time first.
  • Debounce is one-sided — clearing is always immediate. If the valve unsticks itself the alarm clears on the next reconcile cycle without waiting another 3 seconds.
  • Reset on transient clear — if MISMATCH flickers true → false → true, the debounce window resets each time it goes false. Only an uninterrupted 3-second run of true triggers the alarm.
  • exceptionAction: Hold — when the alarm fires, the controller looks up every running batch whose allocated units include r1-wfi-valve's parent unit (reactor-1) and issues an ISA-88 Hold command to each of their procedures. The batch pauses. The operator investigates, and issues Restart from the HMI when the underlying problem is resolved. The rule's ExceptionActionIssued status condition reports whether the command actually reached a procedure. See Alarm Management → Confirm an exception action reached the batch.

You'll find this pattern wired up for every discrete valve in examples/riverbend/25-alarms.yaml, shipped with the product source. It carries one MISMATCH alarm per binary valve in the reference plant.

Tuning debounce for real equipment

Cheap nuisance-trip workaround: bump debounce from 3 s to 30 s. Don't.

Instead:

  1. Open the trend chart on the valve's CMD and FB tags during a normal recipe run.
  2. Measure the longest CMD/FB divergence you see during a clean stroke.
  3. Set debounceSeconds to 2× that worst-case (so a marginal stroke doesn't trip but a true fault still does).

Typical values:

Valve type Debounce
Solenoid (electric, fast) 1 – 2 s
Pneumatic on/off 3 – 5 s
Pneumatic globe with positioner 5 – 10 s
Manual handwheel-actuated motorized 10 – 30 s

Pattern 2: analog deviation with deadband + debounce

Deviation alarms are subtler than threshold alarms. A flow controller will always deviate briefly during a setpoint change, when the upstream supply hiccups, or when the operator manually adjusts the valve in Manual mode. A naive TagDeviation rule alarms on every recipe step transition.

You need both kinds of hysteresis:

  • deadband — value-based hysteresis. Once the alarm is active, the deviation has to drop below (threshold - deadband) to clear. Prevents oscillation chatter at the boundary.
  • debounceSeconds — time-based hysteresis. The deviation has to persist for this many seconds before activating. Filters transient spikes and setpoint-change recovery time.

Same flow as Pattern 1: navigate to the analog ControlModule in the sidebar → Alarm Definitions card → + Add → pick Tag Deviation for the condition type. The form takes the tag name, threshold, deadband, and debounce-seconds inline.

ControlModule detail: Alarm Definitions + Add form. The debounce-seconds input sits below the condition fields, and picking a limit or deviation type adds threshold, setpoint, and deadband inputs beside the tag name

dcs --site newark-plant apply -f flow-deviation.yaml
apiVersion: alarm.dcs.io/v1alpha1
kind: AlarmDefinition
metadata:
  name: flow-deviation
  namespace: site-newark-plant
  labels:
    alarm.dcs.io/source-kind: ControlModule
    alarm.dcs.io/source-name: flow-meter
spec:
  source:
    kind: ControlModule
    name: flow-meter
  condition:
    type: TagDeviation
    tagName: PV
    threshold: "5.0"        # alarm when |PV - SP| > 5 L/min
    setpoint: "50.0"        # nominal setpoint; can also be the SP tag's current value if you maintain it externally
    deadband: "1.0"         # only clear when |PV - SP| <= 4 L/min
    debounceSeconds: 10     # ignore transients shorter than 10s
  type: Equipment
  severity: Medium
  message: "Flow rate deviation exceeds tolerance"
  priority: 3

For temperature-control loops on a reactor or filter-dryer jacket, 30–60 s is more typical because thermal lag dwarfs flow lag.

When to use TagHigh vs TagDeviation

Use TagHigh / TagLow when... Use TagDeviation when...
You're alarming on an absolute hardware limit (overpressure, overtemperature) You're alarming on a controller failing to track its setpoint
The threshold doesn't depend on the current setpoint The threshold is "X away from wherever I'm trying to be"
The recipe might intentionally drive the PV outside a fixed band The PV should always stay near the controller's setpoint
Catching hardware faults Catching tuning, supply, or instrument problems

A common mistake is using TagDeviation for both. Reserve it for "is my controller doing its job". Use TagHigh/TagLow for safety limits. examples/riverbend/25-alarms.yaml shows the absolute-limit pattern. TagHighHigh on reactor temperature with threshold 150 °C is a hardware overtemperature alarm. Tuning deviations are TagDeviation's job.


Pattern 3: phase-level guard + parallel alarm

Patterns 1 and 2 cover post-hoc alarming. The condition occurs, then the alarm fires, then the batch is paused. For genuinely safety-critical conditions you also want a proactive guard in the phase itself: a transition that immediately moves the SFC into a held state on the unsafe condition, regardless of where the phase happens to be in its sequence, and that executes the sequence of safe actions in the right order.

Wire all the layers. The device interlock (Pattern 0) provides the always-on stop at the output. The phase guard provides the ordered procedural response. The alarm provides the audit-traceable, operator-visible record.

Phase-level guard

In a phase template's SFC, every step that drives equipment can include a guarded transition to a safe-hold step. Example from a heat-and-react phase.

Open the phase template in /system → Equipment Library → Phases → the template's detail → Edit SFC. Declare the trip threshold first, in the toolbar's Parameters panel, as a REAL named maxPressure. A chart that names a variable it has not declared is refused as a whole document, with variable maxPressure used before assignment. The declaration has to come before the condition that reads it. Then add a safe-hold step whose action writes the safe-state outputs (vent open, jacket and agitator off). From each equipment-driving step, draw a second transition to safe-hold, select it, and in the transition inspector set its Condition ST to the trip expression (READ('pressure_sensor.PV') > maxPressure) and tick Interlock guard.

Ticking the box moves Priority to -1 if it is still at the default, because that is what the admission webhook requires: an interlock transition must carry a trip condition and a negative priority, so it evaluates before the normal flow transitions. Save a guard that breaks either rule and the webhook rejects the whole template with the reason.

The transition inspector offers Interlock guard on a phase chart and on no other. A guard writes the unit's own control-module tags, which is equipment control. A step on an operation, unit-procedure or recipe-procedure chart names a child procedural element, and it drives nothing itself (ADR 0069). A procedure that needs a guard gets it from the phase template its step names.

kubectl apply is refused there too. An admission webhook rejects transitions[].interlock on OperationTemplate, UnitProcedureTemplate and ProcedureTemplate, on create and on update, and names the transition to delete. Above the phase the flag stored and armed nothing, while the change-control diff rendered it as a structural change for an approver to sign. A guard already on one of those documents is cleared by the next save from the editor.

SFC editor: phase-template canvas with a divergence fan-out to a safe-hold branch

The pattern authored end to end: the two parameters the chart reads, the safe-hold branch, the trip condition, and Interlock guard ticked, which moves Priority to -1 because the webhook requires it. The save then runs into ISA-88's acting-state requirement. The editor's offer to seed safe-state placeholders is accepted, the template is created, and the finished guard is read back with its trip condition still there.

spec:
  parameters:
    - { name: maxPressure, type: REAL, unit: bar, defaultValue: "2.5" }
    # ... other parameters ...
  chart:
    initialStep: heat
    steps:
      - name: heat
        actionST: |
          WRITE('jacket.SP', target_temp);
          WRITE('jacket.MODE', 'AUTO');
      - name: react
        actionST: |
          WRITE('agitator.SP', agitate_speed);
      - name: safe-hold
        # Reached only via the over-pressure guard. Vent before holding.
        actionST: |
          WRITE('jacket.SP', 0);
          WRITE('agitator.SP', 0);
          WRITE('vent_valve.CMD', TRUE);
      - name: done
    transitions:
      - fromStep: heat
        toStep: react
        conditionST: "READ('temp_sensor.PV') >= target_temp"
      - fromStep: react
        toStep: done
        conditionST: "react.T >= hold_time"
      # Over-pressure guard — applies to BOTH heat and react steps.
      # Activates immediately when the trip threshold is crossed. The SFC
      # engine evaluates transitions from lowest Priority to highest each
      # scan and picks the first enabled one; a negative priority makes
      # this guard evaluate before the normal flow transitions (which
      # default to Priority 0).
      #
      # `interlock: true` marks the transition as a BPCS interlock guard.
      # The PhaseTemplate validating webhook rejects the template at admission
      # time unless every interlock=true transition carries a non-empty
      # conditionST and priority <= -1 — so a missing trip expression or a
      # forgotten priority bump is caught before the template is referenced
      # by a running batch (issue #349).
      - fromStep: heat
        toStep: safe-hold
        conditionST: "READ('pressure_sensor.PV') > maxPressure"
        priority: -1
        interlock: true
      - fromStep: react
        toStep: safe-hold
        conditionST: "READ('pressure_sensor.PV') > maxPressure"
        priority: -1
        interlock: true

What the phase guard adds on top of the device layer, and what it doesn't:

  • Sequenced, multi-output response. The phase knows the order of the safe actions (open the vent first, then cut heat and agitation) and parks the procedure in a resumable safe-hold step. A device interlock can only force individual outputs to static safe values. It cannot sequence, and it does not interact with the procedure state.
  • Faster than the alarm path. The SFC re-evaluates transitions every scan, versus the 10-second poll (plus debounce) of a tag-based AlarmDefinition.
  • Not the interlock itself. The SFC engine runs in the procedural operator on the control plane and only while the phase executes, so it covers neither idle/manual operation nor a control-plane partition. Outputs whose unsafe combination is hazardous still need a Pattern 0 device interlock underneath. The guard is the procedural layer above it (ADR 0007).
  • The exception action complements the guard and replaces nothing. The alarm in the next section runs in parallel, and both fire on the same physical event.

Parallel alarm CR for the audit trail

Same flow: navigate to the ControlModule for the pressure transmitter in the sidebar → Alarm Definitions card → + Add → pick Tag High-High for the condition type and enter the trip threshold. The SFC guard and AlarmDefinition fire in parallel on the same event.

ControlModule detail: Alarm Definitions + Add form. The condition-type picker carries Tag High-High for an absolute-limit alarm alongside the boolean types shown here

dcs apply -f r1-overpressure.yaml
apiVersion: alarm.dcs.io/v1alpha1
kind: AlarmDefinition
metadata:
  name: r1-overpressure
  namespace: site-riverbend
  labels:
    alarm.dcs.io/source-kind: ControlModule
    alarm.dcs.io/source-name: r1-pressure-sensor
spec:
  source:
    kind: ControlModule
    name: r1-pressure-sensor
  condition:
    type: TagHighHigh
    tagName: PV
    threshold: "3.0"        # absolute trip — set ABOVE the phase guard's 2.5
    deadband: "0.1"
    debounceSeconds: 5      # ride through transient pressure spikes
  type: Equipment
  severity: Critical
  message: "Reactor over-pressure (>3.0 bar)  potential containment breach"
  priority: 1
  exceptionAction: Hold

Notice the alarm's threshold (3.0 bar) is higher than the phase guard's (2.5 bar). The phase moves the SFC into safe-hold first, proactively venting. If pressure continues to rise (meaning the safe-hold action didn't recover), the alarm trips at the higher threshold and the exception action holds the entire batch. The alarm becomes the "safe-hold didn't work, escalate" signal.

The audit record from the alarm becomes the official pharma-record entry showing the over-pressure event, its severity, the time, and the operator response.

Multi-stage escalation

The AlarmDefinition exceptionAction takes a single action from Hold/Stop/Abort. For most plants that is enough. A dedicated multi-stage escalation kind (timed Hold → Stop → Abort policies, the ISA-88 Clause 7.4 escalation model) shipped as the ProcessException CRD until #863: nothing created one at runtime, so it was purged. Re-add on customer demand. Git history has the CRD and controller.


How alarms reach the operator

flowchart TD
    Tag[ControlModule tag changes]
    Ctl["AlarmDefinition controller<br/>polls every 10 s for tag conditions,<br/>watches K8s events for state conditions"]
    Alarm[Alarm CR created]
    HMI[HMI alarm badge + Alarms view]
    Audit[Audit trail, immutable]
    Hist[Historian alarm timeline]
    Hold["exceptionAction: Hold (if set)"]
    Resolve[Controller resolves source, affected units, running batches]
    Ann["Procedure annotation<br/>dcs.io/command: Hold"]
    Pause[Procedure controller pauses execution at the next safe transition]

    Tag --> Ctl
    Ctl -->|condition met after debounce| Alarm
    Alarm --> HMI
    Alarm --> Audit
    Alarm --> Hist
    Alarm --> Hold
    Hold --> Resolve
    Resolve --> Ann
    Ann --> Pause

The alarm count increments on the HMI's top-nav alarm badge (#alarmBadge). Clicking it opens the full Alarms view where the operator can acknowledge each alarm. After fixing the underlying problem the operator issues Restart to resume the held batch.


Practical checklist for a new equipment area

When you bring up a new unit, audit your protections with this checklist:

  1. Every output whose unsafe combination with another signal is hazardous has a device interlock (Pattern 0): pump vs. closed suction valve, outlet vs. inlet, heater vs. low level. Equipment constraints belong to the equipment, permanently, whatever recipe happens to be running. Reserve a TagTrue: ILCK alarm for interlocks whose trip is abnormal in itself. Where normal sequencing exercises the trip source, leave the trip unalarmed and let CMD_BLOCKED catch the commands it defeats (see Do not alarm a routine trip). The historian records every trip either way.
  2. Every discrete valve has a MISMATCH alarm, debounced to ride through normal travel time, with exceptionAction: Hold.
  3. Every safety-critical PV has a hardware-limit alarm — not a deviation alarm. Use TagHighHigh / TagLowLow with absolute thresholds set by the equipment specification. The recipe sets none of them.
  4. Every IOModule has fault and offline alarmsStateEquals: Fault and StateEquals: Offline with exceptionAction: Hold. These are the "lost the controller" alarms and they must hold every batch using that I/O. They also annunciate the cause when a fail-safe interlock trips on an unreadable signal.

    Do not add exceptionAction: Hold to a StateEquals: Unknown definition. Unknown means the io-probe did not answer, so nothing measured the device. The runtime driving the unit holds its own connection and keeps scanning (ADR 0053). A StateEquals: Unknown alarm without an exception action is worth having (it tells the operator the plant has lost sight of a device), and the platform raises one of its own regardless. 5. Critical analog control loops have deviation alarmsTagDeviation with both deadband and debounce, scoped to your tuning tolerance. Don't auto-Hold these unless your process really requires it. Let the operator decide. 6. Phases that drive hazardous equipment have transition guards independent of the alarm rules. The guard sequences the procedural safe-state. The device interlock underneath provides the always-on stop. The alarm provides the audit record and the batch-level Hold.

The complete example covering all of these (with comments explaining each choice) is examples/riverbend/25-alarms.yaml. It ships with the product source. Use it as a starting template for your own equipment areas.