Sequential control¶
21. Sequential Control with SSFC¶
All of the zqel constructs you have seen so far are about values: rules that define what an output should be, invariants that constrain what values are possible, composition that wires values together. This is the right tool when the question is "what should the motor speed be?" or "is the alarm active?"
Many controllers also have a sequential dimension: the process fills first,
then heats, then holds at temperature, then drains — and can be pre-empted into
a safe-stopped state at any point. This is mode logic. Encoding it as a set
of output rules works, but you end up managing boolean phase-registers manually,
writing edge detectors with PRE() to move between phases, and asserting the
"only one phase active" invariant by hand.
zqel provides a second declaration form — the SSFC (Super Sequential Function
Chart) — for exactly this. An SSFC is a hierarchy of named states with
declared transitions between them. Semantically, an SSFC declaration defines
a FUNCTION_BLOCK type: it is a stateful, instantiable component whose behavior
is expressed as a state machine rather than as a set of rules. It is not a
separate runtime; it lowers to the same zqel IR and is proved, compiled, and
emitted by the same pipeline.
The framing that matters:
Use zqel rules to describe what values should hold.
Use an SSFC to describe which phase the controller is in, how it moves between phases, and what happens on entry, while active, and on exit.
21.1 A minimal state machine¶
SSFC TankFill
VAR_INPUT
go : BOOL;
level_high : BOOL;
level_empty : BOOL;
END_VAR
VAR_OUTPUT
valve_fill : BOOL;
valve_drain : BOOL;
done : BOOL;
END_VAR
INITIAL STATE Idle
TRANSITION TO Fill WHEN go;
END_STATE
STATE Fill
EN: valve_fill := TRUE;
EX: valve_fill := FALSE;
TRANSITION TO Drain WHEN level_high;
END_STATE
STATE Drain
EN: valve_drain := TRUE;
EX: valve_drain := FALSE;
TRANSITION TO Done WHEN level_empty;
END_STATE
STATE Done
EN: done := TRUE;
EX: done := FALSE;
TRANSITION TO Idle WHEN TRUE;
END_STATE
END_SSFC
The same chart, drawn:
stateDiagram-v2
[*] --> Idle
Idle --> Fill : go
Fill --> Drain : level_high
Drain --> Done : level_empty
Done --> Idle : TRUE
Every arrow is one TRANSITION TO … WHEN … line, and the entry arrow is the
INITIAL keyword. The picture adds nothing the text does not say — which is
the point: an SSFC is meant to be read both ways.
Reading the syntax:
SSFC/END_SSFCwrap the block, likeFUNCTION_BLOCK/END_FUNCTION_BLOCK.VAR_INPUT/VAR_OUTPUT/VARwork identically toFUNCTION_BLOCK.- One state carries the
INITIALkeyword — it is active at startup. - Each state declares outgoing transitions:
TRANSITION TO <target> WHEN <guard-expression>; - Guards are pure Boolean expressions evaluated against the pre-state (the
value of all variables at the start of the current scan, before any lifecycle
block has run — exactly like
PRE(x)in aFUNCTION_BLOCK).
The scan model for SSFC extends the three-phase model (§2) to nine steps:
1 Snapshot pre-state — PRE(x) reads from here for the whole scan
2 Evaluate all guards — of active states and their superstates
3 Select transition — at most one may fire per scan
4 Execute EX blocks — of states being left, deepest-first
5 Update marking — which state is now active
6 Reset T_elapsed — newly entered states get T_elapsed = 0
7 Execute EN blocks — of newly entered states, outermost-first
8 Execute DU blocks — of ALL currently active states, outermost-first
9 Commit — outputs observable; becomes pre-state of next scan
If no guard is true in step 3, steps 4–7 are skipped; only DU runs (step 8).
21.2 Lifecycle blocks: EN, DU, EX¶
Every state may declare up to three lifecycle blocks. All are optional; a missing block is equivalent to an empty one.
| Block | Runs when | Typical use |
|---|---|---|
| EN | Exactly once: the scan the state is entered (after the marking update, before DU) | Set an output TRUE on entry; arm a timer; increment a counter |
| DU | Every scan the state is active (including the entry scan, after EN) | Continuous feedback; outputs that depend on sensor readings; T_elapsed-based values |
| EX | Exactly once: the scan the state is left (before the marking update) | Reset outputs that EN set; latch a result before leaving |
STATE Heat
EN:
heater := TRUE;
alarm_active := FALSE;
DU:
heat_warn := Heat.T_elapsed >= HEAT_WARN_D;
alarm_active := alarm_active OR temp_exceeded;
EX:
heater := FALSE;
heat_warn := FALSE;
TRANSITION TO Hold WHEN temp_reached;
END_STATE
- EN turns on the heater. Because
heateris register-backed, it holdsTRUEacross scans until something writesFALSE. No DU rule is needed to sustain it. - DU recomputes
heat_warnevery scan: it becomesTRUEonce the state has been active for at leastHEAT_WARN_D. It also accumulatesalarm_active. - EX clears both outputs in the same scan the state is left, so the next state inherits a clean slate.
PRE(x) vs. direct read inside lifecycle blocks: EX blocks run before EN
blocks in the same scan. A direct read of x inside EN sees any value that EX
already wrote. PRE(x) always reads the step-1 snapshot, regardless of what
earlier lifecycle blocks wrote. The rule: use PRE(x) for "the value this scan
began with"; use direct x for "what the current scan's earlier lifecycle blocks
produced."
21.3 Implicit fields: State.X and State.T_elapsed¶
Every state S exposes two read-only fields available in any lifecycle block or
transition guard in the same SSFC:
S.X : BOOL — TRUE iff S is currently active. Useful in DU blocks of a
parent superstate, in guards of sibling states, or in the rules of a composed
FUNCTION_BLOCK.
S.T_elapsed : TIME — The elapsed time since S was most recently entered.
Zero in the entry scan; increments by DT (the scan cycle time) each subsequent
scan. Reading T_elapsed of an inactive state is a static error.
STATE Hold
DU:
heater := Hold.T_elapsed < HOLD_TIME;
hold_ok_light := Hold.T_elapsed >= HOLD_CONFIRM;
TRANSITION TO Drain WHEN Hold.T_elapsed >= HOLD_TIME;
END_STATE
The compiler treats T_elapsed as a symbolic TIME accumulator. Timing
properties derived from it are verified for all consistent scan rates, not for
a fixed rate — the same approach as the HELD/ELAPSED predicates in §7.
21.4 Superstates and preemption¶
A superstate groups child states under a shared parent. Its lifecycle blocks run whenever any child is active. More importantly, a transition declared directly on the superstate is a preemption transition: it fires from any active child, without knowing which one.
SUPERSTATE Production
INITIAL STATE Fill
EN: valve_fill := TRUE;
EX: valve_fill := FALSE;
TRANSITION TO Heat WHEN level_high;
END_STATE
STATE Heat
EN: heater := TRUE;
EX: heater := FALSE;
TRANSITION TO Hold WHEN temp_reached;
END_STATE
STATE Hold
DU: heater := Hold.T_elapsed < HOLD_TIME;
TRANSITION TO Drain WHEN Hold.T_elapsed >= HOLD_TIME;
END_STATE
STATE Drain
EN: valve_drain := TRUE;
EX: valve_drain := FALSE;
TRANSITION TO Completed WHEN level_empty;
END_STATE
TRANSITION TO SafeStopped WHEN abort; -- preempts ANY of the four children
END_SUPERSTATE
stateDiagram-v2
state Production {
[*] --> Fill
Fill --> Heat : level_high
Heat --> Hold : temp_reached
Hold --> Drain : T_elapsed >= HOLD_TIME
Drain --> Completed : level_empty
}
Production --> SafeStopped : abort
The one arrow that leaves the box rather than a state inside it is the preemption: it has no source state because it fires from any of them.
TRANSITION TO SafeStopped WHEN abort is checked every scan regardless of which
child is active. When abort is TRUE, the entire Production superstate is left:
the active child's EX fires, then Production's EX fires (if declared), then
SafeStopped's EN fires — all in the same scan.
Priority rule: a superstate's own transitions always have higher implicit
priority than any child transition in the same scan. If Fill's level_high
guard and Production's abort guard are both TRUE simultaneously, the abort
transition is selected.
LCA-based EX/EN ordering: When a transition fires, the compiler computes the Lowest Common Ancestor (LCA) of the source and target states. EX blocks fire from the source up to (but not including) the LCA, deepest-first. EN blocks fire from the LCA down to the target, outermost-first. This mirrors a stack-unwind/push pattern: nested context is removed before new context is added.
21.5 Parallel regions and synchronisation¶
A superstate may contain parallel regions — two or more independent sub-machines that run concurrently while the superstate is active.
SUPERSTATE Calibrate
PARALLEL
REGION arm_a
INITIAL STATE A_Move
EN: joint_a := TRUE;
EX: joint_a := FALSE;
TRANSITION TO A_Done WHEN a_at_position;
END_STATE
STATE A_Done END_STATE
END_REGION
REGION arm_b
INITIAL STATE B_Move
EN: joint_b := TRUE;
EX: joint_b := FALSE;
TRANSITION TO B_Done WHEN b_at_position;
END_STATE
STATE B_Done END_STATE
END_REGION
JOIN FROM A_Done, B_Done TO Ready;
END_PARALLEL
TRANSITION TO Aborted WHEN emergency;
END_SUPERSTATE
STATE Ready
EN: ready_light := TRUE;
TRANSITION TO Idle WHEN NOT start;
END_STATE
stateDiagram-v2
state Calibrate {
state fork_c <<fork>>
[*] --> fork_c
fork_c --> A_Move
fork_c --> B_Move
A_Move --> A_Done : a_at_position
B_Move --> B_Done : b_at_position
state join_c <<join>>
A_Done --> join_c
B_Done --> join_c
}
join_c --> Ready
Calibrate --> Aborted : emergency
The JOIN is the bar both regions must reach: Ready is entered only once
both A_Done and B_Done are active. The emergency arrow again leaves the
box, not a state — it preempts both regions at once.
- Entering
Calibrateis a fork: bothA_MoveandB_Moveactivate in the same scan. - Each region advances independently. A_Move may reach A_Done while B_Move is still moving.
- The JOIN fires in the first scan where both
A_DoneandB_Doneare simultaneously active. It exits both regions atomically and entersReady. TRANSITION TO Aborted WHEN emergencypreempts both regions at once, just as a superstate preemption exits all child states in an exclusive SSFC.
Write-conflict rule: Two simultaneously-active regions must not write the
same output variable in lifecycle blocks. The compiler rejects it:
ssfc-cross-region-conflict.
21.6 What the compiler checks¶
python -m zqel batch_controller.zqel
The assurance report names eight claims:
| Claim | What it verifies |
|---|---|
| C-SAF | 1-Safeness: for every (region, transition) pair, token balance adds up. Exact. |
| C-INT | Region integrity: transitions stay within structural scope; parallel coverage is consistent. Exact. |
| C-DEAD | No leaf state is structurally stuck (zero outgoing transitions, no TERMINAL). Exact. |
| C-GUARD-SAT | Every WHEN guard is satisfiable over the type domain. SMT. |
| C-DET | No two guards of the same state can be simultaneously TRUE without PRIO. SMT. |
| C-DATA-DEAD | No guard is permanently FALSE. Environmental Waiting (EW) distinguished from deadlock. SMT. |
| C-REACH | Every state reachable via individually-SAT-satisfiable guard sequence. SMT. |
| C-HOME | Per-place witness path to a nominated home state found. Over-approximation: PASS ≠ guaranteed return-to-home. SMT. |
For an EW verdict on a transition: the machine is correctly waiting for a
VAR_INPUT to change — that is not a bug. A WARN on C-HOME for a state in a
parallel region means the per-place BFS found no witness path; this is a sound
indicator but does not constitute a formal deadlock proof.
Add TERMINAL to a state that is intended to be a valid resting point with no
outgoing transitions:
STATE Completed TERMINAL
END_STATE
21.7 Composing components with WIRING¶
An SSFC can host a child component — a FUNCTION_BLOCK or another SSFC — as
a wired sub-controller. There is exactly one WIRING block per POU, at the
SSFC's own level. It is not written inside a state, and it is not a call: it
declares connections that exist in every scan.
SSFC PasteurizerSequence
VAR_INPUT
temp_actual : MATHREAL;
abort : BOOL;
END_VAR
VAR_OUTPUT
heater_cmd : MATHREAL;
END_VAR
VAR
pid : PID_Temperature;
END_VAR
WIRING
pid(
setpoint := HEAT_SETPOINT,
actual := temp_actual,
enable := Heat.X (* only while Heat is the active state *)
);
heater_cmd := pid.output;
END_WIRING
INITIAL STATE Heat
TRANSITION TO Hold WHEN temp_reached;
END_STATE
STATE Hold
TRANSITION TO Drain WHEN Hold.T_elapsed >= HOLD_TIME;
END_STATE
END_SSFC
What WIRING does:
- The child instance
pidruns every scan, not only while some state is active. What a state controls is the value wired to the child — hereenable := Heat.X, which isFALSEwheneverHeatis not active. - Bindings are written as
child(port := expr, …)for inputs andparent_output := child.portfor outputs. - There is no
__ssfc_dtport. Elapsed time is runtime-owned: every SSFC tracks scan duration against the runtime clock, and a parent forwards nothing. inst*(star-wiring): if the child has input ports whose names and types match signals already in scope,pid*;wires them. Explicit bindings take priority.
What WIRING does not do: it does not alter state machine execution or create
a separate control thread. The child's scan is synchronous with the parent's —
one child update per parent scan — and its internal registers persist across
scans like any FUNCTION_BLOCK.
21.8 Signal ownership analysis¶
When multiple states write the same output, the compiler cannot statically guarantee that exactly one writer is active at any moment. zqel's Write Ownership analysis resolves this at analysis time.
The Pasteurizer above writes heater_cmd in exactly one place — its WIRING
block — so it needs no such analysis. The interesting case is a sequence whose
states each set the same output themselves, say heater_cmd in Heat's DU,
in Hold's DU, and in SafeStopped's EN. Run the analysis:
from zqel.ssfc_ownership import analyze_ownership
report = analyze_ownership(ssfc, net, mg)
for sig in report.all_signals():
print(sig.name, sig.ownership_class)
Typical output:
heater_cmd EXCLUSIVE_MULTI_WRITER
The result means: multiple states write heater_cmd, but the compiler has
proved they cannot all be active in the same scan. Heat, Hold, and
SafeStopped are in an exclusive hierarchy — at most one is active per scan —
so no write conflict is possible.
This is also the reason to prefer one WIRING block over per-state writes
where the choice exists: a single writer needs no proof of exclusivity at all.
OwnershipClass summary:
| Class | Safe? | Meaning |
|---|---|---|
SINGLE_WRITER |
Yes | Only one write site exists. |
EXCLUSIVE_MULTI_WRITER |
Yes | Multiple sites; mutual exclusion formally proved. |
COMPATIBLE_MULTI_WRITER |
Yes | Co-activatable sites, but they write the same value. |
CONFLICTING |
No | Co-activatable sites write different values. Design error. |
UNKNOWN |
Review | Mutual exclusion could not be determined. |
CONFLICTING is the only outcome that blocks CI (under the default policy).
UNKNOWN is worth reviewing but is not a failure by default.
A transitive component effect is automatically traced when a WIRING block
references State.X (the activity marker):
state activation → WIRING → child.input → (child FB) → child.output → parent output
These chains appear in report.component_effects and are analysed alongside
direct variable writes. See concepts/ownership-and-effects.md for the full
classification rules and how-to/audit-signal-ownership.md for the step-by-step
workflow.
21.9 Recovery and progress analysis¶
Write Ownership proves which state writes an output. Recovery and Progress analysis proves whether the SSFC is guaranteed to reach a designated safe state from any reachable configuration.
The Pasteurizer has a natural recovery pair: from Production (any child active)
to SafeStopped. Declare the environment's commitment as an ASSUME clause, then
run the analysis:
ASSUME abort; (* safety interlock: abort stays asserted once raised *)
from zqel.ssfc_recovery import analyze_recovery, render_claim_md
from zqel.ssfc_marking import build_marking_graph
mg = build_marking_graph(net)
claim = analyze_recovery("Production", "SafeStopped", ssfc, net, mg)
print(render_claim_md(claim))
The report has four independent claim levels:
STRUCTURAL ✓ PROVED
path: Production → SafeStopped (1 transition)
POSSIBLE ✓ PROVED
per-edge SAT path: abort is satisfiable
GUARANTEED ≈ PROVED-CONDITIONAL
all admissible executions reach SafeStopped
requires: ASSUME abort
BOUNDED ≈ PROVED-CONDITIONAL
within 1 scan (worst-case attractor rank)
requires: ASSUME abort
Reading the levels:
- STRUCTURAL checks the marking-graph topology, ignoring all guards. It confirms a path exists at all.
- POSSIBLE checks that a guard-satisfiable path exists (per-edge SAT). It does not prove all executions take that path.
- GUARANTEED proves that every admissible execution from
Productioneventually reachesSafeStopped.PROVED-CONDITIONALmeans this holds given the declared ASSUMEs; without them the guardabortmight never be TRUE and the system could remain inProductionindefinitely. - BOUNDED gives the worst-case number of scans. If the scan cycle is 10 ms
and
BOUNDED = 1, the guaranteed recovery time is at most 10 ms.
The assumptions_used field (on the GUARANTEED evidence) lists only the ASSUME
clauses that are load-bearing — those whose removal causes GUARANTEED to fail.
Non-contributing ASSUMEs are excluded.
A second pair, SafeStopped → Idle, depends on an operator action (reset_request).
With no ASSUME for it, GUARANTEED is NOT PROVED — which is the correct result:
the system can reach Idle (POSSIBLE = PROVED), but no external contract
guarantees it ever will. This is not a bug; it is an accurate characterisation
of an operator-triggered transition.
For the full step-by-step workflow — choosing states, when to add ASSUMEs, and how
to interpret NOT PROVED — see how-to/prove-safe-recovery.md.