Language reference: evaluation flow, functions, and function blocks
This is the reference for what runs inside a nautilus controller: how a scan evaluates your logic in each IEC 61131-3 language, and what every built-in operator, function, and function block does — its arguments, its result type, and its behavior. The compiler enforces everything described here; when logic doesn’t type-check (say, a numeric function used where a BOOL must flow), the result is a compile diagnostic on the offending line/rung, never a silent coercion.
- How a scan evaluates
- Ladder power flow
- Identifiers are case-insensitive
- Types — enumerations, constants, VAR_TEMP
- Structure: REGION
- CASE labels
- SCL’s
#prefix - Operators
- Standard functions
- Type conversions
- Standard function blocks
- EN/ENO: execution control
- User function blocks
- Function blocks in ladder
How a scan evaluates
Section titled “How a scan evaluates”Every task runs the classic PLC cycle on its interval:
read inputs → evaluate the program top to bottom → write outputsAll four languages end up as the same intermediate form, one hop at a time:
.st ─────────────────────────► IR (compiled, type-checked) .fbd ── transpile ──► ST ──────► IR .ld ── transpile ──► FBD ──► ST ─► IR .sfc ── transpile ──► ST ──────► IRBecause each hop preserves line maps, a diagnostic anywhere in that chain
lands back on your source — an .ld error points at the rung, an
.fbd error at the statement. The text file is always the source of
truth; the graphical editors are projections that edit it structurally.
Statements evaluate top to bottom within a scan, so a value written by one rung/statement is visible to the ones below it in the same scan, and to everything on the next scan (that’s what makes the seal-in idiom work).
FBD networks
Section titled “FBD networks”An .fbd body can be divided into numbered networks, the way TIA
Portal and CODESYS draw an FBD block: a NETWORK line starts one, with an
optional title, and it runs to the next NETWORK line.
FBD NETWORK 'FT-101 raw to L/min' ft = ScaleAnalog(FT101_Raw, 0.0, 120.0) FT101_Flow := ft NETWORK 'high-flow alarm' // a comment after the NETWORK line is the network's note hiFlow = GT(FT101_Flow, MaxFlowLpm) tHi : TON(IN := hiFlow, PT := T#2S) HighFlowAlm := tHi.QEND_FBD- Networks are numbered by position (1, 2, …): the number is what the diagram draws and what you call it (“network 2”); the text carries only the order, so inserting or moving a network renumbers nothing in the file.
- Execution order is network order. Network 1 runs completely, then network 2, and so on. Within a network the FBD rule holds: an FB call runs before the statements that read its outputs, otherwise source order. A read of a later network’s FB output sees the value from the previous scan, as on any PLC.
- Statements before the first
NETWORKline form an untitled first network. A body with noNETWORKline is one network — exactly the program it always was. NETWORKstands alone on its line; the title is a'…'string (no quotes inside it). A tag that happens to be calledNetworkstill works as one (Network := xis a coil).
The diagram draws each network as a numbered band with its title, its
notes and its logic, and a small badge on every statement (FB calls and
coils) with its execution order within the network. A read that crosses a
network boundary (an FB output or a wire from another network) draws as a
variable box in the reading network, never as a line across bands. The
band header is the network’s handle: click it and the palette’s + add
inserts into that network; ▲ / ▼ move it, + adds a network after it, ✕
removes its NETWORK line (its statements join the network above;
nothing else is deleted), and a double-click on the title renames it. The
palette’s network adds one at the end (or after the picked network).
Ladder power flow
Section titled “Ladder power flow”A rung is a boolean expression that reads left to right:
| Element | Text | Meaning |
|---|---|---|
| NO contact | Tag | passes power when Tag is TRUE |
| NC contact | /Tag | passes power when Tag is FALSE |
| Rising-edge contact | +Tag | one scan TRUE on Tag’s 0→1 transition (an implicit R_TRIG) |
| Falling-edge contact | -Tag | one scan TRUE on Tag’s 1→0 transition (an implicit F_TRIG) |
| Parallel branch | [ a | b ] | OR of its legs; legs are series (AND) and nest freely |
| Function contact | FN(args) | passes power when the call yields TRUE — the function must return BOOL |
| Negated function contact | /FN(args) | passes power when the call yields FALSE — / negates any BOOL-yielding contact term: a plain ref, an accessor chain (/t1.Q), or a call (/GT(a, b)) |
| Function block | inst:TYPE(args) | power drives the block’s power-in pin; power continues from its power-out pin (table below) |
| Coil | ( Tag ) | Tag := the rung condition, every scan |
| Set coil | ( S Tag ) | latch: Tag := Tag OR condition |
| Reset coil | ( R Tag ) | unlatch: Tag := Tag AND NOT condition |
| Rising-edge coil | ( P Tag ) | Tag := TRUE for one scan when the rung condition rises (an implicit R_TRIG) |
| Falling-edge coil | ( N Tag ) | Tag := TRUE for one scan when the rung condition falls (an implicit F_TRIG) |
| Assignment | { Tag := expr } | made when the rung has power at that point; power passes through unchanged. Several with ;: { Count := Count + 1; Last := Now } |
Series elements AND together; a rung with only a coil is driven by the
rail (TRUE). Multiple coils on one rung share the same condition. A
rung’s only output may be a function block instance — no trailing coil is
required — its own output (inst.Q, inst.DN, …) is read elsewhere, the
same way an inst.Q fed to a coil would be: A B t1:TON(PT := T#5S) is a
complete, legal rung.
Edge instances (+Tag, -Tag, and the P/N coils) are unnamed in the
text — the compiler derives a stable instance name from the rung name,
the reference, and the occurrence’s position among repeats of the same
(rung, ref, edge kind) within that rung (contacts key on the watched tag,
P/N coils key on the coil’s own tag), so the name — and the R_TRIG’s
retained state — survives an unrelated edit elsewhere in the program.
Two edge contacts on the same tag in one rung are independent instances.
A rung’s trailing output zone (coils, or a rung-final function block) must
be contiguous at the rung’s right end — coils ahead of a later function
block in the same rung aren’t supported (coils must sit at the rung's right end). Split such a rung into one nautilus rung per output leg
instead.
The BOOL rule. Power is boolean, so anything that gates it must yield
BOOL. Comparisons do: GT(TempC, 90.0) is a fine contact. Numeric
functions don’t: ADD(TempC, 0.0) as a contact is a compile error
(operator AND on BOOL and REAL, or cannot assign REAL to BOOL when
it’s alone on the rung) — ADD does not “pass through” its input. Numeric
functions belong inside a comparison’s arguments — GE(ADD(Base, Bias), Limit) — or in an assignment: Run { Hours := Hours + ScanH } adds
while Run is true and leaves power as it found it, which is the IEC
function box with its EN wired to the rung. The value is any IEC
expression (operators, functions, members, indexes); it is compiled as a
SEL on the rung condition, so it is evaluated every scan like any block
— an array index in it must stay valid while the rung is false. On a
Logix target an assignment becomes the matching instruction (MOVE, ADD,
SUB, MUL, DIV, ABS) or, for anything else, a CPT; see
docs/design/logix-authoring.md.
Reading FB outputs: any instance output is addressable as inst.Pin
anywhere in the program that declares the instance — GE(t2.ET, T#2S) as
a contact, t2.Q as an operand, in any rung. To capture an output into a
variable at the call site, use the standard’s output binding:
t2:TON(PT := T#5S, ET => Elapsed) — the one way ladder stores a
non-BOOL (coils only assign BOOL). => works in ST and FBD calls too.
An instance is a local of its program, so another task’s program cannot
name it: tasks share only tags. t2.Q there — or Owner.t2.Q through
the program’s name, or through its task’s — is an undeclared identifier,
and the error names the program that owns t2 and says to go through a
tag. To use an output in another task, publish it to a tag in the owning
program (a coil, t2.Q ( T2Done ), or an assignment, { T2Elapsed := t2.ET }) and read the tag. The reader sees the value as of the owner’s
last committed scan: a reader that scans after the owner sees a change at
once, one that scans before it sees the change on its own next scan. (In
naut test, tasks due at the same instant scan main first, then in
tasks: order.)
SFC action qualifiers and step supervision
Section titled “SFC action qualifiers and step supervision”Every IEC 61131-3 action qualifier compiles. An association is written
Q Target; / Q Target(T#3S);, or in the standard’s own textual form
Target(Q); / Target(Q, T#3S);. The duration of a timed qualifier is a
TIME literal or a TIME variable.
| Qualifier | Active |
|---|---|
N | while the step is active |
S / R | set / reset once, on the step’s activation scan |
P, P1 / P0 | one scan, on the step’s activation / deactivation |
L | from activation, for the duration (or until the step is left) |
D | once the step has been active the duration, until it is left |
SD | the duration after activation, even if the step was left; until an R |
DS | once the step has been active the duration; then until an R |
SL | from activation for the duration, even after the step is left; an R cuts it short |
An R acts on its step’s activation scan and clears the S, SD, DS and
SL latches on its target. A timed SL that has run out stays latched until
an R, as the standard specifies. Every timed association has its own timer,
retained across an online edit.
A step can carry a maximum time:
STEP Fill (MAXTIME := T#60S, ERROR := FillOverrun):. When the step has
been active longer than that, Fill.ERR goes TRUE (legal in conditions and
action bodies), stays TRUE after the step is left, and clears on its next
activation. ERROR := mirrors it into a BOOL variable, a manifest tag an
alarms: definition can watch. Pinned in lang/conformance/sfc-timed-qualifiers
and lang/conformance/sfc-step-maxtime; the full description is on the
SFC page.
Identifiers are case-insensitive
Section titled “Identifiers are case-insensitive”As IEC 61131-3 says (and TIA Portal, Codesys and Studio 5000 do), Level,
LEVEL and level are one name. That holds for every identifier: a
variable, a manifest tag, a PROGRAM, FUNCTION or FUNCTION_BLOCK, a
TYPE, an FB instance and its pins (startedge(clk := Start) for
startEdge : R_TRIG), a struct member, an SFC step or action and its
.X/.T flags, and an FBD wire. Keywords and standard names were already
case-insensitive (limit(, ton, Real).
- Shown as declared. The declaration’s spelling is the name: diagnostics,
hover, completion, the outline, live values,
/api/statekeys, Sparkplug metrics and the L5X tag list all showLevelfor a tag declaredLevel, however a program spells it (an L5X rung keeps the operand as written; Logix folds case too). A tag’s spelling is the manifest’s (or, for a tag no manifest declares, the first program’s to bind it). An FBD block shows the spelling it was given; a standard function its standard name. - One scope, one name. Two declarations that differ only in case in the
same scope — two variables of one POU, two POUs, two
TYPEs, two manifest tags, two pins of one block — are a duplicate-declaration error naming both. The same name in different scopes (a program local and an FB local, a pin and a variable) is fine, as it always was. - The HTTP API accepts any casing for a tag or member path on writes
(
POST /api/tags), forces and acceptance-testgiven:/expect:keys, and answers with the declared spelling. - Rename in the editor (F2) rewrites every spelling of the symbol to the new name; Find All References lists every spelling.
Pinned in lang/conformance/st-case-insensitive (ST, ladder, FBD and SFC).
Project tags are in scope
Section titled “Project tags are in scope”Every tag the manifest declares (nautilus.yaml and its tag-files:) is in
scope in every PROGRAM — ST, ladder, FBD and SFC — without a declaration:
the tag table is the declaration, as a Codesys GVL, a TIA PLC tag table or
Logix controller scope is.
- Its type is the tag’s
type:(any elementary type, a projectTYPE, or anARRAYof either), else the type itsinit:implies (TRUEBOOL, a number REAL,T#5sTIME, other text STRING). A tag with neither cannot be typed; naming it is an error that says to give it atype:. VAR_EXTERNALstays legal, as IEC’s explicit form, and must agree with a statedtype:(an error at the declaration otherwise). Two programs binding one untyped tag with different types is an error naming both.- A local shadows a tag: a program’s own
VAR Levelis its own variable (IEC scoping).naut checkand the editor warn. FUNCTION_BLOCKandFUNCTIONbodies do not see tags implicitly — a block reaches a tag only through its ownVAR_EXTERNAL, so it stays self-contained.- A GVL file — a library
.stholding a file-levelVAR_GLOBALblock — declares globals for every program; one the manifest does not declare is astatetag starting at zero. A program may still name it inVAR_EXTERNAL(same type).
The tag model guide has the
details, type: forms and init: forms (TIME members, arrays).
BOOL, INT, DINT, UINT, UDINT, WORD, REAL, LREAL, TIME,
STRING, plus ARRAY[lo..hi] OF T and user TYPE ... STRUCT.
- Integer kinds share one 64-bit runtime representation;
REAL/LREALare float64. The declared name is kept on the type, so a diagnostic saysDINTorWORDfor a variable declared that way, and partial access knows aDINThas 32 bits. - Arithmetic does not wrap at the declared width. This is deliberate:
an
INTholding 32767 plus 1 is 32768, not -32768 as on a PLC. A silent wrap turns a totaliser or a counter into a wrong number with no fault, and every seam on the tag bus carries the full value anyway. The width is applied where you ask for it: a conversion (TO_INT,DINT_TO_INT) wraps to the target, as on Codesys. A program that relies on a counter rolling over should convert orMODexplicitly. - Signed with unsigned widens to the narrowest signed type that holds
both (
INT + UINTis aDINT), as the common type rules below say, rather than being refused as strict IEC would. No value is lost either way, so the check stays out of the way of ordinary mixed arithmetic. TIMEcounts milliseconds internally. Literals:T#500MS,T#5S,T#2M30S. TIME values compare with the ordinary comparisons.- Mixed numeric arguments promote to REAL; comparing or combining a STRING with a number is an error, not a coercion.
- A
TYPEis a first-class type: a UDT can be a variable, a tag, a struct field, an array element, aFUNCTIONargument or return, and aFUNCTION_BLOCKpin. Structs nest. - Assigning a struct or an array copies it.
b := a; b.F := 1leavesa.Falone, and so does a struct passed to aVAR_INPUTpin. The one pin that writes back to the caller isVAR_IN_OUT, below. - A field or element of a tag assigns directly —
P101.Running := TRUE,Levels[2] := 41.0. The tag store holds the whole aggregate, so the VM reads it, writes the field, and puts it back.
Enumerations
Section titled “Enumerations”An enumerated TYPE (IEC 61131-3, as in Codesys) names a fixed set of
values (#238):
TYPE WashState : (Idle, Fill := 10, Wash := 20, Drain := 30) := Idle;END_TYPE- A member without
:= ntakes the previous member’s value plus one (the first, 0). Names and values are unique within the type.:= Idleafter the list is the type’s initial value; without it, the first member. A Codesys base type after the list ((A, B) DINT) is accepted. WashState#Fillnames a member qualified;Fillalone works where only one enumeration in scope has a member of that name, or where the context says which (an assignment’s target, the other side of a comparison, aCASEselector). Otherwise the compiler asks for the qualified form.- Values of one enumeration compare (
=,<>, and</>by member value) and areCASElabels. Assignment is between the same enumeration only. An enumeration does not mix with an integer and has no arithmetic. - Conversion is explicit:
TO_INT(s)(orTO_DINT,TO_REAL, anyTO_<type>) is the member’s integer, converted;TO_WashState(n)is the member with integern(a number no member has keeps that number and shows unnamed). - A tag may be typed with an enumeration in the manifest (
type: WashState), seeded by name or number (init: Fillorinit: 10). - Live values show the member name.
/api/state, the live stream, the editors’ inline values and acceptance tests (expect: { State: Fill }) show and takeFill. Underneath, a value is its integer, and that is what Sparkplug, Modbus and the retain store carry.
Pinned in lang/conformance/st-enum (ST and SFC).
Constants
Section titled “Constants”VAR CONSTANT declares a named value: reads fold to it at compile time,
and assigning one (or a field, element or bit of one, or using it as a
FOR variable) is a compile error. A constant’s value, an initial value
and an array bound may be a constant expression: literals, other constants
and operators (N2 : INT := N * 2;, ARRAY[1..N] OF REAL).
VAR_GLOBAL CONSTANT in a library file declares project constants
(#176), the Codesys
GVL of constants — visible to every POU in every language, folded the same
way:
(* lib/constants.st *)VAR_GLOBAL CONSTANT tMaxFill : TIME := T#60S; HI_LIMIT : REAL := 80.0;END_VARA constant is not a tag: it has no manifest entry, no live value and no
API path. A POU’s own declaration of the same name shadows it. A
constant’s type is elementary or an enumeration. (A plain VAR_GLOBAL
block, with no CONSTANT, still declares tags.) Pinned in
lang/conformance/st-global-constants (ST, ladder, FBD and SFC).
Temporaries: VAR_TEMP
Section titled “Temporaries: VAR_TEMP”VAR_TEMP is scratch for one execution
(#203): it starts
every scan of a PROGRAM, and every call of a FUNCTION_BLOCK or
FUNCTION, at its declared initial value (or its type’s zero) — never at
what the previous scan or call left, as TIA’s Temp and IEC say. A block
that reads a temp before writing it therefore sees its initial value. An
online edit never carries a temp over. A temp cannot be RETAIN and cannot
be a function-block instance (it would lose its state every call). Keep
anything that must survive in VAR. Pinned in
lang/conformance/st-var-temp (ST, ladder, FBD and SFC).
Structure: REGION
Section titled “Structure: REGION”REGION name … END_REGION groups statements, as TIA SCL does
(#202). It nests,
appears anywhere a statement can, and changes nothing about how the
statements run: it opens no scope and declares nothing. The name is the
rest of the REGION line and may contain spaces. Regions fold in the
editor and show in the Outline under their POU. REGION is not a reserved
word — a variable may still be called Region. A REGION must be closed
inside the statement list it opened in (not across an END_IF). Pinned in
lang/conformance/st-region.
CASE labels
Section titled “CASE labels”A CASE label is a constant: a literal, a named constant (VAR CONSTANT
or a project constant), an enumeration member, or a range of them
(S_FILL..S_DRAIN). Each label ends the clause before it, named or not
(#196: a named
constant label used to be read as a statement of the clause above).
A variable is not a label. Two labels with the same value — two constants,
a constant and a literal, a value inside a range — are a compile error
naming both, since the second could never run. Pinned in
lang/conformance/st-case-constants.
SCL’s # prefix
Section titled “SCL’s # prefix”TIA SCL marks a local #name. nautilus reads IEC Structured Text, where a
local is written bare, and rejects the prefix wherever it appears with
“the # prefix is Siemens SCL syntax; write the name without it”
(#198). It used to
be dropped silently on an assignment target. Accepting it is a possible
future dialect: siemens option.
Operators
Section titled “Operators”These are the FBD/ladder block names that lower to ST operators. “n-ary”
means the block accepts 2+ inputs (the + pin in the FBD editor).
| Name | Arguments | Result | Behavior |
|---|---|---|---|
AND, OR, XOR | n-ary BOOL (or INT for bitwise) | same | logical on BOOL; bitwise on INT, over all 64 bits (-8 AND 6 = 0) |
NOT | 1 BOOL/INT | same | negation on BOOL; bitwise complement on INT (NOT 0 = -1, NOT 12 = -13) |
ADD | n-ary numeric | common type | sum |
SUB | 2 numeric | common type | difference |
MUL | n-ary numeric | common type | product |
DIV | 2 numeric | common type | quotient; integer and REAL ÷0 yield 0 and the scan keeps running. This is a deliberate deviation (the standard calls it an error; Codesys and TIA yield IEEE ±Inf) so a bad divisor cannot propagate Inf/NaN through a control loop — and because every seam on the tag bus (/api/state, the SSE stream, retained state, *_test.yaml) is JSON, which cannot carry Inf or NaN. The fault is not silent: every ÷0 bumps the controller’s divZero counter in the scan stats (/api/state → scan.divZero, controller-wide, counted since start), the way Logix raises S:V and keeps running |
MOD | 2 integers | common type | remainder; ÷0 yields 0 and counts in divZero |
MOVE | 1 any | same | pass-through assignment (FBD wiring aid) |
GT, GE, LT, LE | 2 comparable | BOOL | ordering (numeric or TIME) |
EQ, NE | 2 comparable | BOOL | equality |
The common type of integers (for ADD, SUB, MUL, DIV, MOD and
the ST operators + - * / MOD) is the narrowest type both arguments widen
to without loss, the standard’s implicit widening:
- the same type gives that type:
DINT + DINTis aDINT; - a literal takes the other argument’s type:
d + 1is aDINT(two literals give the generic integer); - two signed, two unsigned or two bit-string types give the wider:
INT + DINTis aDINT,USINT + UINTaUINT,BYTE + WORDaWORD; - signed with unsigned gives the narrowest signed type that holds both:
INT + USINTis anINT,INT + UINTaDINT,DINT + UDINTaLINT; aULINTwith a signed type has none, and givesLINT; - a bit string with a number counts as the unsigned type of its width:
WORD + INTis aDINT, asUINT + INTis.
The type is what a diagnostic names (s := d1 + d2 into a STRING says
“cannot assign DINT to STRING”). It does not cut the value: arithmetic
never wraps (see Types), and TO_<type> of an expression reads
every bit, so TO_INT(d1 + d2) with both at 40000 is 14464, the same as
DINT_TO_INT(80000). An integer with a REAL is a REAL.
The comparison row is the ladder-relevant one: those six are the functions that can gate power directly.
Standard functions
Section titled “Standard functions”Stateless, callable from ST, FBD blocks, and (where they return BOOL — or inside arguments) ladder function contacts.
A call is positional — LIMIT(0.0, x, 100.0) — or formal, every input
by its standard name: LIMIT(MN := 0.0, IN := x, MX := 100.0). The names
are the ones the signatures below use (IN for one input, IN1…INn for
the extensible ones, G/IN0/IN1 for SEL, K/IN0… for MUX); one
call is one or the other, never a mix. A formal call is also how a function
takes EN/ENO.
Selection
Section titled “Selection”| Name | Signature | Result | Behavior |
|---|---|---|---|
SEL | SEL(G: BOOL, IN0, IN1) | type of IN0/IN1 | binary selector: G=FALSE → IN0, G=TRUE → IN1 |
MUX | MUX(K: INT, IN0, …, INn) | common type | K picks the K-th input (0-based); an out-of-range K faults the scan — clamp K with LIMIT if it can wander |
MIN, MAX | n-ary numeric | common type | smallest / largest |
LIMIT | LIMIT(MN, IN, MX) | common type | IN clamped into [MN, MX] |
Numeric
Section titled “Numeric”| Name | Signature | Result | Behavior |
|---|---|---|---|
ABS | 1 numeric | same type | absolute value |
SQRT, LN, LOG, EXP | 1 numeric | REAL | root, ln, log₁₀, eˣ |
EXPT | EXPT(base, exp) | REAL | baseᵉˣᵖ |
TRUNC | 1 REAL | INT | toward-zero truncation; TRUNC_DINT and the rest of the typed forms are under Type conversions |
SIN, COS, TAN | 1 numeric (radians) | REAL | trigonometry |
ASIN, ACOS, ATAN | 1 numeric | REAL | inverse trig |
ATAN2 | ATAN2(Y, X) | REAL | quadrant-correct arctangent |
Bit operations
Section titled “Bit operations”| Name | Signature | Result | Behavior |
|---|---|---|---|
SHL, SHR | (IN: INT/WORD, N: INT) | same as IN | shift left / logical shift right (zero-fill) |
ROL, ROR | (IN, N) | same as IN | rotate left / right |
AND, OR, XOR | two integers | integer | bitwise, when both operands are integers (on BOOLs they are the logical operators) |
Bit access. Word.3 is bit 3 of an integer, as a BOOL — readable
anywhere a BOOL is (IF Status.0 THEN, a ladder contact Status.0, a
compare argument) and assignable (Cmd.4 := TRUE, a ladder coil
( Cmd.4 )), which reads the word, sets or clears the bit, and writes
the word back. Bits are numbered from 0 at the least significant end. This
is the spelling Logix uses; IEC 61131-3 ed. 3 writes it Word.%X3, and
both work. A bit of an array element or a structure member works the same
way: Words[2].15, P101.Status.12.
Partial access (IEC, #222)
addresses a wider part the same way: w.%B1 is the second byte (a
BYTE, bits 8..15), w.%W0 the low word, w.%D1 the high double word of
an LWORD, w.%L0 the whole 64 bits. Each reads as an unsigned integer of
that width and writes back only its own bits (Alarms.%B3 := 16#AB). The
part must fit the declared type: a DINT has bits 0..31, bytes 0..3
and words 0..1, so d.%X32 or w.16 on a WORD is a compile error that
names the type. Ladder contacts and coils and FBD wires take every form
(Alarms.%X3). Pinned in lang/conformance/st-partial-access.
Caveat: the runtime’s integers are 64-bit, so rotates and arithmetic
operate over 64 bits — a WORD you think of as 16 bits rotates as a 64-bit
value. Only partial access, diagnostics and the conversions
use the declared width.
Strings
Section titled “Strings”All positions are 1-based (IEC convention); length/position arguments clamp to the string instead of faulting.
| Name | Signature | Result | Behavior |
|---|---|---|---|
LEN | LEN(IN) | INT | length |
LEFT, RIGHT | (IN, L) | STRING | first / last L characters |
MID | MID(IN, L, P) | STRING | L characters starting at position P |
CONCAT | n-ary STRING | STRING | concatenation |
INSERT | INSERT(IN1, IN2, P) | STRING | IN2 inserted into IN1 after position P |
DELETE | DELETE(IN, L, P) | STRING | L characters removed starting at P |
REPLACE | REPLACE(IN1, IN2, L, P) | STRING | L characters at P replaced by IN2 |
FIND | FIND(IN1, IN2) | INT | 1-based position of IN2 in IN1; 0 when absent or IN2 empty |
The scan
Section titled “The scan”| Name | Signature | Result | Behavior |
|---|---|---|---|
FIRST_SCAN | FIRST_SCAN() | BOOL | TRUE for the whole of the program’s first scan after it starts or is downloaded, FALSE from the second scan on. An online edit keeps the program’s state, so it is not a start, and neither is a rollback |
FIRST_SCAN() is how a program seeds state from the world once: a ladder
function contact FIRST_SCAN() { next := Clock.SECOND }, or
IF FIRST_SCAN() THEN … END_IF; in ST. Each task’s program has its own
first scan. On a Logix controller it is the status flag S:FS.
Type conversions
Section titled “Type conversions”Explicit, in the standard’s <A>_TO_<B> naming — there are no implicit
conversions across kinds. The set is generated from one table of the
elementary types (#244,
lang/ir/conversions.go), so the compiler, FBD, completion and signature
help all see the same list.
The matrix. Every <A>_TO_<B> between these 18 types exists, except
the cells marked ·:
| from ↓ / to → | BOOL | SINT…LINT | USINT…ULINT | BYTE, WORD | DWORD | LWORD | REAL | LREAL | TIME, LTIME | STRING |
|---|---|---|---|---|---|---|---|---|---|---|
| BOOL | 0/1 | 0/1 | 0/1 | 0/1 | 0/1 | 0/1 | 0/1 | 0/1 ms | TRUE/FALSE | |
| SINT…LINT | ≠ 0 | wrap | wrap | wrap | wrap | wrap | exact¹ | exact¹ | ms | decimal |
| USINT…ULINT | ≠ 0 | wrap | wrap | wrap | wrap | wrap | exact¹ | exact¹ | ms | decimal |
| BYTE, WORD | ≠ 0 | wrap | wrap | wrap | wrap | wrap | · | · | ms | decimal |
| DWORD | ≠ 0 | wrap | wrap | wrap | wrap | wrap | bits | · | ms | decimal |
| LWORD | ≠ 0 | wrap | wrap | wrap | wrap | wrap | · | bits | ms | decimal |
| REAL | ≠ 0 | round² | round² | · | bits | · | — | same | round² ms | shortest |
| LREAL | ≠ 0 | round² | round² | · | · | bits | same | — | round² ms | shortest |
| TIME, LTIME | ≠ 0 | ms, wrap | ms, wrap | ms, wrap | ms, wrap | ms, wrap | ms | ms | same | T#…ms |
| STRING | parse³ | parse³ | parse³ | parse³ | parse³ | parse³ | parse³ | parse³ | parse³ | — |
That is 294 X_TO_Y functions, plus:
| Form | Example | Meaning |
|---|---|---|
TO_<type>(x) | TO_DINT(r), TO_STRING(t) | the overloaded form: <A>_TO_<type> for whatever x is declared as (an expression’s integer converts as LINT; an enumeration as its integer). The same · cells are compile errors. |
TRUNC_<int>(x) | TRUNC_INT(r) | REAL → that integer type, toward zero |
<real>_TRUNC_<int>(x) | LREAL_TRUNC_DINT(lr) | the standard’s typed truncation; same as TRUNC_<int> |
TRUNC(x) | TRUNC(r) | toward zero, into the 64-bit integer an undeclared INT is |
TO_<Enum>(n) | TO_WashState(20) | the enumeration member with that integer |
The rules.
- Integer narrowing wraps. The result keeps the target’s low bits, read
signed or unsigned as the target is:
DINT_TO_INT(70000)= 4464,INT_TO_USINT(-1)= 255,BYTE_TO_SINT(255)= -1,SINT_TO_WORD(-1)= 16#FFFF. This is what Codesys documents (“the high bytes are lost”) and what TIA writes (TIA also drops ENO); the standard calls the integer↔bit-string ones a binary transfer. Saturating would match none of them. Arithmetic still never wraps (see Types); a conversion is the one place the declared width cuts. - The argument is read as its own type first. A variable keeps a 64-bit
value at run time, so
UINT_TO_DINT(u)withuat -1 sees the UINT a PLC would hold, 65535. Pass aDINTtoINT_TO_REALand it is read as an INT, as Codesys’s implicit conversion would. - ² REAL → integer and REAL → TIME round to nearest, ties to even (IEC
60559: 2.5 → 2, 3.5 → 4, -2.5 → -2), the same as Codesys, Logix and TIA’s
ROUND.TRUNC*go toward zero. A REAL with no value in the target — beyond its range, NaN or infinite — faults the scan, like a STRING that does not parse: there is no right number to store, and TIA reports it as an error too.LREAL_TO_SINT(127.5)faults;LREAL_TO_SINT(-128.5)rounds to the even -128 and does not. - REAL ↔ bit strings are binary transfers of the IEEE 754 pattern, and
only at the same width:
REAL_TO_DWORD(1.0)= 16#3F800000,LWORD_TO_LREAL, as the standard and TIA define them. A pattern that is NaN or infinite faults (the tag bus is JSON, which cannot carry them). The standard has noREAL_TO_WORD, and Codesys’s numeric one would read differently from TIA’s, so it is a compile error that says to writeREAL_TO_UINTfor the number orREAL_TO_DWORDfor the bits. - ¹ Integers to REAL are exact up to 2⁵³: REAL and LREAL are both
float64 at run time (a 32-bit REAL would round
DINT_TO_REAL(16777217)to 16777216; here it stays).REAL_TO_LREALandLREAL_TO_REALchange only the type. ULINT and LWORD convert unsigned. - BOOL converts to 0/1 of any numeric type (1 ms for TIME); any number
converts to TRUE when it is nonzero in the source width
(
BYTE_TO_BOOL(256)is FALSE). - TIME and LTIME count milliseconds (LTIME is not nanoseconds here).
TIME_TO_INT(T#70s)wraps like any integer narrowing; useTIME_TO_DINT. - ³ STRING parses, after trimming blanks: an integer in decimal or
base-prefixed (
16#FF,2#1010,8#17) with_separators, a REAL in ordinary decimal or exponent notation, a TIME as a duration (T#1m30s,90s,500ms), a BOOL asTRUE/FALSE/1/0in any case. Text that does not parse, or parses to a value the target cannot hold (STRING_TO_SINT('128'),STRING_TO_UINT('-1')), faults the scan — validate upstream. Formatting is decimal for integers (unsigned for the U- and bit-string types), the shortest exact form for REAL (21.5,3,1e+21),T#5000ms/LTIME#5000msfor durations, so everySTRING_TO_x(x_TO_STRING(v))givesvback. DATE,TIME_OF_DAYandDATE_AND_TIMEare not run-time types yet, so they have no conversions.
Pinned in lang/conformance/st-conversions-matrix (ST and FBD, every
family’s edges) and fn-conversions; the faults in
lang/conformance/faults_test.go.
Standard function blocks
Section titled “Standard function blocks”Stateful — declare an instance (VAR t1 : TON; END_VAR, or inline in a
rung as t1:TON(...)), and each instance keeps its own state between
scans. Outputs read as inst.Pin from any language, within the program
that declares the instance; another task reads one through a tag (see
Ladder power flow).
| Type | Inputs | Outputs | Behavior |
|---|---|---|---|
TON | IN: BOOL, PT: TIME | Q: BOOL, ET: TIME | on-delay: Q rises after IN has been TRUE for PT; ET is elapsed |
TOF | IN, PT | Q, ET | off-delay: Q stays TRUE for PT after IN drops |
TP | IN, PT | Q, ET | pulse: rising IN produces a PT-wide TRUE pulse; a rising edge during the pulse is ignored; afterwards ET holds at PT while IN stays TRUE and returns to 0 the scan IN is FALSE |
CTU | CU: BOOL, R: BOOL, PV: INT | Q: BOOL, CV: INT | count rising CU edges; Q when CV ≥ PV; R resets |
CTD | CD: BOOL, LD: BOOL, PV: INT | Q, CV | count down from PV (LD loads); Q when CV ≤ 0 |
CTUD | CU, CD, R, LD, PV | QU, QD, CV | up/down counter |
R_TRIG | CLK: BOOL | Q: BOOL | Q for exactly one scan on CLK’s rising edge |
F_TRIG | CLK | Q | one-scan pulse on the falling edge |
SR | S1: BOOL, R: BOOL | Q1: BOOL | set-dominant latch |
RS | S: BOOL, R1: BOOL | Q1: BOOL | reset-dominant latch |
PID | see below | see below | closed-loop control — proportional/integral/derivative with anti-windup and bumpless auto/manual |
LOCAL_TIME | — | YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MILLISECOND: DINT | the calendar, now: each call reads the runtime’s clock once, so the seven outputs are one instant. The names are IEC 61131-3’s SPLIT_DT outputs. In production the clock is the machine’s local time; under naut test it is the test’s virtual clock, read in UTC (it starts at 2000-01-01 00:00:00), so a test that reads the calendar gives the same answer on every machine |
EN/ENO: execution control
Section titled “EN/ENO: execution control”Every function and function-block call may bind EN (a BOOL input, TRUE when unbound) and read ENO (a BOOL output), as IEC 61131-3 §6.6.1.2.4 defines them and every FBD editor draws them:
- EN FALSE: the call does not execute. A function’s result is not
assigned: the variable it drives keeps its value. A function block’s
body does not run: its outputs, every
=>binding and its internal state (a counter’s edge memory, a timer’s start) keep their values until it runs again. - ENO = EN AND no error. A call that errors faults the scan here (a
MUXindex out of range, an unparseableSTRING_TO_*), so a call that returns has ENO = EN — except FBD’sDIVandMOD, whose ENO is FALSE on a zero divisor (the result itself follows the ÷0 rule: 0, and thedivZerocounter).
In ST, EN and ENO are formal arguments:
t1(EN := Enable, IN := Start, PT := T#5S, ENO => t1Ok);Out := LIMIT(EN := Enable, MN := 0.0, IN := Raw, MX := 100.0, ENO => ok);A function’s EN/ENO needs its call to be a statement of its own — the whole
right-hand side of an assignment (or a user FUNCTION called as a
statement) — because “not assigned” has to name what is not assigned;
inside a larger expression it is a compile error that says so.
In FBD, any block takes them the same way — sp = LIMIT(EN := Enable, MN := 0.0, IN := Raw, MX := 100.0, ENO => ok) — and a block with EN/ENO must drive
a variable (a coil, directly or through its named wire), never another
block’s input. ENO also reads as a wire: t1.ENO, or sp.ENO for a named
block, feeds a coil or the next block’s EN (TIA’s EN/ENO chain). The diagram
draws EN first and ENO last, but only when bound; the EN toggle on a
block (or FB) shows both as open pins to drop a wire on or drag one from.
A user block that declares its own EN input or ENO output (the
ladder convention, below) keeps them as ordinary
pins: its body decides what they mean. lang/conformance/fbd-en-eno runs
the same EN/ENO logic in ST and FBD and asserts them together.
Power pins in ladder
Section titled “Power pins in ladder”When a block sits in a rung, rung power drives one input and continues from one output; every other pin is passed (or read) by name in the parentheses:
| Type | Power in | Power out |
|---|---|---|
TON, TOF, TP | IN | Q |
CTU | CU | Q |
CTD | CD | Q |
R_TRIG, F_TRIG | CLK | Q |
SR | S1 | Q1 |
RS | S | Q1 |
LOCAL_TIME | none: it sits on the rail | none: power passes through |
| user FUNCTION_BLOCK | EN, else the first BOOL VAR_INPUT the call doesn’t bind by name | ENO, else the first BOOL VAR_OUTPUT |
Passing the power pin explicitly in the argument list (t1:TON(IN := x))
is an error — power owns it.
A user block’s pins are resolved from the whole compile — this file’s
own FUNCTION_BLOCKs plus every project library — so the rung lands power
where the block actually declares it. Two shapes have no pin to use, and
both are meaningful:
- No power-in — every BOOL input is bound by name, or the block has
none. The block is still called, unconditionally, so it may only sit on
a rung whose condition is the rail itself. A rung with contacts ahead of
it is a compile error (
no free BOOL input for the rung's power): say which pin the gate lands on, or give the block anEN. - No power-out — the block has no BOOL output. Power passes through unchanged, so whatever conditioned the block still conditions the coils to its right.
A block whose type nothing in the compile declares falls back to IN/Q.
PID: closed-loop control
Section titled “PID: closed-loop control”PID is a positional (non-velocity) three-term controller in the IEC/OSCAT
spirit — no ladder power pin (like CTUD, it has no single input that
means “run”), so instantiate it from ST or an FBD diagram, the way
examples/lift-station’s level.fbd wires a
direct-acting level loop (lic : PID(AUTO := LeadReq, …, DIRECT := TRUE, …))
and examples/batch-skid’s dosing.fbd wires a
reverse-acting one (tic : PID(AUTO := HeatingActive, …, DIRECT := FALSE, …), the “heater” case below). In the FBD editor, + add → function block →
PID places pid1 : PID(AUTO := _, PV := _, …) with every input an open pin
to drag a tag onto; unwire (select the wire, Del) the ones you leave at
their defaults.
| Pin | Kind | Type | Meaning |
|---|---|---|---|
AUTO | input | BOOL | TRUE = closed loop; FALSE = manual — CV tracks CV_MAN and the integral free-wheels for a bumpless return to AUTO |
PV | input | REAL | process value |
SP | input | REAL | setpoint |
KP | input | REAL | proportional gain. KP = 0 disables the whole controller, not just the P term — see below |
KI | input | REAL | integral gain, repeats/second (1/TI); 0 disables integral action |
KD | input | REAL | derivative gain, seconds (TD); 0 disables derivative action |
CV_MAN | input | REAL | manual output, used when AUTO = FALSE |
CV_MIN | input | REAL | output clamp floor. Default 0 (see below) |
CV_MAX | input | REAL | output clamp ceiling. Default 100 (see below) |
DIRECT | input | BOOL | FALSE = reverse acting, error = SP − PV (e.g. a heater — raise CV when PV is low); TRUE = direct acting, error = PV − SP (e.g. a cooling valve) |
DT | input | REAL | seconds since the last call. Left at 0 (unbound), the block measures elapsed time itself from the scan clock — bind a task’s dt-tag when one is available, same as any hand-written loop |
DB | input | REAL | deadband on error — within ±DB the P and I terms see zero error (chatter suppression); the D term still sees every PV move |
RESET | input | BOOL | TRUE zeroes the integral this scan |
CV | output | REAL | controller output, clamped to [CV_MIN, CV_MAX] |
ERR | output | REAL | error, before the deadband |
SAT_HI, SAT_LO | output | BOOL | TRUE when CV is clamped at its ceiling/floor |
P_TERM, I_TERM, D_TERM | output | REAL | the three contributions to CV, for trending/diagnostics |
Algorithm. ISA standard form: CV = KP·(error + KI·∫error·dt + KD·d(PV)/dt).
The derivative acts on PV, not on error (so a setpoint step never
“kicks” D_TERM — only a PV change does), through a fixed first-order
filter (time constant KD/10) that keeps sensor noise from being
amplified into a noisy CV. Anti-windup is conditional integration:
the integral only accumulates when doing so wouldn’t push the unclamped
output further past a rail it has already reached — cheap, and unlike
back-calculation it needs no extra tracking-gain to tune. AUTO/MANUAL
is bumpless both ways: in MANUAL, the integral is continuously
back-solved every scan so P_TERM + I_TERM + D_TERM already equals
CV_MAN, so the instant AUTO goes TRUE, CV continues from exactly
where CV_MAN left off instead of jumping. Leaving CV_MIN/CV_MAX both
unbound (they default to 0) falls back to the IEC 0..100 range, since
an explicit 0..0 clamp would otherwise pin CV at zero.
(* LIC-101: tank level, reverse acting — open the inlet valve more as level falls below setpoint, close it as level rises to setpoint. *)VAR lic : PID; END_VARlic(AUTO := TRUE, PV := LevelPct, SP := LevelSP, KP := 1.5, KI := 0.05, KD := 0.0, CV_MAN := 0.0, CV_MIN := 0.0, CV_MAX := 100.0, DIRECT := FALSE, DB := 0.5, DT := ScanDtS);InletValve := lic.CV;IF lic.SAT_HI THEN InletMaxedAlm := TRUE; END_IF;Arrays of instances
Section titled “Arrays of instances”A function-block instance can be declared in an array — Timers : ARRAY [0..3] OF TON; — and each element is its own instance with its own
retained state. Call an element by index and read its outputs by index:
Timers[2](IN := Run, PT := T#5S); and Timers[2].Q in ST, or
Run Timers[2]:TON(PT := T#5S) in a rung. The index may be a variable.
On a Logix target the array is one TIMER[4] tag; an element called
with a literal index carries its preset in the tag’s data, one called
with a computed index takes a MOVE to its .PRE ahead of the rung.
A MOVE to .PRE stays on the controller, and a computed index can
land on any element, so once an array is called with a computed index
anywhere, its literal-index calls also re-assert their presets with a
MOVE ahead of their rungs.
Dialects
Section titled “Dialects”A project runs on the nautilus runtime by default, and that is the only
semantics the standard library has. A project that targets a vendor’s
controller can opt into that vendor’s idioms with dialect: in
nautilus.yaml:
dialect: logixA dialect is a library of blocks with the vendor’s semantics, written in
nautilus and compiled into every program of the project like a lib/
file — so the nautilus runtime runs them, the vendor writer emits the
native instruction for them, and the vendor import folds the native idiom
back into them. One definition, three uses. The names are nautilus (the
default, adds nothing), logix, and the reserved siemens and codesys.
The logix dialect today:
| Block | Pins | Semantics | On a Logix controller |
|---|---|---|---|
TONR | IN, PT, Reset → Q, ET | a TON whose Reset clears the accumulated time and Q while TRUE; the free-running pulse is t:TONR(PT := T#1S, Reset := t.Q) | in ladder, a TON with a RES(t) rung ahead of the timer’s rung; in ST, an FBD_TIMER run by TONR(t);, its .Reset TRUE while Reset is TRUE or IN is FALSE |
A vendor idiom with a standard equivalent is not a dialect block. The
first-scan flag and the controller’s calendar are FIRST_SCAN() and
LOCAL_TIME, standard in every project; the Logix writer spells them
S:FS and GSV(WallClockTime,,LocalDateTime,…).
Tag aliases. A manifest tag may carry alias:, the vendor-side
binding of the tag — on Logix the alias tag’s target, which is how a Logix
program names a rack point or another tag:
tags: - { name: StartPB, role: input, alias: "Local:1:I.Data.3" }The program names StartPB; the Logix writer emits it as an alias tag;
the L5X importer turns an export’s alias tags, and rack points the logic
named directly, into tags with alias:, and a rung that names an alias
tag imports with the tag typed through its target. The nautilus runtime
ignores the binding — the tag is a tag — so a simulation runs the same
source. It is the one place a hardware address lives in a project.
User function blocks
Section titled “User function blocks”User FUNCTIONs and FUNCTION_BLOCKs written in library files
participate everywhere the built-ins do; see “Structuring logic” in the
main README. Two things about their pins are worth stating outright,
because they decide how a block’s signature is shaped.
A pin may be any type, including a user TYPE
Section titled “A pin may be any type, including a user TYPE”VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT and VAR
declarations inside a FUNCTION_BLOCK (and a FUNCTION’s inputs, locals,
and return type) resolve against the whole compile: this file’s TYPE
block plus every project library joined ahead of it. So a block can take
the UDT its site model already defines, nested structs and all:
FUNCTION_BLOCK FB_ScaleVAR_INPUT IN : AnalogInput; END_VAR (* a user TYPE, nested structs fine *)VAR_OUTPUT OUT : AnalogInput; END_VAROUT := IN;OUT.VALUE := IN.SCALE.LO + (IN.SCALE.HI - IN.SCALE.LO) * INT_TO_REAL(IN.RAW) / 32767.0;END_FUNCTION_BLOCKAt the call site the struct output reads like any other pin, one field at a
time (s.OUT.VALUE) or whole (Scaled := s.OUT). A pin naming a type
nothing declares is still a compile error that names the type.
An input left unbound keeps its value
Section titled “An input left unbound keeps its value”A call may leave any VAR_INPUT out: it keeps its value — the declared
initial value (NoFlowTime : TIME := T#5S) until something writes it,
exactly an unconnected FB input in TIA or CODESYS. A VAR_IN_OUT must be
bound at every call. The FBD palette’s function block picker follows
this: an input with a declared initial value arrives unbound (it
already has its value), an input without one arrives as an open _ pin
(the compiler flags it until something is wired, since nobody decided
what it reads), and so does every VAR_IN_OUT. EN is never written by the
picker: unbound, it is TRUE.
VAR_IN_OUT is a reference pin
Section titled “VAR_IN_OUT is a reference pin”A VAR_IN_OUT pin is bound at the call site to a variable, and what
the block writes into it is visible to the caller when the call returns.
That is what collapses a block whose UDT already names its own inputs and
outputs from thirty scalar pins to one:
FUNCTION_BLOCK FB_StarterVAR_IN_OUT M : Motor; END_VARVAR edge : R_TRIG; END_VARedge(CLK := M.Cmd);IF edge.Q THEN M.Starts := M.Starts + 1; END_IF;M.Running := M.Cmd;END_FUNCTION_BLOCKVAR_EXTERNAL P101 : Motor; END_VARVAR s : FB_Starter; END_VARs(M := P101); (* P101 carries the block's writes afterwards *)The rules, all of them enforced at compile time:
| Rule | Why |
|---|---|
Bound with :=, like an input — s(M := P101) | it is an input; the => form binds outputs, and an IN_OUT already writes back |
The argument must be assignable: a variable, a struct field, an array element, or a VAR_EXTERNAL tag | the block writes back to it; an expression has nowhere to write |
| The argument’s type must match the pin exactly | a reference cannot convert, so an INT variable does not stand in for a REAL pin |
Every VAR_IN_OUT must be bound at every call site | there is no default for a reference |
| The pin’s own type may not be a function block | an instance is retained state, not a value; nothing in the language copies one |
Mechanically the pin is copied in before the block’s body runs and copied
back to the same variable after it — the observable behaviour of “by
reference” for scan code, and what makes a VAR_IN_OUT bound to a
VAR_EXTERNAL UDT round-trip through the tag store as one whole-struct
write. Two calls in one scan bound to different variables each see and
update their own.
FUNCTIONs have no VAR_IN_OUT (or VAR_OUTPUT): an IEC function is a
single return value, and the compiler says so.
VAR_IN_OUT pins are ST/FBD-callable; the FBD and ladder editors expose a
block’s VAR_INPUT/VAR_OUTPUT pins only, so a block meant to be wired
graphically should keep its interface on those.
Function blocks in ladder
Section titled “Function blocks in ladder”A .ld file may define FUNCTION_BLOCKs as well as (or instead of) a
PROGRAM. Each one is an ordinary IEC POU whose body happens to be rungs:
FUNCTION_BLOCK PumpSeqVAR_INPUT Start : BOOL; Stop : BOOL; Level : REAL; StopLevel : REAL; END_VARVAR_OUTPUT Run : BOOL; Warm : BOOL; END_VARVAR t1 : TON; END_VARLD RUNG seal [ Start | Run ] /Stop /GE(Level, StopLevel) ( Run ) RUNG warm Run t1:TON(PT := T#5S) ( Warm )END_LDEND_FUNCTION_BLOCKThis is what ladder has instead of a JSR: a subroutine with a real
interface — pins, not shared tags — and its own retained state per
instance. Two pumps are two instances of one block, each with its own
seal-in and its own t1.
The VAR_* sections are ordinary POU declarations, VAR_IN_OUT included,
so a ladder block can take a UDT by reference the same way an ST one does
(see VAR_IN_OUT is a reference pin). The
LD body lowers through the usual single hop — LD → FBD netlist → ST — so
by the time the compiler sees it, it is an ordinary
FUNCTION_BLOCK … END_FUNCTION_BLOCK. There is no special case anywhere
downstream.
Calling one
Section titled “Calling one”From ladder, with the rung’s power on the block’s power-in pin and =>
capturing outputs (the same inst:TYPE(args) syntax the standard blocks
use — see Power pins in ladder for where power
lands on a user block):
RUNG lead P101Start p101:PumpSeq(Stop := P101Stop, Level := LevelPct, StopLevel := StopLevel, Run => P101Run, Warm => P101Warm)From ST or FBD, like any other block:
VAR p : PumpSeq; END_VARp(Start := Cmd, Stop := Halt, Level := LevelPct, StopLevel := 80.0);PumpRun := p.Run;An FB instance a rung declares inline (t1:TON(...)) needs no separate
VAR entry — but writing one, as the block above does, is fine and often
clearer: a declaration in the POU’s own header wins, and the rung is then
a call on it rather than a second declaration.
A .ld library
Section titled “A .ld library”A .ld file with no PROGRAM is a project library, exactly like a
PROGRAM-less .st file. Its blocks join the prelude ahead of every task,
so any program in the project — in any language — can instantiate them.
.fbd libraries work the same way. A library may sit in the project root or
anywhere under lib/ (e.g. lib/motor.ld); lib/ holds libraries only, so
a PROGRAM there is an error, and no other subdirectory composes.
Composition order. Every .st library first, then every transpiled
.ld / .fbd library, each group sorted by project-relative path (root
files and lib/ files interleaved: lib/motor.ld sorts before pump.ld). ST leads
because that is where a project’s TYPE declarations live and a graphical
block’s pin may name a UDT. Order never decides whether a call resolves:
the ST front-end registers every FUNCTION_BLOCK signature in the composed
source before it lowers any body, so blocks may reference each other in
either direction, across files. What order decides is only which
declaration a duplicate-name collision reports.
Two names, one error: declaring the same FUNCTION_BLOCK twice in one
.ld file is refused with the second declaration’s line, before anything
is transpiled.
examples/lift-station’s lib/motor.ld (a MotorStarter block,
instantiated twice from permissives.ld) is the whole feature.
What the editors do
Section titled “What the editors do”The ladder view renders a file’s blocks as rung groups, each under its own
FUNCTION_BLOCK heading with its pins, and every rung in them edits like
any other. The palette’s FB… picker lists every block a rung can call —
the standard ones, then the file’s and the project libraries’ — and inserts
one with the rung’s power already on its power pin, _ placeholders on its
non-BOOL inputs and in-outs, and an instance name you can edit; double-click
a block’s header to rename the instance (its declaration and every
reference in the POU follow). The FBD palette’s function block picker
lists the same catalog (naut fbd graph sends it too) and inserts
inst : TYPE(pin := _, …), every required input an open pin (an input with
a declared initial value stays unbound — above);
its block → wire function field offers the project’s own FUNCTIONs, by
their declared names, beside the standard functions; its output reference
reads an instance output (Speed := m1.Run). Two limits worth knowing: an edit op addresses a rung by
name, so two rungs with the same name in different POUs of one file
resolve to the first — name them distinctly; and addRung with no after
appends before the file’s first END_LD. The language server analyses
a multi-POU .ld fully, diagnostics landing on the offending rung.