Putting blocks together¶
11. Building bigger blocks: composition¶
You build large systems by wiring small blocks together. VAR inst : OtherBlock;
creates an instance; inst(In := expr, ...) wires its inputs; inst.Out reads
its outputs. A start/stop station built from the reset-dominant RS latch
(examples/composition/StartStop.zqel,
reusing RS.zqel):
FUNCTION_BLOCK StartStop
VAR_INPUT
Start : BOOL;
Stop : BOOL;
END_VAR
VAR_OUTPUT
Running : BOOL;
END_VAR
VAR
latch : RS; // an instance of the RS block from RS.zqel
END_VAR
IMPLEMENTATION
latch(S := Start, R1 := Stop); // wire Start -> latch.S, Stop -> latch.R1
Running := latch.Q1; // expose the latch's output
END_IMPLEMENTATION
END_FUNCTION_BLOCK
The three lines of IMPLEMENTATION are exactly the three arrows into and out of
the instance:
flowchart LR
Start(["Start"]) --> S
Stop(["Stop"]) --> R1
subgraph latch["latch : RS"]
S["S"]
R1["R1"]
Q1["Q1"]
end
Q1 --> Running(["Running"])
StartStop owns no memory of its own. Everything it remembers, it remembers
through latch — which is why the latch's proven properties carry upward
rather than needing to be restated here.
At compile time zqel flattens the instance in: the sub-block's internals become
hidden locals of the parent, its inputs are replaced by the wired expressions, and
latch.Q1 reads its freshly computed output. The generated ST shows the flattened
result — the RS latch's equation, now with its state renamed latch__Q1:
VAR
latch__Q1 : BOOL; (* the sub-latch's memory, now a hidden local *)
_latch__Q1_next : BOOL;
END_VAR
_latch__Q1_next := (NOT Stop AND (Start OR latch__Q1));
Running := (NOT Stop AND (Start OR latch__Q1));
latch__Q1 := _latch__Q1_next;
Because composition is just flattening, everything else — proofs, backends — works unchanged. And the contracts compose:
- A sub-block keeps its own
INVARIANTs: instantiating a child never drops its safety proofs. - A parent may write invariants about a sub-instance's outputs (
inst.Out). - A child's
ASSUMEis honoured based on how it is wired, and the compiler splits it accordingly. If it resolves to the parent's own inputs, it bubbles up as a premise on the parent (the parent's caller must honour it). If the parent instead supplies it with internal logic (a local, register or constant), the parent must discharge it — the premise becomes a proof obligation the parent proves on the spot (wiring it toD := TRUEcompiles; wiring it toFALSEfails, as it must). Both directions work today. The split is syntactic (by which names the wired premise references), so a premise that mixes free inputs with internal state is not yet handled in full generality — the roadmap tracks the general “prove what you can, assume the residual” form.
11b. Template blocks — reusing logic at different sizes¶
Composition lets you wire a fixed-size RS latch into anything that needs one.
But what if you want a dot-product block that works for vectors of different
lengths? Without something more, you would write Vec3, Vec4, Vec5 as
separate files — identical logic, different size. That is the monomorphism trap.
Type template parameters solve this. Put the size in angle brackets after the block name, then use it like an ordinary integer everywhere inside:
FUNCTION_BLOCK Vec<N: UINT>
VAR_INPUT
a : ARRAY[0..N-1] OF LREAL;
b : ARRAY[0..N-1] OF LREAL;
END_VAR
VAR_OUTPUT
dot : LREAL;
END_VAR
IMPLEMENTATION
dot := SUM(k IN 0..N-1 : a[k] * b[k]);
END_IMPLEMENTATION
END_FUNCTION_BLOCK
Save this as Vec.zqel. Now a parent block can wire in Vec<3> and Vec<4> as
if they were ordinary sub-blocks:
FUNCTION_BLOCK TwoVecs
VAR_INPUT
p3 : ARRAY[0..2] OF LREAL; q3 : ARRAY[0..2] OF LREAL;
p4 : ARRAY[0..3] OF LREAL; q4 : ARRAY[0..3] OF LREAL;
END_VAR
VAR_OUTPUT
d3 : LREAL;
d4 : LREAL;
END_VAR
VAR
v3 : Vec<3>;
v4 : Vec<4>;
END_VAR
WIRING
v3(a := p3, b := q3);
v4(a := p4, b := q4);
d3 := v3.dot;
d4 := v4.dot;
END_WIRING
END_FUNCTION_BLOCK
The compiler monomorphizes each instantiation: it substitutes N = 3 into
the full AST of Vec, resolves ARRAY[0..N-1] to ARRAY[0..2] and
SUM(k IN 0..N-1 : …) to SUM(k IN 0..2 : …), names the result Vec<3>, and
then compiles it as an ordinary concrete FB. Vec<3> and Vec<4> are
independent concrete types: separate proofs, separate generated arrays,
separate hidden locals after flattening.
Nothing about this sidesteps the rules you already know. The Bounded-Cycle Theorem still holds: every bound is a compile-time integer literal after substitution, so the compiler knows the size of every array and the range of every aggregate before it starts verifying anything.
Two type parameters work the same way. A 2×3 matrix-vector multiply:
FUNCTION_BLOCK MatVec<M: UINT, K: UINT>
VAR_INPUT
a : ARRAY[0..M*K-1] OF LREAL; (* row-major; a[i*K+k] is row i, col k *)
x : ARRAY[0..K-1] OF LREAL;
END_VAR
VAR_OUTPUT
y : ARRAY[0..M-1] OF LREAL;
END_VAR
IMPLEMENTATION
y := ARRAY(i IN 0..M-1 : SUM(k IN 0..K-1 : a[i*K+k] * x[k]));
END_IMPLEMENTATION
END_FUNCTION_BLOCK
VAR
mv : MatVec<2, 3>; (* M=2 rows, K=3 columns *)
END_VAR
WIRING
mv(a := A, x := x);
y := mv.y;
END_WIRING
For more detail — multiple parameters, wiring patterns, pitfalls — see the How-To: Use Template Blocks and the Concept: Template Blocks.
12. Physical units — catching “apples plus oranges” at compile time¶
A famous class of bugs is mixing incompatible physical quantities (adding a
pressure to a temperature, or forgetting to multiply a flow by a time). zqel can
catch these at compile time if you annotate your MATHREALs with units:
VAR_INPUT
flow : MATHREAL<m3/h>; // volumetric flow, cubic metres per hour
cap : MATHREAL<m3>; // tank capacity, cubic metres
END_VAR
VAR_OUTPUT
volume : MATHREAL<m3>; // accumulated volume, cubic metres
END_VAR
IMPLEMENTATION
volume:
SET CLAMP(volume + flow * DT, 0.0, cap) OTHERWISE;
...
(Full example: examples/analog/flow_totalizer.zqel.)
The compiler works out the dimension of every expression and checks
consistency:
+,−and comparisons require compatible dimensions (a bare number adapts to either side).*and/combine dimensions (multiply/divide the exponents).DThas the dimension of time.
The key insight it proves here: flow (m³/h) times DT (s) has dimension m³ —
a volume — because the two time dimensions cancel. So it may be added to volume.
Try to add a raw flow to a volume and you get:
[dimension-mismatch] cannot apply '+' to incompatible dimensions m^3 and m^3/s
Units are a compile-time contract only: they are checked and then erased, so the generated ST/Rust is byte-for-byte identical whether or not you annotated them. They cost nothing at runtime and catch a whole category of engineering mistakes before the code ever runs.