Skip to content

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.

Every task runs the classic PLC cycle on its interval:

read inputs → evaluate the program top to bottom → write outputs

All 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 ──────► IR

Because 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).

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.Q
END_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 NETWORK line form an untitled first network. A body with no NETWORK line is one network — exactly the program it always was.
  • NETWORK stands alone on its line; the title is a '…' string (no quotes inside it). A tag that happens to be called Network still works as one (Network := x is 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).

A rung is a boolean expression that reads left to right:

ElementTextMeaning
NO contactTagpasses power when Tag is TRUE
NC contact/Tagpasses power when Tag is FALSE
Rising-edge contact+Tagone scan TRUE on Tag’s 0→1 transition (an implicit R_TRIG)
Falling-edge contact-Tagone 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 contactFN(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 blockinst: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.

QualifierActive
Nwhile the step is active
S / Rset / reset once, on the step’s activation scan
P, P1 / P0one scan, on the step’s activation / deactivation
Lfrom activation, for the duration (or until the step is left)
Donce the step has been active the duration, until it is left
SDthe duration after activation, even if the step was left; until an R
DSonce the step has been active the duration; then until an R
SLfrom 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.

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/state keys, Sparkplug metrics and the L5X tag list all show Level for a tag declared Level, 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-test given:/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).

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 project TYPE, or an ARRAY of either), else the type its init: implies (TRUE BOOL, a number REAL, T#5s TIME, other text STRING). A tag with neither cannot be typed; naming it is an error that says to give it a type:.
  • VAR_EXTERNAL stays legal, as IEC’s explicit form, and must agree with a stated type: (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 Level is its own variable (IEC scoping). naut check and the editor warn.
  • FUNCTION_BLOCK and FUNCTION bodies do not see tags implicitly — a block reaches a tag only through its own VAR_EXTERNAL, so it stays self-contained.
  • A GVL file — a library .st holding a file-level VAR_GLOBAL block — declares globals for every program; one the manifest does not declare is a state tag starting at zero. A program may still name it in VAR_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/LREAL are float64. The declared name is kept on the type, so a diagnostic says DINT or WORD for a variable declared that way, and partial access knows a DINT has 32 bits.
  • Arithmetic does not wrap at the declared width. This is deliberate: an INT holding 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 or MOD explicitly.
  • Signed with unsigned widens to the narrowest signed type that holds both (INT + UINT is a DINT), 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.
  • TIME counts 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 TYPE is a first-class type: a UDT can be a variable, a tag, a struct field, an array element, a FUNCTION argument or return, and a FUNCTION_BLOCK pin. Structs nest.
  • Assigning a struct or an array copies it. b := a; b.F := 1 leaves a.F alone, and so does a struct passed to a VAR_INPUT pin. The one pin that writes back to the caller is VAR_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.

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 := n takes the previous member’s value plus one (the first, 0). Names and values are unique within the type. := Idle after 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#Fill names a member qualified; Fill alone 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, a CASE selector). Otherwise the compiler asks for the qualified form.
  • Values of one enumeration compare (=, <>, and </> by member value) and are CASE labels. 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) (or TO_DINT, TO_REAL, any TO_<type>) is the member’s integer, converted; TO_WashState(n) is the member with integer n (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: Fill or init: 10).
  • Live values show the member name. /api/state, the live stream, the editors’ inline values and acceptance tests (expect: { State: Fill }) show and take Fill. 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).

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_VAR

A 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).

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).

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.

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.

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.

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).

NameArgumentsResultBehavior
AND, OR, XORn-ary BOOL (or INT for bitwise)samelogical on BOOL; bitwise on INT, over all 64 bits (-8 AND 6 = 0)
NOT1 BOOL/INTsamenegation on BOOL; bitwise complement on INT (NOT 0 = -1, NOT 12 = -13)
ADDn-ary numericcommon typesum
SUB2 numericcommon typedifference
MULn-ary numericcommon typeproduct
DIV2 numericcommon typequotient; 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
MOD2 integerscommon typeremainder; ÷0 yields 0 and counts in divZero
MOVE1 anysamepass-through assignment (FBD wiring aid)
GT, GE, LT, LE2 comparableBOOLordering (numeric or TIME)
EQ, NE2 comparableBOOLequality

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 + DINT is a DINT;
  • a literal takes the other argument’s type: d + 1 is a DINT (two literals give the generic integer);
  • two signed, two unsigned or two bit-string types give the wider: INT + DINT is a DINT, USINT + UINT a UINT, BYTE + WORD a WORD;
  • signed with unsigned gives the narrowest signed type that holds both: INT + USINT is an INT, INT + UINT a DINT, DINT + UDINT a LINT; a ULINT with a signed type has none, and gives LINT;
  • a bit string with a number counts as the unsigned type of its width: WORD + INT is a DINT, as UINT + INT is.

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.

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.

NameSignatureResultBehavior
SELSEL(G: BOOL, IN0, IN1)type of IN0/IN1binary selector: G=FALSE → IN0, G=TRUE → IN1
MUXMUX(K: INT, IN0, …, INn)common typeK picks the K-th input (0-based); an out-of-range K faults the scan — clamp K with LIMIT if it can wander
MIN, MAXn-ary numericcommon typesmallest / largest
LIMITLIMIT(MN, IN, MX)common typeIN clamped into [MN, MX]
NameSignatureResultBehavior
ABS1 numericsame typeabsolute value
SQRT, LN, LOG, EXP1 numericREALroot, ln, log₁₀, eˣ
EXPTEXPT(base, exp)REALbaseᵉˣᵖ
TRUNC1 REALINTtoward-zero truncation; TRUNC_DINT and the rest of the typed forms are under Type conversions
SIN, COS, TAN1 numeric (radians)REALtrigonometry
ASIN, ACOS, ATAN1 numericREALinverse trig
ATAN2ATAN2(Y, X)REALquadrant-correct arctangent
NameSignatureResultBehavior
SHL, SHR(IN: INT/WORD, N: INT)same as INshift left / logical shift right (zero-fill)
ROL, ROR(IN, N)same as INrotate left / right
AND, OR, XORtwo integersintegerbitwise, 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.

All positions are 1-based (IEC convention); length/position arguments clamp to the string instead of faulting.

NameSignatureResultBehavior
LENLEN(IN)INTlength
LEFT, RIGHT(IN, L)STRINGfirst / last L characters
MIDMID(IN, L, P)STRINGL characters starting at position P
CONCATn-ary STRINGSTRINGconcatenation
INSERTINSERT(IN1, IN2, P)STRINGIN2 inserted into IN1 after position P
DELETEDELETE(IN, L, P)STRINGL characters removed starting at P
REPLACEREPLACE(IN1, IN2, L, P)STRINGL characters at P replaced by IN2
FINDFIND(IN1, IN2)INT1-based position of IN2 in IN1; 0 when absent or IN2 empty
NameSignatureResultBehavior
FIRST_SCANFIRST_SCAN()BOOLTRUE 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.

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 →BOOLSINT…LINTUSINT…ULINTBYTE, WORDDWORDLWORDREALLREALTIME, LTIMESTRING
BOOL0/10/10/10/10/10/10/10/1 msTRUE/FALSE
SINT…LINT≠ 0wrapwrapwrapwrapwrapexact¹exact¹msdecimal
USINT…ULINT≠ 0wrapwrapwrapwrapwrapexact¹exact¹msdecimal
BYTE, WORD≠ 0wrapwrapwrapwrapwrap··msdecimal
DWORD≠ 0wrapwrapwrapwrapwrapbits·msdecimal
LWORD≠ 0wrapwrapwrapwrapwrap·bitsmsdecimal
REAL≠ 0round²round²·bits·—sameround² msshortest
LREAL≠ 0round²round²··bitssame—round² msshortest
TIME, LTIME≠ 0ms, wrapms, wrapms, wrapms, wrapms, wrapmsmssameT#…ms
STRINGparse³parse³parse³parse³parse³parse³parse³parse³parse³—

That is 294 X_TO_Y functions, plus:

FormExampleMeaning
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) with u at -1 sees the UINT a PLC would hold, 65535. Pass a DINT to INT_TO_REAL and 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 no REAL_TO_WORD, and Codesys’s numeric one would read differently from TIA’s, so it is a compile error that says to write REAL_TO_UINT for the number or REAL_TO_DWORD for 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_LREAL and LREAL_TO_REAL change 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; use TIME_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 as TRUE/FALSE/1/0 in 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#5000ms for durations, so every STRING_TO_x(x_TO_STRING(v)) gives v back.
  • DATE, TIME_OF_DAY and DATE_AND_TIME are 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.

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).

TypeInputsOutputsBehavior
TONIN: BOOL, PT: TIMEQ: BOOL, ET: TIMEon-delay: Q rises after IN has been TRUE for PT; ET is elapsed
TOFIN, PTQ, EToff-delay: Q stays TRUE for PT after IN drops
TPIN, PTQ, ETpulse: 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
CTUCU: BOOL, R: BOOL, PV: INTQ: BOOL, CV: INTcount rising CU edges; Q when CV ≥ PV; R resets
CTDCD: BOOL, LD: BOOL, PV: INTQ, CVcount down from PV (LD loads); Q when CV ≤ 0
CTUDCU, CD, R, LD, PVQU, QD, CVup/down counter
R_TRIGCLK: BOOLQ: BOOLQ for exactly one scan on CLK’s rising edge
F_TRIGCLKQone-scan pulse on the falling edge
SRS1: BOOL, R: BOOLQ1: BOOLset-dominant latch
RSS: BOOL, R1: BOOLQ1: BOOLreset-dominant latch
PIDsee belowsee belowclosed-loop control — proportional/integral/derivative with anti-windup and bumpless auto/manual
LOCAL_TIME—YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MILLISECOND: DINTthe 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

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 MUX index out of range, an unparseable STRING_TO_*), so a call that returns has ENO = EN — except FBD’s DIV and MOD, whose ENO is FALSE on a zero divisor (the result itself follows the ÷0 rule: 0, and the divZero counter).

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.

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:

TypePower inPower out
TON, TOF, TPINQ
CTUCUQ
CTDCDQ
R_TRIG, F_TRIGCLKQ
SRS1Q1
RSSQ1
LOCAL_TIMEnone: it sits on the railnone: power passes through
user FUNCTION_BLOCKEN, else the first BOOL VAR_INPUT the call doesn’t bind by nameENO, 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 an EN.
  • 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 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.

PinKindTypeMeaning
AUTOinputBOOLTRUE = closed loop; FALSE = manual — CV tracks CV_MAN and the integral free-wheels for a bumpless return to AUTO
PVinputREALprocess value
SPinputREALsetpoint
KPinputREALproportional gain. KP = 0 disables the whole controller, not just the P term — see below
KIinputREALintegral gain, repeats/second (1/TI); 0 disables integral action
KDinputREALderivative gain, seconds (TD); 0 disables derivative action
CV_MANinputREALmanual output, used when AUTO = FALSE
CV_MINinputREALoutput clamp floor. Default 0 (see below)
CV_MAXinputREALoutput clamp ceiling. Default 100 (see below)
DIRECTinputBOOLFALSE = 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)
DTinputREALseconds 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
DBinputREALdeadband on error — within ±DB the P and I terms see zero error (chatter suppression); the D term still sees every PV move
RESETinputBOOLTRUE zeroes the integral this scan
CVoutputREALcontroller output, clamped to [CV_MIN, CV_MAX]
ERRoutputREALerror, before the deadband
SAT_HI, SAT_LOoutputBOOLTRUE when CV is clamped at its ceiling/floor
P_TERM, I_TERM, D_TERMoutputREALthe 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_VAR
lic(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;

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.

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: logix

A 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:

BlockPinsSemanticsOn a Logix controller
TONRIN, PT, Reset → Q, ETa 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 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_Scale
VAR_INPUT IN : AnalogInput; END_VAR (* a user TYPE, nested structs fine *)
VAR_OUTPUT OUT : AnalogInput; END_VAR
OUT := IN;
OUT.VALUE := IN.SCALE.LO + (IN.SCALE.HI - IN.SCALE.LO) * INT_TO_REAL(IN.RAW) / 32767.0;
END_FUNCTION_BLOCK

At 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.

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.

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_Starter
VAR_IN_OUT M : Motor; END_VAR
VAR edge : R_TRIG; END_VAR
edge(CLK := M.Cmd);
IF edge.Q THEN M.Starts := M.Starts + 1; END_IF;
M.Running := M.Cmd;
END_FUNCTION_BLOCK
VAR_EXTERNAL P101 : Motor; END_VAR
VAR s : FB_Starter; END_VAR
s(M := P101); (* P101 carries the block's writes afterwards *)

The rules, all of them enforced at compile time:

RuleWhy
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 tagthe block writes back to it; an expression has nowhere to write
The argument’s type must match the pin exactlya 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 sitethere is no default for a reference
The pin’s own type may not be a function blockan 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.

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 PumpSeq
VAR_INPUT Start : BOOL; Stop : BOOL; Level : REAL; StopLevel : REAL; END_VAR
VAR_OUTPUT Run : BOOL; Warm : BOOL; END_VAR
VAR t1 : TON; END_VAR
LD
RUNG seal [ Start | Run ] /Stop /GE(Level, StopLevel) ( Run )
RUNG warm Run t1:TON(PT := T#5S) ( Warm )
END_LD
END_FUNCTION_BLOCK

This 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.

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_VAR
p(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 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.

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.