Skip to content

Ladder Diagram (LD)

IEC 61131-3 defines ladder graphically. nautilus defines it as text: a .ld file holds rungs written left to right along the power rail, in a grammar with the standard’s semantics and no vendor dialect. The VS Code extension projects that text into a ladder editor where every gesture writes back to the same file.

Ladder fits boolean logic that gets reviewed by asking which contacts passed power: interlocks, permissives, latches, motor seal-ins, annunciators and alarm horns. It is the language most control engineers read fastest, and one rung is one line of git diff.

Loops and signal conditioning read better as Function Block Diagram. Math, arrays, and plant physics belong in Structured Text. Mix them per task against one tag store.

A trimmed slice of examples/lift-station/permissives.ld — the duty mapping and one pump’s permissive/start rungs; P102’s mirror the same shape:

PROGRAM Permissives
VAR_EXTERNAL
LeadIsP101 : BOOL; LeadReq : BOOL; LSHH101 : BOOL; LSLL101 : BOOL;
P101_Req : BOOL;
P101_SealFail : BOOL; P101_OverTemp : BOOL; P101_VfdFault : BOOL;
P101_Permissive : BOOL;
P101_Mode : INT; P101_Running : BOOL;
P101_RunCmd : BOOL; P101_FailToRun: BOOL; P101_LockedOut: BOOL;
P101_Avail : BOOL; ResetFaults : BOOL;
END_VAR
VAR
m101 : MotorStarter;
END_VAR
LD
// duty mapping + high-high override, one rung: LSHH forces the call
// regardless of the sequence or who's lead
RUNG p101req
[ LeadIsP101 LeadReq | LSHH101 ] ( P101_Req )
RUNG p101perm (* a dry well (LSLL) blocks both pumps, not just the lead *)
/P101_SealFail /P101_OverTemp /P101_VfdFault /LSLL101 ( P101_Permissive )
// every BOOL input bound by name: no free input for rung power, so the
// call sits alone on the rail — see docs/functions.md "Power pins in ladder"
RUNG p101start
m101:MotorStarter(Mode := P101_Mode, AutoReq := P101_Req, Permissive := P101_Permissive,
RunFb := P101_Running, Reset := ResetFaults,
Run => P101_RunCmd, FailToRun => P101_FailToRun,
LockedOut => P101_LockedOut)
RUNG p101avail (* Off (Mode 0) is never available, same as a failed, locked-out, or blocked pump *)
/P101_FailToRun /P101_LockedOut P101_Permissive /EQ(P101_Mode, 0) ( P101_Avail )
END_LD
END_PROGRAM

Every element the grammar accepts:

ElementTextWhat it does
NO contactTagpasses power when Tag is TRUE
NC contact/Tagpasses power when Tag is FALSE
Rising-edge contact+Tagone scan of power on Tag’s 0→1 transition (an implicit R_TRIG)
Falling-edge contact-Tagone scan of power on Tag’s 1→0 transition (an implicit F_TRIG)
Parallel branch[ a | b ]OR of its legs; each leg is a series, and branches nest
Function contactEQ(P101_Mode, 0)passes power when the call returns TRUE
Negated function contact/EQ(P101_Mode, 0)passes power when the call returns FALSE; / negates any BOOL term, /t1.Q included
Block in the rungm101:MotorStarter(Mode := P101_Mode, …)power drives its power-in pin, and continues from its power-out pin
Output captureRun => P101_RunCmdbinds an output pin to a variable, inside the block’s parentheses; the standard’s own => form, for a BOOL pin exactly as for a timer’s elapsed time
Output 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 is TRUE for one scan when the rung condition rises
Falling-edge coil( N Tag )Tag is TRUE for one scan when the rung condition falls

Contacts and coils take the same accessor references FBD takes, so array elements and struct members address directly: Levels[2], PIT_001.VALUE. A rung header carries a (* … *) comment; full-line // comments inside the block are diagram notes.

Series elements AND together and branch legs OR, so [ Start | Run ] /Stop ( Run ) compiles to Run := AND(OR(Start, Run), NOT Stop). That is the seal-in: Start energizes Run, Run’s own contact holds it in, Stop drops it out.

Coils sit at the right end of a rung. A rung needs at least one coil, function block or assignment. A block can sit anywhere in the rung: power continues from its output pin, so A t1:TON(PT := T#5S) B ( Lamp ) sets Lamp to t1.Q AND B. Without a coil the rung ends at its last block, so A B t1:TON(PT := T#5S) is a complete rung whose only output is t1.Q, read elsewhere; anything after that block would drive nothing and is an error. Several coils share one condition, evaluated once and fanned out. (When a coil writes a tag the condition reads, as in /Lamp ( Lamp ) ( Horn ), the condition is held in a generated BOOL, <rung>__power, which shows among the program’s locals.) A rung with only a coil is driven by the rail, so ( AlwaysOn ) assigns TRUE. Rungs evaluate top to bottom within a scan, so a coil written on one rung reads back as a contact on the next rung in the same scan.

Power is boolean, so anything gating it has to return BOOL. The six comparisons do. Numeric functions do not, and ADD(TempC, 0.0) as a contact is a compile error rather than a pass-through; numeric work goes inside a comparison’s arguments, as GE(ADD(Base, Bias), Limit).

When a block sits in a rung, power drives one input pin and leaves from one output pin; every other pin is named in the parentheses. TON, TOF and TP use IN and Q, CTU uses CU, CTD uses CD, R_TRIG and F_TRIG use CLK, SR uses S1/Q1, RS uses S/Q1. Passing the power pin yourself (t1:TON(IN := x)) is an error, because power owns it.

Coils assign BOOL only. To store a timer’s elapsed time or a counter’s value, use the standard’s output binding at the call site, Run => P101_RunCmd above. Any instance output is also readable as inst.Pin anywhere, including as a contact: m101.FailToRun reads the same way a plain BOOL tag would, without needing a => capture first.

Edge contacts and edge coils are unnamed in the text. The compiler derives a stable instance name from the rung name, the tag, and the position among repeats in that rung, so the edge’s retained state survives an unrelated edit elsewhere in the program.

LD transpiles in one hop to the FBD netlist, which transpiles to ST. Ladder inherits the whole chain rather than reimplementing it: the built-in operator, function and function-block vocabulary, user FUNCTIONs and FUNCTION_BLOCKs from library .st files, arrays and structs through accessor references, and typed diagnostics mapped back to the rung.

A user block drops into a rung the way a TON does. Power lands on EN if the block declares one, otherwise on the first BOOL VAR_INPUT the call does not bind by name, and continues from ENO or the first BOOL VAR_OUTPUT. Every other pin is bound in the parentheses, with => capturing outputs. Instance state persists across scans, and an online edit carries it by name and type.

Blocks can be written in ladder, too. A .ld file may hold FUNCTION_BLOCKs whose bodies are rungs, and a .ld file with no PROGRAM is a project library, the same rule that makes a PROGRAM-less .st one:

FUNCTION_BLOCK MotorStarter
VAR_INPUT
Mode : INT; (* 0 Off, 1 Hand, 2 Auto *)
AutoReq : BOOL;
Permissive : BOOL;
RunFb : BOOL;
Reset : BOOL;
END_VAR
VAR_OUTPUT
Run : BOOL;
FailToRun : BOOL;
END_VAR
VAR t1 : TON; END_VAR
LD
RUNG run
[ EQ(Mode, 2) AutoReq | EQ(Mode, 1) ] Permissive /FailToRun ( Run )
RUNG fail
Run /RunFb t1:TON(PT := T#5S) ( S FailToRun )
RUNG reset
Reset ( R FailToRun )
END_LD
END_FUNCTION_BLOCK

This is what ladder has instead of a JSR: a subroutine with pins rather than shared tags, and 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. examples/lift-station/lib/motor.ld is the whole feature (this listing trims its lockout counter) — instantiated twice, once per pump, from permissives.ld above. See Function blocks, libraries, and tasks.

permissives.ld as text on the left with live values inline, and the same rungs as a ladder diagram on the right, with power flow painted green from the running controller

Right-click a .ld file and choose Open With → Ladder Diagram to use the diagram as the editor, or run nautilus: Open Ladder Diagram Preview for text on the left and rungs on the right. Layout is canonical, computed from the source, so no coordinates land in your diffs. Editing gestures resolve through the Go compiler into minimal text edits:

  • A palette of NO contact, NC contact, rising- and falling-edge contact (P / N: the Logix ONS habit), function contact, parallel branch, and output/set/reset coil. Click to append at the selection, or drag onto a rung.

    Verified 25 Oct 2026, 08:03 UTC on f686456 · VS Code 1.139.1 · PASS · L03 on the proof page
  • FB… opens a picker over the standard blocks and the project’s own function blocks, prefilled with the next free instance name, and inserts the call at the end of the selected rung.

    Verified 25 Oct 2026, 08:03 UTC on f686456 · VS Code 1.139.1 · PASS · L05 on the proof page
  • Double-click a contact or coil to retag it (typing +Tag or -Tag makes the contact an edge, /Tag an NC one), a function contact to rewrite the call, a block’s body to edit its arguments or its header to rename the instance, the rung name to rename it, and the (* … *) slot to write a rung comment.

    Verified 25 Oct 2026, 08:03 UTC on f686456 · VS Code 1.139.1 · PASS · L03 on the proof page
  • N flips a contact between NO and NC, P cycles it NO → P → N → NC, M cycles a coil through output, set and reset, B wraps the selection in a parallel branch, Del deletes, and Ctrl+X/C/V cut, copy and paste. A pasted block instance gets a fresh name. With a rung’s name selected, copy takes the whole rung: it pastes below the selected rung with the next name (m1 → m2) and its block instances renamed the same way.

    Verified 25 Oct 2026, 08:03 UTC on f686456 · VS Code 1.139.1 · PASS · L11 on the proof page
  • A multi-leg OR in one step: Ctrl- or Shift-click several elements of one series and press O (or the toolbar’s OR). They become one branch with a leg each, a b c → [ a | b | c ]. A branch selects as a whole when you click its rails; O then adds a leg, and Del deletes it.

  • Drag an element to reorder it or move it to another rung. A contact dropped in the coil zone is refused, and so is a coil dropped mid-series.

    Verified 25 Oct 2026, 08:03 UTC on f686456 · VS Code 1.139.1 · PASS · L07 on the proof page
  • + rung adds a rung, // adds a comment note, and the variables panel declares, renames (double-click the name) or removes a declaration without leaving the diagram. It also lists the block instances the rungs declare by their calls (m1 : MotorStarter, with the rung), and Escape closes it. In a FUNCTION_BLOCK it declares the block’s pins (VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT) as well as locals; renaming a pin also renames it in the calls in that file.

  • The amber declare … offer lists every name a rung uses that its POU does not declare. A nautilus.yaml tag is in scope in the program as it is, so it is not listed (unless the manifest gives it no type, when the offer’s VAR_EXTERNAL types it); any other name in the program is offered as VAR; inside a FUNCTION_BLOCK it offers a pin, VAR_INPUT for a name the rungs read and VAR_OUTPUT for one a coil or => writes. The type comes from the use: a counter’s CV => Count is INT, a contact is BOOL.

With a controller reachable, live values paint power flow: contacts and coils show their state (an edge contact shows its one-shot’s output, so it lights for the one scan it fires), the wires show whether power reaches that far, and comparisons evaluate locally. An in-rung block reads its real output pin from the controller. Anything unresolvable renders neutral.

Each contact and coil shows its tag’s description on a second line under the operand, the way Studio 5000 does. The description is the desc from nautilus.yaml (or a tag file), or a trailing (* comment *) on the variable’s declaration. The element’s tooltip carries the full text, and the nautilus.diagram.showDescriptions setting turns the second line off. To find every other place a tag is read or written, select its contact, coil or block and press Shift+F12, or right-click it and choose Find All References. The References view lists every program that uses the tag, on the diagram files’ own lines, plus its declaration in the manifest. This is the ladder’s cross-reference, the equivalent of Studio 5000’s Ctrl+E.

nautilus: Diff Ladder Diagram (vs git HEAD) and (vs Controller) overlay the committed or running rungs on the working ones, marking added, changed and removed elements in cyan, amber and red. Green already means power here. (between git revisions…) lists every commit that touched the file and overlays any two of them the same way, so the change between last month’s program and today’s, or between any two releases, reads as rungs. Pick the working tree as the newer side and the overlay follows your edits. The git diffs work on a Rockwell .L5X export as well.

Terminal window
naut check # compile every .st/.fbd/.ld/.sfc; errors land on the rung
naut test # acceptance tests, on the virtual clock
naut ld graph permissives.ld # the ladder render model as JSON
naut ld edit # apply one structural edit op, from JSON on stdin

naut check composes the line maps back through both hops, so a type error in the ST a rung produced reports at that rung’s RUNG line. The language server does the same live.

Online edits treat .ld as the program source. Download, diff against the controller, and naut pull all move the ladder text itself, so the program a controller reports compares 1:1 with the file in git. See Online edits.

A rung change reviews as a rung:

RUNG p101req
[ LeadIsP101 LeadReq | LSHH101 ] ( P101_Req )
[ LeadIsP101 LeadReq | LSHH101 | ManualCall ] ( P101_Req )

naut new my-plant --template minimal --language ld scaffolds a project whose program is ladder.

  • Jumps and labels. No JMP, JMPC, LBL, RET. Rungs run top to bottom, every scan.
  • Master control relay zones. No MCR or zone bracketing.
  • Negated coils. ( /Tag ) is rejected; invert with NC contacts or use a reset coil.
  • Coils inside a branch, or ahead of a later block. The output zone is contiguous at the rung’s right end. Split such a rung into one rung per output leg.
  • Vendor instruction sets. The vocabulary is the IEC built-ins in the language reference, and nothing else.