Skip to content

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.

runner.step()           # Execute one scan
runner.run(cycles=100)  # Execute 100 scans

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.

with Rung(Button):
    latch(MotorRunning)

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(GreenTimer, preset=3000)

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(MyTimer, preset=3000)               # Timer: accumulate while rung is true
count_up(MyCounter, preset=100)              # Counter: increment each scan

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 Instruction Reference.

Timers and counters

Timer and Counter are built-in structured types. Each has a .Done bit and an .Acc accumulator. Use Timer.clone("Name") for named instances:

from pyrung import Timer, Counter

GreenTimer = Timer.clone("GreenTimer")

with Rung(State == "g"):
    on_delay(GreenTimer, preset=3000)  # 3000 ms (default unit)

The accumulator tracks progress in the unit you specify (milliseconds by default). 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. Use rise() on the rung condition if you want one increment per leading edge:

PartCounter = Counter.clone("PartCounter")

with Rung(rise(Sensor)):
    count_up(PartCounter, preset=9999).reset(CountReset)

Instruction pins

Some instructions have extra condition inputs beyond the rung — like the .reset() on the counter example above. These are pins on the instruction block.

                    ┌─────────────────┐
 Sensor ───────────▶│   count_up       │
                    │                  │──▶ .Done
 Reverse ──.down()─▶│  preset: 100     │──▶ .Acc
 Home, Auto .reset()▶│                  │
                    └─────────────────┘

The rung condition powers the instruction (top wire). Other pins are wired with dot-methods: .down(), .reset(), .clock(). Multiple conditions on one pin AND together — Home, Auto on .reset() means both must be True.

                       ┌─────────────────┐
 State == "g" ────────▶│   on_delay       │
                       │                  │──▶ .Done
                       │  preset: 3000    │──▶ .Acc
 StopBtn, Fault .reset()▶│  unit: "ms"      │
                       └─────────────────┘

Each pin gets its own line with \ continuation:

with Rung(rise(Sensor)):
    count_up(PartCounter, preset=100) \
        .down(Reverse) \
        .reset(Home, Auto)

Reads directly off the diagram — the pin name in the ASCII maps to the dot-method in the Python.

Programs

Program collects your rungs into a unit of logic that a runner can execute.

with Program() as logic:
    with Rung(Start):
        latch(Running)
    with Rung(Stop):
        reset(Running)

For larger programs, use the @program decorator to define logic as a function:

@program
def logic():
    with Rung(Start):
        latch(Running)
    with Rung(Stop):
        reset(Running)

Both forms produce the same thing — a Program you pass to PLC.

Structured tags (UDTs)

When you have a group of related tags, a @udt keeps them organized:

from pyrung import udt

@udt()
class Motor:
    Running: Bool
    Speed: Int
    Fault: Bool

Access fields with dot notation:

with Rung(Motor.Running):
    out(StatusLight)

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:

ds[1]   # Tag "DS1", INT
x[1]    # Input tag "X1", BOOL
y[1]    # Output tag "Y1", BOOL

Use .select() for bulk operations:

blockcopy(ds.select(1, 4), ds.select(2, 5))  # Shift DS1..DS4 into DS2..DS5

Reading and writing values

Inside a with PLC(...) as plc: block (or with runner: when you have a runner from a fixture), you can read and write tag values directly:

with PLC(logic) as plc:
    State.value = "g"           # Write (one-shot, consumed after one scan)
    print(State.value)          # Read
    plc.step()                  # Step with current values

For persistent overrides that hold across multiple scans, use forces:

plc.force("Button", True)
plc.step()   # True
plc.step()   # Still True
plc.unforce("Button")

System points

The PLC exposes built-in status and control through the system namespace. Import it with from pyrung import system.

system.sys — scan-level status: always_on, first_scan, clock toggles (clock_10ms through clock_1h), mode_run, scan_counter. Use first_scan for one-time initialization:

with Rung(system.sys.first_scan):
    copy("g", State)

system.fault — math and runtime fault flags: division_error, out_of_range, math_operation_error, address_error, plc_error, and code (the most recent fault code as an integer). Fault flags are auto-cleared at the start of each scan.

with Rung(system.fault.division_error):
    latch(MathFaultSeen)

system.rtc — real-time clock: year4, month, day, hour, minute, second (read-only). Writable counterparts (new_hour, etc.) with apply_date/apply_time triggers. Use for time-of-day logic like shift changes.

The Click cheatsheet has the full point-to-address mapping.

What's next as your programs grow

Once you have UDTs with command/feedback pairs — solenoids, sensors, actuators — you can annotate the physical behavior of feedback signals with physical= and link=. The autoharness reads those annotations and drives feedback in tests automatically, so you stop writing boilerplate that toggles inputs by hand. See Physical Annotations and Autoharness.

Next steps