One block¶
1. Your first block: an RS latch¶
A latch (or bistable) remembers a bit. Press set, it turns on and stays on;
press reset, it turns off and stays off. It is the “hello world” of PLC logic.
Here it is in zqel, fully commented (examples/rs_latch.zqel):
FUNCTION_BLOCK RS // declare a reusable function block named RS
VAR_INPUT // its inputs (read at the start of each scan)
R : BOOL; // R = reset button
S : BOOL; // S = set button
END_VAR
VAR_OUTPUT // its outputs (written at the end of each scan)
Q : BOOL; // Q = the stored bit
END_VAR
IMPLEMENTATION
Q: // define the output Q with a list of prioritised rules
OFF WHEN R PRIO 0; // if R is pressed, force Q off ... priority 0
ON WHEN S PRIO 1; // if S is pressed, force Q on ... priority 1 (higher!)
HOLD OTHERWISE; // otherwise keep Q's previous value
END_IMPLEMENTATION
END_FUNCTION_BLOCK
Design choice, not a law of nature. If
RandSare both1in the same scan, this block is set-dominant because we gaveSthe higher priority. A different priority assignment would give a different latch. zqel does not guess which RS behaviour you “meant” — you state it explicitly.
Read the Q: block as a set of competing rules, not as a sequence of steps. Each
rule says “under this condition, the output should become this value, and my
importance is this number.” The vocabulary:
| Keyword | Meaning |
|---|---|
ON / OFF |
make the output TRUE / FALSE (BOOL outputs only) |
SET expr |
assign the value of an expression (any type) |
HOLD |
keep the output's previous value (its memory) |
WHEN guard |
this rule is active only when guard is true |
PRIO n |
this rule's priority; bigger number = more important |
OTHERWISE |
the fallback rule; no condition, no priority |
Here is the single most important idea in zqel. Among all the rules whose
guard is currently true, the one with the highest PRIO decides the output.
The order in which you wrote the rules is irrelevant. Above, S has priority 1
and R has priority 0, so if you press both at once, set wins — this is a
“set-dominant” latch. If you wanted reset to win, you would swap the priority
numbers, not move the lines around. There is no single “canonical” answer here:
IEC 61131-3 itself ships both an SR (set-dominant) and an RS (reset-dominant)
standard block. Which one you want is a design decision, and in zqel you make it
by choosing the priorities — nothing else.
What the compiler makes of it¶
Let us compile it and ask for Structured Text:
$ python -m zqel examples/rs_latch.zqel --target st
FUNCTION_BLOCK RS
VAR_INPUT
R : BOOL;
S : BOOL;
END_VAR
VAR_OUTPUT
Q : BOOL;
END_VAR
VAR
_Q_next : BOOL;
END_VAR
_Q_next := (S OR (NOT R AND Q)); (* <-- the three rules, collapsed into ONE formula *)
Q := _Q_next;
END_FUNCTION_BLOCK
Look closely at that one line. Your three prioritised rules — set, reset, hold — were compiled into a single boolean formula:
Q_next = S OR (NOT R AND Q)
In words: Q becomes true if S is pressed (set wins), or else if R is not pressed and Q was already true (hold). This is exactly the classic set-dominant latch equation you would find in a textbook — but you did not write it. You wrote the intent (three rules and their priorities), and Z3 derived the equation, folding the priorities together in the correct order. That derived formula is the meaning of your block, and it is what every backend prints.
Why the extra variable
_Q_next? The compiler computes the new value into a temporary first (_Q_next), and only then assigns it toQ. Section 2 explains why this staging matters.
2. How a block runs: one scan, all-at-once¶
A subtle but important point: in a real PLC scan, all of a block's outputs are conceptually updated simultaneously, using the values they had at the start of the scan. If one output's formula reads another output, it must read the old value, not a half-updated new one.
zqel guarantees this, and you can see the mechanism in the generated ST: every new
value is computed into a _..._next temporary first, and only after all of them
are computed are they committed. The rising-edge detector in the next section shows
this clearly. Keep the pattern in mind:
1. compute _a_next, _b_next, ... (all from start-of-scan values)
2. commit a := _a_next; b := _b_next; ...
flowchart LR
S0["state at start of scan<br/>a, b, inputs"]
C1["compute _a_next<br/>from start-of-scan values"]
C2["compute _b_next<br/>from start-of-scan values"]
K{{"commit — all at once"}}
S1["state at start of the next scan<br/>a, b"]
S0 --> C1 --> K
S0 --> C2 --> K
K --> S1
Both arrows leave the same box. That is the whole point: _b_next cannot see
_a_next, no matter which one the generated code happens to assign first.
This is not something you write — it is how zqel renders “update everything at once” into a language (ST) that only has step-by-step assignment.
3. Two kinds of memory: registers and derived values¶
Every output or internal variable is defined in one of two ways, and the difference is exactly when a reference reads its value. This trips up beginners, so read carefully.
-
A register is defined by rules (like
Q:above). It is stored: it has memory that survives across scans. Inside a register's rules, a reference to another register means that register's value at the start of the scan — its “pre-value”, the old value. This is what you need for edge detection: to detect a change, you must compare against the previous value. -
A derived value is defined with the
:=operator (name := expression;). It is combinational: it is recomputed every scan and not stored. Inside a derived definition, a reference to a register means its freshly computed new value — no one-scan delay.
Beginners usually need one compact picture:
| Where you read a register | What you get |
|---|---|
inside a rule block (Q: / M: / ...) |
the old value from the start of the scan |
inside a derived definition (x := ...) |
the new value just computed for this scan |
If you remember only one sentence from this section, remember this:
rules read old state; := reads freshly computed state.
flowchart LR
OLD["registers<br/><b>old</b> values<br/>(start of scan)"]
RULES["rule blocks<br/><code>Q: SET … WHEN …</code>"]
NEW["registers<br/><b>new</b> values"]
DER["derived definitions<br/><code>x := …</code>"]
OLD -->|"a rule reads here"| RULES
RULES --> NEW
NEW -->|"a := reads here"| DER
style OLD fill:#fff3cd,stroke:#856404,color:#000
style NEW fill:#d4edda,stroke:#155724,color:#000
The two reads sit on opposite sides of the same box. That is why pulling a
subexpression out of a rule into a := definition is not a refactoring: it
moves the read across the boundary, and the program changes.
A rising-edge detector (R_TRIG, examples/iec/r_trig.zqel)
outputs a one-scan pulse each time its input goes from 0 to 1. It must remember
the previous input:
FUNCTION_BLOCK R_TRIG
VAR_INPUT
CLK : BOOL; // the signal we watch for a rising edge
END_VAR
VAR_OUTPUT
Q : BOOL; // Q = 1 for exactly one scan on a 0->1 edge
END_VAR
VAR
M : BOOL; // hidden internal memory: the PREVIOUS CLK
END_VAR
IMPLEMENTATION
Q: // Q is a REGISTER, so M below is read as its PRE-value
SET CLK AND NOT M OTHERWISE; // edge = CLK is high now AND was low last scan
M:
SET CLK OTHERWISE; // remember this scan's CLK for the next scan
END_IMPLEMENTATION
END_FUNCTION_BLOCK
VAR ... END_VAR declares hidden internal state — M here — that is not part
of the public interface. The generated ST makes the pre-value and the all-at-once
update visible:
_Q_next := (CLK AND NOT M); (* uses M = the OLD previous-CLK *)
_M_next := CLK; (* next scan's "previous CLK" *)
Q := _Q_next; (* only now do we commit the new values ... *)
M := _M_next; (* ... so _Q_next above saw the OLD M, not CLK *)
If both assignments happened immediately in order, M would be overwritten before
Q read it and the edge detection would break. The _next staging is precisely
what prevents that. This is the reading-time rule made concrete.
Rule of thumb. Need the previous value (edges, latches, counters comparing to a limit)? Use a register (rules). Need a value computed fresh this scan from other results (a plain combinational output)? Use
:=.
4. Priorities in depth, and the errors that keep them honest¶
Because priority is written down explicitly, the compiler can check two things a human reviewer usually misses.
(a) No silent tie-breaks. If two rules with the same priority could be active
at the same time but demand different results, that is not a coin toss — it is an
ambiguous-priority error, reported with a counterexample. You must give them
different priorities or make their conditions mutually exclusive. (Two rules with
equal priority that agree — e.g. several OFF rules — are fine.)
(b) No dead code. A rule that is always beaten by a higher-priority rule can
never affect the output. zqel reports it as an unreachable-rule warning so you
can delete it or fix its condition.
A bounded up-counter (examples/counter.zqel) uses a
realistic three-level priority stack:
FUNCTION_BLOCK Counter
VAR_INPUT
Reset : BOOL; // reset to zero
Count : BOOL; // count-up pulse
END_VAR
VAR_OUTPUT
Value : INT; // the current count
END_VAR
IMPLEMENTATION
Value:
SET 0 WHEN Reset PRIO 30; // reset beats everything
HOLD WHEN Value >= 10 PRIO 20; // at the limit: freeze (saturate)
SET Value + 1 WHEN Count PRIO 10; // otherwise, if counting, add one
HOLD OTHERWISE; // idle: keep the value
INVARIANT // a property we DEMAND the compiler prove:
Value >= 0; // the count is never negative
INVARIANT
Value <= 10; // and never exceeds 10
END_IMPLEMENTATION
END_FUNCTION_BLOCK
INT is a 16-bit signed integer with the same wrap-around behaviour as real PLC
hardware (add 1 to 32767 and you get −32768). zqel models this faithfully, so its
proofs are about what the hardware actually does, not an idealised infinite
integer. We will come back to the two INVARIANT lines in the next section.
The generated ST folds the four-rule priority stack into nested SEL calls
(SEL(cond, a, b) is the IEC selector: it returns b if cond is true, else
a — a two-way multiplexer):
(* proven invariant: (Value >= 0) *)
(* proven invariant: (Value <= 10) *)
_Value_next := SEL(Reset, SEL(((10 <= Value) OR NOT Count), (1 + Value), Value), 0);
Value := _Value_next;
Unfold it from the outside in and you recover your priorities exactly: if Reset,
result is 0; else if (Value >= 10 or not counting) keep Value; else
Value + 1. The two comment lines are the compiler telling you which properties
it proved before emitting this code.
4b. Saying when, not what: relational clauses¶
Everything so far computed a value: ON WHEN …, :=, a priority ladder. Some
requirements do not arrive in that shape. "The pump may only run while the
valve is open" is not a formula for the pump — it is a condition on the
pump, and turning it into a formula is work you do in your head and the next
reader has to undo.
RULE lets you write the condition itself. Three forms carry most of it:
| Form | Reads as |
|---|---|
RULE X ONLY_WHEN Y |
X may hold only while Y — a permission |
RULE X IMPLIES Y |
X forces Y — a trigger |
RULE X EXCLUDES Y |
X and Y never hold together |
A first attempt, and why it fails¶
An interlock, written the way one says it out loud:
FUNCTION_BLOCK Interlock
VAR_INPUT Demand : BOOL; ValveOpen : BOOL; Fault : BOOL; END_VAR
VAR_OUTPUT Pump : BOOL; END_VAR
IMPLEMENTATION
RULE Pump ONLY_WHEN ValveOpen;
RULE Pump EXCLUDES Fault;
RULE Demand IMPLIES Pump;
END_IMPLEMENTATION
END_FUNCTION_BLOCK
The compiler refuses, and the refusal is the interesting part:
warning: [ns1-pair-conflict] sufficient clause (line 7) and necessary clause
(line 5) on 'Pump' collide: the antecedent Demand can hold while the necessary
condition ValveOpen fails
verification failed:
[synthesis-contract-unrealizable] no output satisfies all RULEs for the input
assignment [Demand=True, Fault=False, ValveOpen=False]; colliding clauses:
RULE #1, RULE #3
Demand on, valve shut — no fault needed. One rule says the pump must run, one says it may not, and nothing satisfies both. The requirements were contradictory before any code existed, and the counterexample is the situation your plant would have found for you.
Read the two lines as the different things they are. The warning explains which two clauses collide and why, naming your own signals. The verdict underneath comes from the proof over the whole clause set, and it would fail with or without the explanation — which matters, because a collision that needs three clauses to appear escapes the pair analysis entirely.
The fix is to say what you meant — demand triggers the pump when it is allowed to:
FUNCTION_BLOCK Interlock
VAR_INPUT Demand : BOOL; ValveOpen : BOOL; Fault : BOOL; END_VAR
VAR_OUTPUT Pump : BOOL; END_VAR
IMPLEMENTATION
RULE Pump ONLY_WHEN ValveOpen;
RULE Pump EXCLUDES Fault;
RULE Demand AND ValveOpen AND NOT Fault IMPLIES Pump;
END_IMPLEMENTATION
END_FUNCTION_BLOCK
Compilation succeeded: Interlock
3 output properties verified
balance: Pump: 1 ON-necessary, 0 OFF-necessary, 1 ON-sufficient,
0 OFF-sufficient, 1 exclusion
Clauses constrain; they need not determine¶
Look at what came out:
(* proven invariant: NOT (Pump AND Fault) *)
(* proven invariant: (NOT ((Demand AND ValveOpen) AND NOT Fault) OR Pump) *)
Pump := (ValveOpen AND NOT Fault);
Demand is not in the generated code. That is not a bug — read the rules
again and you will see they never say the pump must be off without demand.
Pump := ValveOpen AND NOT Fault satisfies all three, and so would
Pump := Demand AND ValveOpen AND NOT Fault. Several implementations fit;
the compiler picked one and proved it fits.
The balance: line is the compiler telling you exactly this. It counts the
roles your clauses fill — here one ON-necessary, one ON-sufficient, and
zero OFF-necessary and OFF-sufficient. Nothing you wrote constrains when
the pump is off.
When you do want it pinned down, say so with IFF:
FUNCTION_BLOCK InterlockExact
VAR_INPUT Demand : BOOL; ValveOpen : BOOL; Fault : BOOL; END_VAR
VAR_OUTPUT Pump : BOOL; END_VAR
IMPLEMENTATION
RULE Pump IFF Demand AND ValveOpen AND NOT Fault;
END_IMPLEMENTATION
END_FUNCTION_BLOCK
Compilation succeeded: InterlockExact
1 output property verified
balance: Pump: defined (IFF)
Pump := (Demand AND ValveOpen AND NOT Fault);
Now Demand is there, because now you said it belongs there.
Which to reach for¶
Use clauses when the requirement is a condition — interlocks, permissions, mutual exclusions — especially when several of them come from different documents and different people. Each stays readable on its own, and the compiler folds them together and tells you when they disagree.
Use := or a priority ladder when you genuinely have a value to compute.
Neither is more advanced than the other; they answer different questions.