Core Concepts
This page covers the vocabulary you need to write and understand a pyrung program. For engine internals and architecture, see Architecture.
Scans
A PLC doesn't run code line by line — it runs in scans. Each scan works through every rung in order, first to last. On each rung, conditions are checked, then instructions execute. Once every rung has been evaluated, that's one scan.
Tags
A tag is a named, typed value. Think of it as a PLC address with a human-readable name.
from pyrung import Bool, Int, Real, Char
Button = Bool("Button") # 1 bit, resets on STOP→RUN
Step = Int("Step") # 16-bit signed, retentive
Temp = Real("Temp") # 32-bit float, retentive
State = Char("State") # 8-bit ASCII, retentive
Tags don't hold values themselves — they're references. Values live in the system state and update each scan.
| Constructor | Size | Retentive by default |
|---|---|---|
Bool |
1 bit | No |
Int |
16-bit signed | Yes |
Dint |
32-bit signed | Yes |
Real |
32-bit float | Yes |
Word |
16-bit unsigned | Yes |
Char |
8-bit ASCII | Yes |
Retentive means the value survives a STOP→RUN transition. Non-retentive tags reset to their defaults.
Rungs
A rung is a with block. The condition goes on the Rung, the instructions go in the body. If the condition is true, the instructions execute. If false, instructions that depend on the live power rail are turned off.
This reads like a ladder diagram: Button is the contact on the left rail, latch(MotorRunning) is the coil on the right. If Button is true, MotorRunning gets latched.
Conditions can be combined and compared:
with Rung(Button & ~EStop): # AND + NOT
latch(MotorRunning)
with Rung(Temp > 150.0): # Comparison
out(OverTempAlarm)
with Rung(State == "g"): # Equality
on_delay(GreenDone, GreenAcc, preset=3000, unit=Tms)
Branches
A branch creates a parallel condition within a rung — like a parallel path on a ladder diagram:
with Rung(First): # ① Evaluate: First
out(Third) # ③ Execute
with branch(Second): # ② Evaluate: First AND Second
out(Fourth) # ④ Execute
out(Fifth) # ⑤ Execute
Three rules:
- Conditions evaluate before instructions. ① and ② are resolved before ③ ④ ⑤ run. A branch ANDs its own condition with the parent rung's.
- Instructions execute in source order. ③ → ④ → ⑤, as written — not "all rung, then all branch."
- Each rung starts fresh. The next rung sees the state as it was left after the previous rung's instructions.
Instructions
Instructions are what go inside a rung. Here are the ones you'll use most often:
out(Light) # Energize while rung is true, de-energize when false
latch(Motor) # Set and hold — stays true even if rung goes false
reset(Motor) # Clear a latched tag
copy("g", State) # Copy a value into a tag
calc(Step + 1, Step) # Evaluate an expression, store the result
on_delay(Done, Acc, preset=3000, unit=Tms) # Timer: accumulate while rung is true
count_up(Done, Acc, preset=100) # Counter: increment on rising edge
out vs latch: out follows the rung — true when the rung is true, false when it's false. latch is sticky — once set, it stays set until explicitly reset.
The full instruction set (branching, subroutines, shift registers, edge detection, and more) is in the Ladder Logic Guide.
Timers and counters
Timers accumulate time while their rung is true. When the accumulator reaches the preset, the done bit fires.
GreenDone = Bool("GreenDone")
GreenAcc = Int("GreenAcc")
with Rung(State == "g"):
on_delay(GreenDone, GreenAcc, preset=3000, unit=Tms) # 3000 ms
The accumulator tracks progress in the unit you specify (Tms for milliseconds). If the rung goes false before the preset, the accumulator resets (that's on_delay — use off_delay for the inverse behavior).
Counters increment once per scan while enabled. To count only on the rising edge (holding the sensor high counts once), use oneshot=True:
Programs
Program collects your rungs into a unit of logic that a runner can execute.
For larger programs, use the @program decorator to define logic as a function:
Both forms produce the same thing — a Program you pass to PLCRunner.
Structured tags (UDTs)
When you have a group of related tags, a @udt keeps them organized:
Access fields with dot notation:
For multiple instances of the same structure, set count:
@udt(count=3)
class Pump:
running: Bool
flow: Real
# Access by instance
with Rung(Pump[1].running):
out(Pump1Light)
Blocks
A block is a contiguous array of tags — used for grouped memory and physical I/O. Addresses typically start at 1 to match PLC conventions, but any start index is supported.
from pyrung import Block, InputBlock, OutputBlock, TagType
ds = Block("DS", TagType.INT, 1, 100) # Internal memory DS1..DS100
x = InputBlock("X", TagType.BOOL, 1, 16) # Physical inputs X1..X16
y = OutputBlock("Y", TagType.BOOL, 1, 16) # Physical outputs Y1..Y16
Index into a block to get a tag:
Use .select() for bulk operations:
Reading and writing values
Inside a runner.active() block, you can read and write tag values directly:
with runner.active():
State.value = "g" # Write (one-shot, consumed after one scan)
print(State.value) # Read
runner.step() # Step with current values
For persistent overrides that hold across multiple scans, use forces:
runner.add_force("Button", True)
runner.step() # True
runner.step() # Still True
runner.remove_force("Button")
Next steps
- Quickstart — build and test a traffic light
- Ladder Logic Guide — full instruction reference
- Testing Guide — patterns for deterministic testing
- Architecture — engine internals, scan phases, SystemState