# pyrung > Pythonic PLC simulation engine — write ladder logic in Python, simulate with full debug support. # Overview # pyrung **Ladder logic in Python that reads like ladder, scans like a PLC, and deploys to real hardware.** ``` from pyrung import Bool, PLC, Program, Rung, out Button = Bool("Button") Light = Bool("Light") with Program() as logic: with Rung(Button): out(Light) with PLC(logic) as plc: Button.value = True plc.step() assert Light.value is True ``` - LLM docs index: https://ssweber.github.io/pyrung/llms.txt - New to ladder logic? [Know Python? Learn Ladder Logic.](https://ssweber.github.io/pyrung/learn/index.md) ## What it does Ladder logic has always been a domain language for industrial control. pyrung asks a simple question: **what if that language lived in Python?** **For controls engineers:** Write and simulate Click PLC logic without hardware or proprietary software. Use plain tag names from day one. Add hardware addresses when you're ready. A validator checks your program against Click constraints and tells you exactly what to fix. **For developers:** VS Code becomes your PLC programming environment — step through scans, set breakpoints on rungs, watch tags update inline, and force overrides from the debug console. **For makers and P1AM-200 users:** The same program can generate a deployable CircuitPython scan loop with built-in ladder instructions, Modbus TCP, and SD card persistence — no plumbing required. ## How it works **Every scan is a snapshot.** Logic is a pure function — the same inputs always produce the same outputs, nothing is mutated in place. Every step produces a new immutable state, so history is always there when you want it. **You drive execution.** The engine never runs on its own. Call `step()`, `run()`, or `run_until()` from tests, a GUI, or a debugger. Pause anywhere, inject inputs, inspect any historical state. **Time is a variable.** `dt=0.010` advances the clock by a fixed amount each scan, making timers and counters perfectly deterministic in tests. Rewind and replay whenever you need to. **Write first, validate later.** Start with semantic tag names and plain Python. Map to hardware addresses when you're ready, then run the validator. It tells you what Click can and can't do — before you find out at the PLC. **Ask why, not just what.** `plc.cause(Running)` traces backward through scan history to explain exactly why a tag changed — proximate triggers vs. enabling conditions. `plc.effect(StartBtn)` traces forward to show what it caused. Projected mode answers "what would it take to clear this fault?" without running a single scan. **Prove it before you ship it.** `prove()` exhaustively checks a property over all reachable states — no test cases to write, no scenarios to miss. Automated fault coverage proves every device coupling has an alarm path. Lock files catch behavioral regressions in PRs. ## Quick links - [Installation](https://ssweber.github.io/pyrung/getting-started/installation/index.md) — `pip install pyrung` - [Quickstart](https://ssweber.github.io/pyrung/getting-started/quickstart/index.md) — up and running in 5 minutes - [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md) — how the scan cycle and state model work - [Instruction Reference](https://ssweber.github.io/pyrung/instructions/index.md) — the full DSL reference - [Click PLC Dialect](https://ssweber.github.io/pyrung/dialects/click/index.md) — memory banks, address mapping, validation - [Commissioning Workflow](https://ssweber.github.io/pyrung/guides/commissioning/index.md) — declare, analyze, verify, commission - [Physical Annotations and Autoharness](https://ssweber.github.io/pyrung/guides/physical-harness/index.md) — annotate devices, eliminate feedback boilerplate in tests - [Analysis](https://ssweber.github.io/pyrung/guides/analysis/index.md) — dataview, cause/effect, coverage queries, static validators - [Verification](https://ssweber.github.io/pyrung/guides/verification/index.md) — prove(), fault coverage, lock files - [VS Code Debugger](https://ssweber.github.io/pyrung/guides/dap-vscode/index.md) — breakpoints, monitors, step-through debugging - [CircuitPython Dialect](https://ssweber.github.io/pyrung/dialects/circuitpy/index.md) — P1AM hardware model and code generation - [CircuitPython Modbus TCP](https://ssweber.github.io/pyrung/dialects/circuitpy-modbus/index.md) — Modbus server and client for P1AM-200 # Learn # Know Python? Learn Ladder Logic. > A guided introduction to PLC programming for Python developers, using [pyrung](https://ssweber.github.io/pyrung/). You know Python. You've never touched a PLC. This guide teaches you ladder logic, the dominant programming language of industrial automation, using tools you already have: Python, VS Code, and pytest. No hardware required. pyrung won't let you cheat — if you try to write a `for` loop where a scan cycle belongs, it'll tell you. That's the point. You're learning a different way of thinking about programs, and the guardrails are there to keep you honest. ______________________________________________________________________ ## What you're building Every lesson adds a feature to the same project: a **conveyor sorting station**. Boxes arrive on a belt, get measured, and a diverter gate routes them to the correct bin. By the end, you'll have a system with start/stop/e-stop, auto and manual modes, a state-driven sorting sequence, structured tags for the equipment, a full test suite, and a path to real hardware. Each lesson follows the same arc: start with the Python you'd instinctively reach for, see why it doesn't work for a machine that controls physical things, then learn the ladder logic way. Every lesson ends with an exercise you can run and test. **Prerequisites:** Python 3.12+, basic pytest knowledge, a text editor. Code samples use PLC-style `TitleCase` for tag names -- more on that in [Lesson 2](https://ssweber.github.io/pyrung/learn/tags/index.md). ``` pip install pyrung ``` ## Lessons 1. [The Scan Cycle](https://ssweber.github.io/pyrung/learn/scan-cycle/index.md) -- A button runs the conveyor motor. 1. [Tags](https://ssweber.github.io/pyrung/learn/tags/index.md) -- Speed setpoint and an over-speed alarm. 1. [Latch and Reset](https://ssweber.github.io/pyrung/learn/latch-reset/index.md) -- Start, stop, and emergency stop. 1. [Assignment](https://ssweber.github.io/pyrung/learn/assignment/index.md) -- Record box sizes and keep a running total. 1. [Timers](https://ssweber.github.io/pyrung/learn/timers/index.md) -- Hold the diverter gate open for 2 seconds. 1. [Counters](https://ssweber.github.io/pyrung/learn/counters/index.md) -- Count boxes into each bin. 1. [State Machines](https://ssweber.github.io/pyrung/learn/state-machines/index.md) -- The full sorting sequence. 1. [Branches and OR Logic](https://ssweber.github.io/pyrung/learn/branches/index.md) -- Auto and manual modes. 1. [Structured Tags and Blocks](https://ssweber.github.io/pyrung/learn/structured-tags/index.md) -- A Bin UDT and a sort log. 1. [Testing](https://ssweber.github.io/pyrung/learn/testing/index.md) -- A pytest suite for the whole system. 1. [From Simulation to Hardware](https://ssweber.github.io/pyrung/learn/hardware/index.md) -- Map your project to a real Click PLC or P1AM-200. ______________________________________________________________________ *Built with [pyrung](https://github.com/ssweber/pyrung). Write ladder logic in Python, simulate it, test it, deploy it.* # Lesson 4: Assignment ## The Python instinct ``` last_size = current_size total_boxes = total_boxes + 1 ``` Assignment is so fundamental in Python that it barely registers as a concept. You have `=` and you're done. ## The ladder logic way In ladder logic, moving data is an explicit instruction that lives on the instruction side of a rung. It executes when the rung is true and does nothing when the rung is false. ``` from pyrung import Bool, Int, Program, Rung, PLC, copy, calc, rise EntrySensor = Bool("EntrySensor") BoxSize = Int("BoxSize") # Raw sensor reading CurrentSize = Int("CurrentSize") # Snapshot of this box's reading SortCount = Int("SortCount") # Total boxes sorted CycleCount = Int("CycleCount") # Scans since startup with Program() as logic: with Rung(rise(EntrySensor)): copy(BoxSize, CurrentSize) # Snapshot the size reading calc(SortCount + 1, SortCount) # Increment total with Rung(): calc(CycleCount + 1, CycleCount) # Always counting (every scan) ``` `copy` moves a value into a tag. `calc` evaluates an expression and stores the result. Both are instructions that only execute when their rung has power. A `copy` inside a rung that's false simply doesn't happen, and the destination keeps whatever value it had. ``` rise(EntrySensor) -- fires one scan: BoxSize --copy--> CurrentSize SortCount + 1 --calc--> SortCount Every scan: CycleCount + 1 --calc--> CycleCount ``` ## Edge detection: `rise()` and `fall()` `rise(EntrySensor)` fires for exactly one scan when the sensor transitions from False to True. Without it, the copy and calc above would execute *every scan* while the sensor stays active. If a box sits on the sensor for 100 scans, you'd get 100 copies and 100 increments instead of one. This is the biggest conceptual jump from Python. In Python, `if sensor:` is about the *current value*. In ladder, `rise()` is about the *transition* — it detects the leading edge and fires once. `fall()` does the same for the trailing edge (True → False). You'll use `rise()` constantly from here on: edge-triggered counting in [Lesson 6](https://ssweber.github.io/pyrung/learn/counters/index.md), state transitions in [Lesson 7](https://ssweber.github.io/pyrung/learn/state-machines/index.md), and anywhere you need "do this once when the condition changes." ## Try it ``` with PLC(logic) as plc: BoxSize.value = 150 EntrySensor.value = True plc.step() assert CurrentSize.value == 150 assert SortCount.value == 1 EntrySensor.value = False plc.step() assert SortCount.value == 1 # rise() only fires once assert CycleCount.value == 2 # Unconditional rung runs every scan ``` ## copy vs calc These two handle overflow differently, and the difference matters. `copy` clamps: if you copy 50000 into a 16-bit signed Int, you get 32767 (the max). `calc` wraps: if an Int at 32767 has 1 added, it rolls to -32768. Reach for `copy` when moving data (don't silently roll over a sensor reading) and `calc` for arithmetic (wrapping matches how real PLC counters and accumulators behave). Note the argument order: `copy(source, dest)` reads like an assignment left-to-right. Some PLC editors display it destination-first. pyrung always uses source-first. ## Unconditional rungs Notice `Rung()` with no condition. That rung is always true, so its instructions execute every scan. This is how you compute values that should always be current, like a cycle counter or a scaled analog reading. ## Exercise Remember "order has meaning" from [Lesson 1](https://ssweber.github.io/pyrung/learn/scan-cycle/index.md)? It applies within a rung too: instructions execute top-to-bottom, in the order you write them. That matters here. Create a `PreviousSize` tag. Each time a new box arrives (`rise(EntrySensor)`), copy `CurrentSize` to `PreviousSize` before copying the new `BoxSize` into `CurrentSize`. Test that after two boxes (sizes 100 and 200), `CurrentSize` is 200 and `PreviousSize` is 100. Then swap the two copies and run the test again -- verify that `PreviousSize` gets the *wrong* value. Understand why before you swap them back. ______________________________________________________________________ The conveyor needs to wait -- hold the diverter gate open long enough for the box to pass through. Python would `sleep`. A PLC can't sleep. That's where timers come in. Also known as... `copy` is `MOV`, `COP`, or `MOVE`. `calc` is `MATH` or `CPT`. `rise()` and `fall()` are one-shots (`ONS`/`OSR`) or edge triggers (`R_TRIG`/`F_TRIG`). An unconditional rung is "always on" — some PLCs expose a special always-true bit, others just wire straight from the rail. # Lesson 8: Branches and OR Logic ## The Python instinct ``` if auto_mode and state == "sorting" and is_large: diverter = True elif manual_mode and diverter_button: diverter = True ``` ## The ladder logic way Ladder logic has two ways to combine conditions. Commas inside `Rung(...)` are implicit AND. For OR, use `Or()`: ``` from pyrung import Bool, Int, Program, Rung, branch, comment, out, latch, reset, Or, And Auto = Bool("Auto") Manual = Bool("Manual") StopBtn = Bool("StopBtn") # NC contact StartBtn = Bool("StartBtn") EstopOK = Bool("EstopOK") # NC safety relay permission Running = Bool("Running") Light = Bool("Light") DiverterBtn = Bool("DiverterBtn") DiverterCmd = Bool("DiverterCmd") ConveyorMotor = Bool("ConveyorMotor") StatusLight = Bool("StatusLight") Mode = Int("Mode") with Program() as logic: # Motor runs in either mode when started with Rung(Or(Auto, Manual)): out(Light) # Status light: either mode is active # Or works with comparisons and any number of conditions with Rung(Or(Mode == 1, Mode == 3, Mode == 5)): latch(Running) ``` `Or` is variadic — pass any number of conditions. `And` is the explicit form of comma-separated conditions, useful inside `Or` for nested groups. Mirrors a familiar Python pattern: `any([a, b, c])` and `all([a, b, c])`. ## Branches A `branch` creates a parallel path within a rung. Think of it as a second wire that ANDs its condition with the parent's. Here's the conveyor's motor rung. `EstopOK` gates everything — it's a permission input from the safety relay, True when the world is safe to run. Below that, the motor and status light share the same gate: ``` EstopOK (parent rung — True when safe) +-- Running -> out(ConveyorMotor) +-- Running -> out(StatusLight) ``` ``` with Program() as logic: comment("Start/stop — NC stop resets when pressed or wire broken") with Rung(StartBtn, Or(Auto, Manual)): latch(Running) with Rung(~StopBtn): reset(Running) with Rung(~EstopOK): reset(Running) comment("Motor output — EstopOK gates all outputs") with Rung(EstopOK): with branch(Running): out(ConveyorMotor) with branch(Running): out(StatusLight) ``` This is the **gate pattern**. The parent rung holds your master condition, and every branch inside inherits that permission automatically. Lose the gate, lose all the outputs — atomically, in one scan. `EstopOK` reads as "safety is satisfied" so the gate uses the raw tag with no `~`. The reset rungs use `~StopBtn` and `~EstopOK` because those fire when the NC circuits open — same `~` convention from [Lesson 3](https://ssweber.github.io/pyrung/learn/latch-reset/index.md). The gate pattern is *the* textbook ladder structure for any permission or interlock — guard doors, light curtains, machine-enabled flags. Real fail-safe E-stop wiring lives in [Lesson 11](https://ssweber.github.io/pyrung/learn/hardware/index.md); here, the gate is general-purpose. ## Combining branches and `Or` The diverter needs to fire in two cases: auto mode during sorting, or manual mode with the button pressed. That's `Or` with `And` — same pattern as `any([all([...]), all([...]])` in Python: ``` comment("Diverter output — auto sort OR manual button, gated by EstopOK") with Rung( EstopOK, Or( And(State == SORTING, IsLarge, Auto), And(Manual, DiverterBtn), ), ): out(DiverterCmd) ``` The diverter rung reads `State` and `IsLarge` directly from the state machine in [Lesson 7](https://ssweber.github.io/pyrung/learn/state-machines/index.md) — no intermediate latch needed. Both control sources fold into one rung with a single `out(DiverterCmd)`. Remember "order has meaning" from [Lesson 1](https://ssweber.github.io/pyrung/learn/scan-cycle/index.md)? This is how you escape it: **one coil, one rung.** If two separate rungs both `out` the same tag, the last one evaluated wins — a false manual rung below a true auto rung would de-energize the diverter. Fold every reason the output should energize into one rung and order stops being a side effect. Key concept: atomic rungs **All conditions evaluate before any instructions execute.** The branch doesn't "see" results of instructions above it in the same rung — every rung is a snapshot of the world, evaluated then acted on as a unit. This is the **atomic rung** property: conditions read from the state as it was when the rung started, not from half-finished instruction results. It ties back to [Lesson 1's scan cycle](https://ssweber.github.io/pyrung/learn/scan-cycle/index.md) and forward to [Testing](https://ssweber.github.io/pyrung/learn/testing/index.md), where deterministic scans make this guarantee testable. ## Seal-in: a branch that holds itself [Lesson 3](https://ssweber.github.io/pyrung/learn/latch-reset/index.md) used `latch`/`reset` for start/stop control. The classic ladder alternative is a **seal-in** — a single rung where the output feeds back into its own branch: ``` with Rung(~StopBtn): with branch(Or(StartBtn, Running)): out(Running) ``` `Running` appears in its own branch condition. Press `StartBtn` and `Running` energizes; release it and `Running` still powers the branch — it holds itself in. Open `~StopBtn` and the parent rung drops, breaking the seal. Reach for `latch`/`reset` when clarity matters; expect seal-in in every legacy ladder you inherit. ## Try it ``` from pyrung import PLC with PLC(logic) as plc: StopBtn.value = True # NC inputs: True = healthy EstopOK.value = True Auto.value = True StartBtn.value = True plc.step() assert Running.value is True assert ConveyorMotor.value is True assert StatusLight.value is True StartBtn.value = False plc.step() assert Running.value is True # Still running (latched) # E-stop kills everything (NC opens) EstopOK.value = False plc.step() assert ConveyorMotor.value is False assert StatusLight.value is False assert Running.value is False ``` ## Exercise Add a `ManualLight` that is on only in manual mode. Write a test that switches from Auto to Manual mid-run and verifies the diverter control source changes without the motor stopping. Test that the diverter button does nothing in auto mode. ______________________________________________________________________ Each bin has a count, the diverter has a state, and there's a mode selector. That's a lot of scattered tags. In a real PLC, you'd group them into structures -- UDTs for the bins and the equipment. Also known as... OR is a parallel branch of contacts; AND is contacts in series. `branch()` is "parallel branch" everywhere. The safety-gate pattern is sometimes called "Master Control Reset" (`MCR`). Seal-in is the classic OR-branch with a series stop contact. # Lesson 6: Counters ## The Python instinct ``` count = 0 for item in items: count += 1 if count >= 10: batch_complete = True ``` ## The ladder logic way There's no `for` loop. There's no "list of items." There's a sensor at the end of each bin chute that goes True every time a box drops in — and a counter that counts it. But here's the catch: **a counter increments every scan while its rung is True**, not every edge. A sensor held True for 100 scans racks up 100 counts from a single box. Wrap the sensor with `rise()` — the edge detection from [Lesson 4](https://ssweber.github.io/pyrung/learn/assignment/index.md) — to count edges instead. One increment per False→True transition. You'll do this almost every time you use a counter on a sensor input. ``` from pyrung import Bool, Counter, Program, Rung, PLC, count_up, rise BinASensor = Bool("BinASensor") BinBSensor = Bool("BinBSensor") BinACounter = Counter.clone("BinACounter") BinBCounter = Counter.clone("BinBCounter") CountReset = Bool("CountReset") with Program() as logic: with Rung(rise(BinASensor)): count_up(BinACounter, preset=10) \ .reset(CountReset) with Rung(rise(BinBSensor)): count_up(BinBCounter, preset=10) \ .reset(CountReset) ``` `rise(BinASensor)` fires for exactly one scan when the sensor goes from False to True. Without it, the counter would increment every scan while the sensor is active, racking up hundreds of counts per box. `Counter` is a built-in structured type — the mirror of `Timer`. Its `.Acc` is a Dint (32-bit) because a 16-bit integer rolls over at 32,767 — on a fast line, that's a few hours of production. Production counters in real PLCs are almost always 32-bit for the same reason. ``` rise(BinASensor)? --yes--> .Acc += 1 --> .Acc >= Preset? --yes--> .Done = True | | no no v v no change keep counting CountReset? --any time--> .Acc = 0, .Done = False ``` Key concept: chips, not function calls Notice `.reset(CountReset)` on its own line. In Python, you'd pass all behavior into a single function call. In a ladder diagram, an instruction block is more like a **chip with multiple input pins**: the rung powers the count input, but the reset pin is a separate wire connected to its own condition. When `CountReset` goes true, the accumulator and done bit clear regardless of what the rung is doing. This mental model extends to every box instruction in real PLCs — timers, PID loops, message blocks, motion instructions. The `.reset()` chain is pyrung's way of drawing those extra wires. If this looks familiar, it should — it's the same `.reset()` chain from the [retentive timer in Lesson 5](https://ssweber.github.io/pyrung/learn/timers/#retentive-on-delay). Counters and timers are structurally identical: both are built-in types with `.Done` and `.Acc` fields, both chain `.reset()`, and `.reset()` is terminal for the [same reason](https://ssweber.github.io/pyrung/learn/timers/#retentive-on-delay). Counters can also count in both directions. A `count_up` with a `.down()` chain becomes a bidirectional counter (CTUD) — boxes entering minus boxes leaving gives boxes currently in zone: ``` ZoneCounter = Counter.clone("ZoneCounter") count_up(ZoneCounter, preset=50) \ .down(BoxLeavesSensor) \ .reset(ZoneReset) ``` Same chained-builder pattern, one more pin on the chip. ## Try it ``` with PLC(logic) as plc: # Simulate 3 boxes into Bin A for _ in range(3): BinASensor.value = True plc.step() BinASensor.value = False plc.step() assert BinACounter.Acc.value == 3 assert BinACounter.Done.value is False # Simulate 7 more for _ in range(7): BinASensor.value = True plc.step() BinASensor.value = False plc.step() assert BinACounter.Acc.value == 10 assert BinACounter.Done.value is True # Batch complete! ``` Notice the irony: the *test* uses `for` loops to simulate physical events, while the *logic* has no loops at all. Python where Python belongs (driving the simulation, asserting state), ladder where ladder belongs (the actual control). The boundary is the runner. ## Exercise Add a `TotalCounter` that counts every box regardless of which bin, triggered by an `EntrySensor`. Add a `TotalReset` button. Test that after 5 boxes (3 to Bin A, 2 to Bin B), the total is 5 and the individual counts are correct. Then reset and verify all three counters clear. ______________________________________________________________________ We have sensors, timers, counters, and a diverter. But nothing coordinates the sequence: detect a box, read its size, position the diverter, wait, count. That's a state machine. Also known as... Counters are `CTU`/`CTD`/`CTUD`. Done bits and accumulators follow the same naming as timers. Reset is its own input pin. Edge-counting is always "one-shot feeding the counter" — never the counter itself. # Lesson 11: From Simulation to Hardware > *"The tech (maybe you) at 3am will thank you."* You started with a button that turned on a motor. You ended with a tested, deployable conveyor sorting station -- start/stop/e-stop, auto and manual modes, a state-driven sorting sequence, structured bin counting, and a test suite that proves it all works. The tests you wrote in [Lesson 10](https://ssweber.github.io/pyrung/learn/testing/index.md) are still your safety net. pyrung's simulation behavior matches its codegen output -- the same assertions that proved your sorting logic in pytest will hold on the target hardware. That's the bargain: you don't have to test on hardware because you already tested in simulation. Everything from here is about taking what you've built and connecting it to the physical world. About this example pyrung and the conveyor sorting station in this guide are provided as an educational example, "as-is" with no expressed or implied warranty. If you adapt any of this code for a real application, **it is your responsibility to completely modify, integrate, and test it to ensure it meets all system and safety requirements for your intended use.** Like all general-purpose PLCs, the hardware targeted in this lesson is not fault-tolerant and is not designed, manufactured, or intended for use in hazardous environments requiring fail-safe performance -- nuclear facilities, aircraft navigation, air traffic control, life support, or weapons systems -- where failure could lead directly to death, personal injury, or severe environmental damage. Real installations must follow all applicable local and national codes (NEC, NFPA, NEMA, and the codes of your jurisdiction). pyrung verifies your *logic*; it cannot verify your wiring, your safety circuit, or your machine. Get a review from a qualified controls engineer before energizing anything that can move, heat, pinch, or otherwise hurt someone. ## `StopBtn` was the warm-up. Now meet the E-stop. You've been writing `~StopBtn` since [Lesson 3](https://ssweber.github.io/pyrung/learn/latch-reset/index.md). That's the same NC wiring convention real stop buttons use -- the bit is HIGH when healthy, LOW when pressed or broken. So you already know how fail-safe inputs read in code. The wiring is the easy part. The hard part is **who owns the stop.** When you wired `StopBtn` to the PLC, the PLC was in charge: it read the bit, decided to call `reset(Running)`, and stopped the motor as a software decision. That works for a conveyor in the lab. It does *not* work on a machine that can hurt someone, because the PLC is not a safety device. If your scan halts, your watchdog hangs, your firmware glitches, or your output transistor welds shut, the PLC's "decision" to stop never reaches the actuator. A real E-stop takes the PLC *out of the chain of command*. The red mushroom button wires to a dedicated **safety relay** (Pilz, Banner, ABB Jokab) rated to ISO 13849 / IEC 62061. The safety relay handles dual-channel monitoring, contact welding detection, and the actual stop circuit that drops power to dangerous outputs. The PLC reads the relay's permission contact as `EstopOK` and is *informed* -- but not in charge. If the PLC dies, the safety relay still drops the contactor. - **`StopBtn`** -- operator says "please stop." PLC handles it in software. It's a control input. - **`EstopOK`** -- safety relay says "the world is OK to run." PLC obeys it as a gate. It's a permission input. Both are NC wired, but the naming tells you which is which. `~StopBtn` reads as "stop is asserted." `EstopOK` reads as "safety is satisfied" -- no negation needed because the name encodes the polarity. Same NC wiring, opposite naming, because they encode different *meanings*. In the example code, `EstopOK` gates all outputs through `with Rung(EstopOK):` -- read that as a *demonstration* of the pattern, not a safety design. ## Three ways to deploy Your pyrung program can reach hardware through three completely different paths. Pick the one that fits your use case -- or combine them. | Use case | Option | What runs where | | --------------------------------------------- | --------------------- | -------------------------------------------------------------------------- | | Prototype, HMI integration, lab work | **A: Modbus runtime** | pyrung *is* the controller, running on a laptop or Pi, speaking Modbus TCP | | Production PLC, integrate with existing plant | **B: Click codegen** | pyrung translates to Click ladder CSVs; the PLC runs natively | | Standalone embedded, no PLC software | **C: CircuitPython** | pyrung transpiles to a Python scan loop on a P1AM-200 | These aren't mutually exclusive -- the same pyrung source can target all three. ## Option A: Connect via Modbus Your pyrung program runs on your laptop, a Raspberry Pi, or whatever -- and exposes its tags as a Modbus TCP server. Anything that speaks Modbus can connect and read or write tags as if pyrung were a real Click PLC. This covers several distinct use cases: 1. **HMI integration during development** -- connect a real HMI to your simulation, validate operator workflows before any hardware ships 1. **Soft-PLC in production** -- for non-safety-critical applications, pyrung *is* the runtime 1. **Hybrid systems** -- pyrung does the logic, a real PLC or I/O module handles field wiring via Modbus 1. **Hardware-in-the-loop testing** -- connect real sensors to a pyrung simulation that controls real outputs HMIs, SCADA systems, [ClickNick](https://github.com/ssweber/clicknick)'s Data View window, other PLCs, or your own scripts can connect and watch box counts climb, toggle between auto and manual, and press E-stop -- all from a real interface talking to your simulated conveyor. Modbus is a development protocol Modbus TCP is fine for development, monitoring, and HMIs. It's not a substitute for proper fieldbus protocols (EtherNet/IP, ProfiNet, EtherCAT) when you need deterministic timing or cybersecurity. Don't put Modbus on the open internet without a VPN. ## Option B: Map to a Click PLC ``` from pyrung.click import x, y, ds, TagMap, pyrung_to_ladder mapping = TagMap({ StartBtn: x[1], # Physical input terminal 1 StopBtn: x[2], # NC stop button EstopOK: x[3], # NC safety relay permission Auto: x[4], Manual: x[5], EntrySensor: x[6], DiverterBtn: x[7], Bin[1].Sensor: x[8], Bin[2].Sensor: x[9], ConveyorMotor: y[1], # Physical output terminal 1 DiverterCmd: y[2], StatusLight: y[3], }) mapping.validate(logic) # Check against Click constraints pyrung_to_ladder(logic, mapping, "conveyor/") # Export ladder CSV + nicknames ``` The `Bin[1].Sensor` mapping is the [Lesson 9](https://ssweber.github.io/pyrung/learn/structured-tags/index.md) UDT in action -- `.map_to()` works on structured tag fields the same way it works on flat tags. The validator is the bridge pyrung lets you write rich expressions because the simulator can handle them. Click can't. `mapping.validate(logic)` catches every gap between what you wrote and what your target can run, and tells you exactly what to fix. For example, pyrung lets you write `Rung(SizeReading + Offset > Threshold)` with math directly in the condition, but Click requires you to `calc` that into a separate tag first. The validator catches this. By the time `validate()` is clean, the codegen is guaranteed to produce something the PLC can run -- the same behavior as the simulator. `pyrung_to_ladder` generates a directory with one CSV per program, a nickname file for the tag table, and a manifest. [ClickNick](https://github.com/ssweber/clicknick)'s Guided Paste reads the manifest and walks you through importing each piece into Click Programming Software in the right order. **What doesn't port cleanly.** Every codegen target has limits. A few things the validator will flag: - Inline math in conditions (`SizeReading + Offset > Threshold`) -- must be a separate `calc` rung - Complex nested `Or`/`And` beyond Click's branch depth - `Real` precision differences -- Click uses 32-bit float; Python uses 64-bit - Timer/counter presets that exceed Click's range limits - `named_array` structures that don't fit Click's flat memory model without manual address assignment The validator teaches you which restrictions matter for *your* code. You don't have to learn Click's limits up front. For a full reference on memory banks, address mapping, and `named_array` patterns for Click, see the [Click Cheatsheet](https://ssweber.github.io/pyrung/guides/click-cheatsheet/index.md). ## Option C: Generate CircuitPython for a P1AM-200 ``` from pyrung.circuitpy import P1AM, generate_circuitpy hw = P1AM() inputs = hw.slot(1, "P1-08SIM") # 8-ch discrete input outputs = hw.slot(2, "P1-08TRS") # 8-ch discrete output source = generate_circuitpy(logic, hw, target_scan_ms=10.0) ``` **Same source, two runtimes.** The CircuitPython codegen produces a complete Python file with a scan loop, hardware initialization, and your logic -- ready to copy to a board's flash. Same conveyor sorting station you simulated, same tests you wrote, now running on a microcontroller with real Productivity1000 I/O. No PLC software, no proprietary editor, no licensing fees, no vendor lock-in. If you can write Python, you can deploy industrial control. ______________________________________________________________________ Hardware will surprise you Your simulation was deterministic. Your hardware is not. Sensor noise, contact bounce, ground loops, EMI, and mechanical chatter are real, and pyrung can't simulate them. When something works on the bench but misbehaves in the cabinet, you're back to oscilloscopes and multimeters. [Lesson 5](https://ssweber.github.io/pyrung/learn/timers/index.md)'s `on_delay` and [Lesson 4](https://ssweber.github.io/pyrung/learn/assignment/index.md)'s `rise()` are the building blocks for debounce filters -- the [Testing guide](https://ssweber.github.io/pyrung/guides/testing/#forces) covers patterns. The DAP debugger and forces work against a *running* pyrung program (Option A) -- but for Options B and C, your debugging tools are the vendor's: Click Programming Software's Data View, the P1AM-200's serial console. ## Exercise Run `mapping.validate(logic)` on the conveyor program. What does it complain about? Pick one complaint and fix it. If it comes back clean, try adding an intentional violation -- put math directly in a `Rung()` condition -- and verify the validator catches it. ## Where to go from here **Extend the conveyor.** Add an HMI screen via Modbus (Option A). Add Modbus comms to a weigh-scale so the sort threshold comes from real equipment. Add a recipe system using `named_array`. Each of these builds directly on what you already know. **Explore the broader PLC landscape.** You now have enough context to engage with: PackML for state-machine standardization ([Lesson 7](https://ssweber.github.io/pyrung/learn/state-machines/index.md) was the on-ramp), OPC UA for plant-floor connectivity, safety-rated controllers (Pilz, Sick, Banner) for real safety beyond what `EstopOK` demonstrates here, and IEC 61131-3 SFC for graphical state machines. None are pyrung features, but the mental model transfers. **Go deeper in pyrung.** The tutorial covered the core -- here's what's left: - [Data movement](https://ssweber.github.io/pyrung/instructions/copy/index.md): `copy`, `blockcopy`, `fill`, type conversion - [Math](https://ssweber.github.io/pyrung/instructions/math/index.md): `calc()`, overflow behavior, range sums - [Tag structures](https://ssweber.github.io/pyrung/guides/tag-structures/index.md): named arrays, cloning, field defaults, hardware mapping - [Drum sequencers, shift registers, search](https://ssweber.github.io/pyrung/instructions/drum-shift-search/index.md): advanced pattern instructions - [Subroutines and program control](https://ssweber.github.io/pyrung/instructions/program-control/index.md): `call`, `forloop`, multi-program structure - [Communication](https://ssweber.github.io/pyrung/instructions/communication/index.md): Modbus `send`/`receive` - [VS Code debugger](https://ssweber.github.io/pyrung/guides/dap-vscode/index.md): step through scans, set breakpoints on rungs, watch tags live - [Click PLC dialect](https://ssweber.github.io/pyrung/dialects/click/index.md): full hardware mapping and validation - [CircuitPython deployment](https://ssweber.github.io/pyrung/dialects/circuitpy/index.md): generate code for P1AM-200 The Zen of Ladder Try `import pyrung.zen`. - *The scan cycle is fast.* - *Rungs giveth power and taketh away.* - *And order has meaning.* - *But use order side effects sparingly.* - *One coil, one rung.* - *Latch only when needed.* - *If you need a FOR loop... no you don't.* - *Don't forget safety.* - *Keep it simple.* - *Test it.* - *Use clear tags and comments.* - *Name the purpose, not the part... unless you need a map to find it.* - *PackML and state machines are a honking great idea -- let's use more of those.* - *The tech (maybe you) at 3am will thank you.* ______________________________________________________________________ *Built with [pyrung](https://github.com/ssweber/pyrung). Write ladder logic in Python, simulate it, test it, deploy it.* # Lesson 3: Latch and Reset ## The Python instinct ``` if start_pressed: conveyor_running = True # But what turns it off? # And what if start_pressed goes False? ``` ## The problem In the real world, you press a momentary "Start" button. Your finger comes off. The conveyor should keep running. `out` won't work here because it de-energizes the moment the rung goes false. ## The ladder logic way ``` from pyrung import Bool, Program, Rung, PLC, latch, reset StartBtn = Bool("StartBtn") # NO momentary contact StopBtn = Bool("StopBtn") # NC contact: conductive at rest Running = Bool("Running") with Program() as logic: with Rung(StartBtn): latch(Running) # SET: Running = True, stays True with Rung(~StopBtn): reset(Running) # RESET when stop pressed or wire broken ``` `latch` is sticky. Once set, it stays set until explicitly `reset`. This is the bread and butter of motor control, alarm acknowledgment, and mode selection in every factory on earth. Why two rungs? Your Python instinct says "the rung went false, the output should drop." That's how `out` works. But `latch` isn't `out` — it sets the bit and *leaves it set*. After Start is pressed, `Running` stays true on its own. To clear it, you need a separate rung with `reset()`. If you only had the first rung, the motor would stop the instant you released Start — exactly the bug [Lesson 1](https://ssweber.github.io/pyrung/learn/scan-cycle/index.md) ended on. `StopBtn` is wired **normally-closed** — the circuit is conductive at rest, so the PLC input reads True when healthy. Writing `~StopBtn` means "this contact fires when the stop circuit opens" — button pressed, wire cut, or power lost. The reset rung is last because stop should always win (remember "last rung wins" from [Lesson 1](https://ssweber.github.io/pyrung/learn/scan-cycle/index.md)). What `~` actually means Your Python instinct reads `~StopBtn` as "not StopBtn" — a Boolean inversion. That's not what it is. In ladder logic, `~` declares the **contact type**: normally-closed (NC), conductive in its resting state. In a real ladder editor, `~` is drawn as `|/|` (NC), versus `| |` for normally-open (NO). Two different symbols, two different physical contact types — not "X" and "not X." Why does this matter? Because it composes naturally with how real devices are wired. Stop buttons, door interlocks, motor overload contacts, and level sensors are *typically wired NC* so that a wire break reads as "stop" instead of silently leaving the machine running. Every NC device on a real machine reads with a `~` in the rung — not because it's "alarmed" but because it's *physically wired* as normally-closed. Once you read `~` as "NC contact" instead of "not," ladder rungs start reading like wiring diagrams. Which is what they are. ``` latch(Running) reset(Running) Off -----------------------------> On ---------------------------> Off | | +-- StartBtn released? Still On.+ ``` ## Try it ``` with PLC(logic) as plc: StopBtn.value = True # NC input: True = healthy wiring StartBtn.value = True plc.step() assert Running.value is True StartBtn.value = False # Finger off the button plc.step() assert Running.value is True # Still running! StopBtn.value = False # Stop pressed (NC opens) plc.step() assert Running.value is False ``` ## A subtlety: rung order matters What if Start and Stop are both pressed at the same time? The answer: **the last rung to write wins.** Since `reset(Running)` is below `latch(Running)`, Stop wins. This is intentional — stop always wins. ## Labeling your rungs As programs grow, each rung benefits from a label. `comment()` attaches one to the next rung: ``` from pyrung import comment with Program() as logic: comment("Start the conveyor") with Rung(StartBtn): latch(Running) comment("Stop — NC contact resets when pressed or wire broken") with Rung(~StopBtn): reset(Running) ``` This isn't a Python `#` comment — it's rung metadata that travels with the program. When you export to a Click PLC, these appear above each rung in the ladder editor. From here on, we'll use `comment()` to label rungs as the logic gets more complex. By [Lesson 11](https://ssweber.github.io/pyrung/learn/hardware/index.md) you'll meet `EstopOK` — same NC wiring, different governance story. The wiring direction you're learning here is the easy part; the hard part is who *owns* the stop. ## Exercise Build a stop-blocks-start test: start the conveyor, then press stop. Verify it stops. Then verify that pressing Start while Stop is still held does NOT restart the conveyor. (Hint: you need `~StopBtn` to block the start, not just reset after it. Think about adding `~StopBtn` as a condition on the latch rung too.) ______________________________________________________________________ The conveyor runs and stops, but there's no tracking. When a box arrives, the system needs to record its size and keep a tally. That needs data movement -- `copy` and `calc`. Also known as... `latch` is called `SET`, `OTL`, or `S`; `reset` is `RST`, `OTU`, or `R`. You'll see a single-rung alternative in [Lesson 8](https://ssweber.github.io/pyrung/learn/branches/index.md) — the *seal-in rung*, which uses a self-holding branch instead of separate latch/reset. # Lesson 1: The Scan Cycle ## The Python instinct ``` # You'd write this if run_button: conveyor_motor = True else: conveyor_motor = False ``` This runs once. A PLC doesn't run once. It runs in a **scan cycle**, an infinite loop that evaluates every line of logic, top to bottom, hundreds of times per second. Always. Forever. Even when nothing is happening. ## Why? Because a PLC controls physical things. A conveyor belt doesn't stop needing instructions and a valve doesn't pause while you wait for user input. The machine is always running, so the logic is always running. ``` Read Inputs --> Execute Logic (top to bottom) --> Write Outputs --+ ^ | +------------------------------------------------------------+ ``` ## The ladder logic way ``` from pyrung import Bool, Program, Rung, PLC, out RunButton = Bool("RunButton") ConveyorMotor = Bool("ConveyorMotor") with Program() as logic: with Rung(RunButton): out(ConveyorMotor) ``` Read it aloud: "On this rung, if RunButton is true, energize ConveyorMotor." Every scan, this rung is evaluated, and `out` automatically makes the motor follow the rung's power state. No `if/else` needed. If you've seen ladder logic in a textbook or an editor, it looks something like this: ``` | RunButton ConveyorMotor | |--[ ]---------( )-------------| ``` The left rail is power. `[ ]` is a contact (condition). `( )` is a coil (output). If the contact closes, power flows through and the coil energizes. pyrung's `with Rung(RunButton): out(ConveyorMotor)` is the same thing expressed in Python. ## Try it ``` with PLC(logic) as plc: RunButton.value = True plc.step() # One scan assert ConveyorMotor.value is True RunButton.value = False plc.step() # Next scan assert ConveyorMotor.value is False # Motor follows button, every scan ``` ## Key concept: `out` is not assignment `ConveyorMotor = True` in Python sets a value once. `out(ConveyorMotor)` means "the motor follows this rung's power state, every single scan." Take your finger off the button, the conveyor stops. That's why `out` works this way -- in a factory, releasing the button *should* stop the machine. Last one wins If two rungs both `out` the same tag, the last one to execute wins — because the scan walks top to bottom and each `out` overwrites the previous value: ``` with Rung(SensorA): out(ConveyorMotor) # Rung 1 turns motor on with Rung(SensorB): out(ConveyorMotor) # Rung 2 overwrites — motor follows SensorB ``` There's a fix for this, and we'll get to it in [Lesson 8](https://ssweber.github.io/pyrung/learn/branches/index.md). ## Exercise Add an `EntrySensor` (Bool) and a `SensorLight` (Bool). Write a second rung where the sensor light comes on when the entry sensor detects a box. Test both rungs independently: the motor should follow the button, and the light should follow the sensor. Then test the "last one wins" trap from the callout above: add a *third* rung with an unconditional `out(ConveyorMotor)`. Before you run it, predict what happens when `RunButton` is true. Then test your prediction. ______________________________________________________________________ The motor and sensor work, but they're just on or off. What if we need to track a speed setpoint or trigger an alarm at a threshold? That requires typed tags. Also known as... `out()` is usually called `OUT` or `OTE`. A rung condition like `Rung(Tag)` is a "normally open contact" (`XIC`). `Rung(~Tag)` is a "normally closed contact" (`XIO`). If you Google any of those, you'll find the same thing in a different dialect. # Lesson 7: State Machines ## The Python instinct ``` state = "idle" while True: if state == "idle": if entry_sensor: state = "detecting" elif state == "detecting": read_size() time.sleep(0.5) state = "sorting" # ... ``` ## The ladder logic way State machines in ladder logic use a tag for the current state, timers for durations, and `copy` for transitions. No `while`, no `sleep`, no blocking. Here's the full sorting sequence: a box arrives, the system reads its size, positions the diverter, holds it open, then returns to idle. ``` IDLE --rise(Entry)--> DETECTING --0.5s--> SORTING --2s--> RESETTING --cleanup--> IDLE ``` ``` from pyrung import Bool, Int, Timer, Program, Rung, PLC from pyrung import comment, on_delay, copy, latch, reset, rise # State values as tag-constants — initialized once, never written IDLE = Int("IDLE", default=0) DETECTING = Int("DETECTING", default=1) SORTING = Int("SORTING", default=2) RESETTING = Int("RESETTING", default=3) State = Int("State") # Inputs EntrySensor = Bool("EntrySensor") SizeReading = Int("SizeReading") SizeThreshold = Int("SizeThreshold") # Internal IsLarge = Bool("IsLarge") DetTimer = Timer.clone("DetTimer") HoldTimer = Timer.clone("HoldTimer") with Program() as logic: comment("IDLE to DETECTING: box arrives") with Rung(State == IDLE, rise(EntrySensor)): copy(DETECTING, State) comment("DETECTING: read size for 0.5 seconds") with Rung(State == DETECTING): on_delay(DetTimer, preset=500) with Rung(State == DETECTING, SizeReading > SizeThreshold): latch(IsLarge) with Rung(DetTimer.Done): copy(SORTING, State) comment("SORTING: hold diverter for 2 seconds") with Rung(State == SORTING): on_delay(HoldTimer, preset=2000) with Rung(HoldTimer.Done): copy(RESETTING, State) comment("RESETTING: clean up and return to idle") with Rung(State == RESETTING): reset(IsLarge) copy(IDLE, State) ``` Each state has a small group of rungs: one to run its timer or check its condition, one to handle the transition. Clean, readable, testable. The state values are **tag-constants** — `Int` tags initialized once and never written. Your Python instinct says `Enum`; the ladder answer is "constants are tags." They live in the PLC's tag table, visible to anyone who opens the project — better documentation than a Python comment because they travel with the project file. Once you've seen `@named_array` in [Lesson 9](https://ssweber.github.io/pyrung/learn/structured-tags/index.md), you can package these as a read-only structure — one declaration, a dropdown in the debugger. Same idea, less repetition. A few things to notice in the code: - **`rise(EntrySensor)`** — remember [Lesson 4](https://ssweber.github.io/pyrung/learn/assignment/index.md)? Without it, the IDLE→DETECTING transition fires every scan the sensor sees a box, not just the first. - **`State == DETECTING` repeats across three rungs.** In Python you'd write one `if` and nest. In ladder, each rung stands alone — independently editable, grep-able, and deletable. The maintenance tech at 3am searching for `DETECTING` finds every rung that participates. - **We never reset `DetTimer`.** Once `State` leaves DETECTING, the `on_delay` rung goes false and the TON auto-resets — `DetTimer.Done` clears on its own. That's the [TON behavior from Lesson 5](https://ssweber.github.io/pyrung/learn/timers/index.md). - **`IsLarge` crosses states.** It's latched in DETECTING and reset in RESETTING. [Lesson 8](https://ssweber.github.io/pyrung/learn/branches/index.md) reads it in the diverter output rung. Latches outlive rungs — they're how a state machine carries data between states without globals or context objects. ## Try it ``` with PLC(logic, dt=0.010) as plc: State.value = 0 SizeThreshold.value = 100 # Box arrives -- large box EntrySensor.value = True SizeReading.value = 150 plc.step() assert State.value == 1 # DETECTING # Wait for detection period (0.5s = 50 scans) plc.run(cycles=50) assert State.value == 2 # SORTING assert IsLarge.value is True # Classified as large # Wait for hold period + pass through RESETTING (2s = 200 scans) plc.run(cycles=200) assert State.value == 0 # Back to IDLE assert IsLarge.value is False # Cleaned up in RESETTING ``` RESETTING is a **pass-through state** — it transitions to IDLE in the same scan. That's fine; its job is to clean up (`reset(IsLarge)`, `copy(IDLE, State)`), and cleanup doesn't need to wait. If you want to observe it, use `runner.monitor(State, callback)` — it fires on every committed change, including mid-cycle transitions. ## Exercise Add an error state (`4`). If the entry sensor stays active for more than 5 seconds during the detecting phase (the box is jammed), transition to state `4` (error) and turn on a `JamAlarm`. The jam clears only when the sensor goes false AND an operator presses an `AckButton`. Test both the jam path and the normal path. ______________________________________________________________________ > If you're a visual person, this is a good time to set up the [VS Code debugger](https://ssweber.github.io/pyrung/guides/dap-vscode/index.md). From here on, the logic gets complex enough that stepping through scans and watching tags update live can be more useful than reading assertions. The sorting sequence works in one mode. But a real conveyor has auto mode (runs the sequence) and manual mode (operator controls the diverter directly). That's OR logic and branches. Also known as... State machines in ladder are almost always hand-rolled using an Int tag plus comparison contacts, or built on a dedicated sequencer instruction (`SQO`, `DRUM`). IEC 61131-3 has Sequential Function Chart (SFC) as a first-class language for this. For standardized state models, search for **PackML** — it defines ~17 states that any operator recognizes. # Lesson 9: Structured Tags and Blocks ## The Python instinct ``` @dataclass class Bin: sensor: bool = False count: int = 0 full: bool = False bins = [Bin(), Bin()] size_log = [0] * 5 ``` Python has dataclasses for structured records and lists for arrays. Ladder logic has both too, but they map to fixed regions of PLC memory. ## UDTs Up to now, each bin had its own separate tags: `BinASensor`, `BinAAcc`, `BinBSensor`, `BinBAcc`. That's fine for two bins, but it doesn't scale -- and it doesn't match how real plants are organized. Identical equipment should use identical structures. Remember the doubled name from [Lesson 2](https://ssweber.github.io/pyrung/learn/tags/index.md) — `ConveyorSpeed = Int("ConveyorSpeed")`? It's gone. pyrung generates the flat identity from the structure: `Bin[1].Sensor` is the Python access path to a tag whose real identity is `Bin1_Sensor`. On Click that's a flat nickname; on Rockwell it's a real UDT member. Your Python stays the same either way. ``` from pyrung import udt, Bool, Counter, Program, Rung, PLC, out, rise, count_up @udt(count=2) class Bin: Sensor: Bool Full: Bool BinACounter = Counter.clone("BinACounter") BinBCounter = Counter.clone("BinBCounter") CountReset = Bool("CountReset") with Program() as logic: with Rung(rise(Bin[1].Sensor)): count_up(BinACounter, preset=10) \ .reset(CountReset) with Rung(rise(Bin[2].Sensor)): count_up(BinBCounter, preset=10) \ .reset(CountReset) with Rung(BinACounter.Done): out(Bin[1].Full) with Rung(BinBCounter.Done): out(Bin[2].Full) ``` `@udt(count=2)` creates two instances, accessed by index. `Bin[1].Sensor` and `Bin[2].Sensor` are distinct tags, but they share the same structure. Counters are separate — `Counter` is a built-in UDT, so you don't embed its fields in yours. This maps directly to how real plants are organized: identical equipment, replicated logic, consistent naming. Yes, the `Bin[1]` and `Bin[2]` rungs look nearly identical. Your Python instinct says "loop." Resist it. Each rung is independently editable, grep-able, and visible in the ladder editor. When Bin 2 needs a different preset or an extra condition, you edit one rung — you don't fight a loop. Duplication in ladder logic is a feature, not a smell. That said, Python `for` loops work fine at build time — `for i in (1, 2): with Rung(rise(Bin[i].Sensor)): ...` emits two distinct rungs into the program. pyrung doesn't forbid it; it's just normal Python running during program construction. But explicit rungs are usually more readable, especially once the bins diverge. A singleton UDT (`count` omitted or `count=1`) generates compact names with no instance number: `Motor_Running`, `Motor_Speed`. With `count > 1` you get numbered names: `Pump1_Running`, `Pump2_Running`. If your naming convention wants `Motor1_Running` even for a singleton (so future expansion doesn't rename everything), pass `always_number=True`. ``` Bin (UDT, count=2) Counter (built-in) SortLog (Block, Int, 1-5) +-- .Sensor : Bool +-- .Done : Bool +-- [1] : Int +-- .Full : Bool +-- .Acc : Dint +-- [2] : Int +-- [3] : Int +-- [4] : Int +-- [5] : Int ``` PLC arrays start at 1 `Bin[1]`, not `Bin[0]`. Every PLC vendor in the world is 1-indexed and pyrung honors that because the tag table you generate has to match the PLC's. Your Python instinct will betray you here exactly once. If you specifically need 0-based addressing (matching a 0-based hardware range or porting code), Blocks accept a 0-based start — but the default is 1. When all fields share the same type (like a group of Int fields for one sensor), pyrung also offers `named_array`, which maps to contiguous memory and supports bulk operations. See the [Tag Structures guide](https://ssweber.github.io/pyrung/guides/tag-structures/index.md) for details. ## Read-only structures The state constants from [Lesson 7](https://ssweber.github.io/pyrung/learn/state-machines/index.md) — four `Int` tags, initialized once, never written — are really a read-only named array: ``` @named_array(Int, stride=4, readonly=True) class SortState: IDLE = 0 DETECTING = 1 SORTING = 2 RESETTING = 3 State = Int("State", choices=SortState) ``` `SortState.IDLE` is a tag — use it anywhere: `State == SortState.IDLE`, `copy(SortState.DETECTING, State)`. The decorator's `readonly=True` applies to every field; constants show a read-only badge (**RO**) in the debugger and are locked from editing by default. `choices=SortState` tells the Data View to show a labeled dropdown instead of a raw number — `SORTING (2)` instead of `2`. Selecting a choice writes the value immediately. Tags can also be marked `public=True` to indicate they're part of the operator-facing API. The Data View shows a **P** badge next to public tags and provides a filter checkbox to hide everything else. See [Tag Structures — Tag flags](https://ssweber.github.io/pyrung/guides/tag-structures/#tag-flags) for all available flags. ## Blocks When you need an array of same-typed tags rather than a structured record, a `Block` gives you a contiguous range you can index into and operate on in bulk. Here's a sort log that records the last 5 box sizes: ``` from pyrung import Block, TagType, copy, blockcopy SortLog = Block("SortLog", TagType.INT, 1, 5) # SortLog1..SortLog5 BoxSize = Int("BoxSize") NewBox = Bool("NewBox") with Program() as logic: # (bin counting rungs from above...) # Log box sizes: shift register pattern with Rung(rise(NewBox)): blockcopy(SortLog.select(1, 4), SortLog.select(2, 5)) # Shift down copy(BoxSize, SortLog[1]) # Insert at front ``` `SortLog.select(1, 4)` gives you SortLog1 through SortLog4 as a range, and `blockcopy` moves the whole thing in one instruction. The oldest value in SortLog5 falls off the end. This is a **shift register** — the canonical FIFO pattern in ladder logic, with dedicated instructions on every platform (`BSL`/`BSR` on Rockwell, `SHIFT` on Click and Do-More). pyrung uses `blockcopy` over `select` for the same effect: no loops, no index arithmetic. Why `.select(1, 4)` instead of `[1:4]`? Python's `list[1:4]` is `[1, 2, 3]` — exclusive end. PLC ranges like `DS1..DS4` are inclusive on both ends — `[1, 2, 3, 4]`. Reusing slice syntax would silently do the wrong thing exactly half the time. `.select(start, end)` is visibly different because the semantics are different. Both bounds are inclusive, every time. ## Try it ``` with PLC(logic) as plc: # 3 boxes into Bin 1 for _ in range(3): Bin[1].Sensor.value = True plc.step() Bin[1].Sensor.value = False plc.step() assert BinACounter.Acc.value == 3 assert BinBCounter.Acc.value == 0 # Bin 2 untouched assert Bin[1].Full.value is False # Log 3 box sizes for size in [150, 80, 200]: BoxSize.value = size NewBox.value = True plc.step() NewBox.value = False plc.step() # Newest first assert SortLog[1].value == 200 assert SortLog[2].value == 80 assert SortLog[3].value == 150 ``` ## Going deeper The [Tag Structures guide](https://ssweber.github.io/pyrung/guides/tag-structures/index.md) covers the full API. Two features worth knowing early: - **`Field()`** — override defaults or retentive policy per field: `id: Int = Field(default=100, retentive=True)` - **`@named_array`** — like `@udt` but all fields share one type. Use UDT for mixed types, named_array for same-typed records The guide also covers cloning, stride, hardware mapping, and per-instance sequences. ## Exercise Add a singleton `Conveyor` UDT with fields for `Running` (Bool), `Speed` (Int), and `MotorFault` (Bool). Write logic where the conveyor stops when `MotorFault` is true, regardless of the running state. Use `fill` to add a "clear log" function: when a `ClearLog` button is pressed, fill the SortLog with zeros. (Hint: see the [Data Movement reference](https://ssweber.github.io/pyrung/instructions/copy/index.md) for `fill`.) ______________________________________________________________________ The logic is complete. Now prove it works -- write a test suite that covers the normal cycle, the fault path, the mode switch, and the edge cases. Also known as... Structured tags are UDTs or `STRUCT`s. Flat-namespace PLCs fake it with underscore prefixes — exactly what pyrung generates as the flat identity. Block-copy, shift-register, and fill all have dedicated instructions on every platform. # Lesson 2: Tags ## The Python instinct ``` conveyor_speed: int = 0 ``` Python's type hint tells you it's an integer. It doesn't tell you it's 16-bit signed, non-retentive, or mapped to a specific region of physical memory. ## The ladder logic way ``` from pyrung import Bool, Int, Real ConveyorSpeed = Int("ConveyorSpeed") # 16-bit signed integer, in mm/s SpeedLimit = Int("SpeedLimit") # Alarm threshold Temperature = Real("Temperature") # 32-bit float ``` The name appears twice: the Python variable is how *you* reference the tag in code; the string is the tag's identity in PLC memory — it's what HMIs and tag exports see. They're allowed to differ, but matching them avoids confusion. This duplication goes away in [Lesson 9](https://ssweber.github.io/pyrung/learn/structured-tags/index.md), where UDT member names *are* the tag strings. Tags are typed and sized. You can't put a float in a Bool or store a negative number in an unsigned Word. This reflects real PLC hardware where each tag maps to a specific region of memory with a fixed width. A note on naming Tag names in this guide use `TitleCase` (e.g. `ConveyorRunning`), not Python's `snake_case`. Two reasons: 1. **It matches PLC convention** — what you'll see in every vendor's projects. 1. **Characters are a budget.** Most PLCs cap tag names at 16–40 characters. `EStopPressed` fits everywhere; `e_stop_pressed` might not. On flat-namespace PLCs like Click, underscores group related tags into a pseudo-namespace (`Bin1_Count`, `Bin1_Full`) that becomes a real UDT member (`Bin1.Count`) on platforms with structures. More on that in [Structured Tags and Blocks](https://ssweber.github.io/pyrung/learn/structured-tags/index.md). ``` Tag Types +-- Bool -- 1-bit on/off +-- Int -- 16-bit signed +-- Dint -- 32-bit signed +-- Real -- 32-bit float +-- Word -- 16-bit unsigned +-- Char -- text string ``` ## Retentive vs non-retentive When a PLC goes through a STOP→RUN cycle (like a reboot), **retentive** tags keep their values and **non-retentive** tags reset to defaults. There's no Python analog — every Python variable is "retentive" until the process exits. Bool tags are non-retentive by default: your outputs start in a known safe state. Int, Real, and others are retentive: your production counter doesn't reset to zero every time someone power-cycles the machine. This matters because a control engineer's first question about any tag is "what happens on power-up?" ## Setting values from outside the program The program (your rungs) reads and writes tags through instructions. But you also need to set values from *outside* the program, the way an operator would type a setpoint into an HMI. In pyrung, that's `with PLC(logic) as plc:` — inside it, you read and write tag values and call `plc.step()` or `plc.run()` to execute scans. ``` from pyrung import Bool, Int, Program, Rung, PLC, out ConveyorSpeed = Int("ConveyorSpeed") SpeedLimit = Int("SpeedLimit") OverSpeed = Bool("OverSpeed") with Program() as logic: with Rung(ConveyorSpeed > SpeedLimit): out(OverSpeed) with PLC(logic) as plc: SpeedLimit.value = 500 # Like typing into a dataview ConveyorSpeed.value = 300 plc.step() assert OverSpeed.value is False ConveyorSpeed.value = 600 # Speed exceeds limit plc.step() assert OverSpeed.value is True # Program reacts on the next scan ``` `ConveyorSpeed.value = 600` happens outside the program, before the scan. The program sees the new value when it runs and reacts accordingly. This is the same relationship an operator has with a real PLC: they set inputs and parameters, the logic does the rest. ## Exercise Add a `BoxWeight` (Real) tag and a `WeightLimit` (Real). Write a rung that energizes a `HeavyBox` alarm when weight exceeds the limit. Test with values below and above the threshold. Then test the boundary: what happens when `BoxWeight` exactly equals `WeightLimit`? Does your rung use `>` or `>=`? Make sure it does what you intend -- in a real plant, that boundary is the difference between a nuisance alarm and a missed overweight. ______________________________________________________________________ The motor turns on and off with the button, but in a real factory you press Start and walk away. The motor needs to stay running after you release the button. That's latch and reset. Also known as... `Bool` tags are called control relays, `C` bits, or `X`/`Y` for I/O. `Int` is a 16-bit signed type almost everywhere. `Real` is a 32-bit float. "Retentive" is universal — it's a tag's ability to survive a power cycle or STOP→RUN transition. # Lesson 10: Testing If you know pytest, you already know how to test pyrung. No `plc-test` framework to learn, no proprietary test runner, no XML config. Standard pytest fixtures and asserts. This is where pyrung pays for itself. Everything you've built -- the motor control, the sorting sequence, the bin counters, the mode switching -- is testable with standard pytest. No hardware, no manual verification, no "download and hope." ``` import pytest from pyrung import PLC @pytest.fixture def plc(): r = PLC(logic, dt=0.010) r.force(StopBtn, True) # NC inputs: healthy wiring r.force(EstopOK, True) r.force(Auto, True) # Default to auto mode return r ``` Remember the `dt=0.010` determinism from [Lesson 5](https://ssweber.github.io/pyrung/learn/timers/index.md)? This is what it was for. Every test in this lesson runs in deterministic, reproducible scan time -- no flaky tests, no timing race conditions, no "works on my machine." One scan, one tick, every time. Each test gets its own `PLC` because pytest's default scope is `function` -- no state accumulates between tests. All tag I/O and stepping happens inside the context manager: ``` def test_start_stop(plc): with plc: StartBtn.value = True plc.step() StartBtn.value = False plc.step() assert Running.value is True assert ConveyorMotor.value is True def test_estop_overrides_start(plc): """Safety: E-stop kills everything, even if Start is held.""" plc.unforce(EstopOK) with plc: EstopOK.value = False StartBtn.value = True plc.step() assert Running.value is False assert ConveyorMotor.value is False ``` ## Fork for parallel scenarios Impossible on real hardware You can't fork a real conveyor. You can't pause a real PLC, copy its state, and run two futures in parallel from the same instant. With pyrung you can. This is how you test "what if the part is large vs small," "what if the operator hits stop now vs in 100 ms," "what if the network packet arrives before vs after the sensor edge." Two assertions from one starting point, no setup duplication, no flakiness. Test two outcomes from the same starting point without resetting: ``` Setup: start conveyor, box arrives at sensor | runner.fork() +---+---+ v v SizeReading SizeReading = 150 = 50 | | v v DiverterCmd DiverterCmd = True = False ``` ``` def test_small_vs_large_box(plc): """Same setup, two outcomes.""" with plc: SizeThreshold.value = 100 StartBtn.value = True plc.step() EntrySensor.value = True plc.step() # Fork: large box — run past detection, check mid-sorting large = plc.fork() large.force(SizeReading, 150) with large: large.run(cycles=50) assert State.value == 2 # SORTING assert DiverterCmd.value is True # Fork: small box small = plc.fork() small.force(SizeReading, 50) with small: small.run(cycles=50) assert State.value == 2 assert DiverterCmd.value is False ``` `fork()` branches state *mid-test* from a shared dynamic starting point. For testing the *whole* test with different starting conditions, use `pytest.mark.parametrize`: ``` @pytest.mark.parametrize("box_size,expected_diverter", [ (50, False), # small (150, True), # large (99, False), # boundary, just under (100, False), # boundary, exactly at threshold (101, True), # boundary, just over ]) def test_box_classification(plc, box_size, expected_diverter): with plc: SizeThreshold.value = 100 StartBtn.value = True plc.step() plc.force(EntrySensor, True) plc.force(SizeReading, box_size) plc.run(cycles=55) # Past detection, mid-sorting assert DiverterCmd.value is expected_diverter ``` The kind of boundary testing that's agonizing on real hardware -- load 5 specific test boxes, push them through by hand -- and trivial in pyrung. ## History for post-mortem debugging Also impossible on real hardware Real PLC software has trends and trace buffers, but they're sampled and lossy. pyrung's history is **every scan, every tag, immutable, indexable**. This is post-mortem debugging -- the alarm fired, you have the complete record, and you can walk backwards until you find the cause. ``` plc.run(cycles=100) assert JamAlarm.value is True # Why did the jam fire? Walk back through scans: for i in range(-1, -10, -1): snapshot = plc.history[i] print(f"scan {i}: State={snapshot[State]} EntrySensor={snapshot[EntrySensor]}") ``` ## Driving signals: values, forces, and patches Three ways to set a tag's value, each with different persistence: | Mechanism | Persistence | Use case | | ------------------------------------ | ------------------------ | ----------------------------------------------------- | | `tag.value = X` (inside `with plc:`) | one scan | Setting an initial value, simulating a one-shot input | | `plc.force(tag, X)` | persistent until removed | Holding a sensor on across many scans | | `plc.unforce(tag)` | releases the force | Letting the logic see the computed value again | Forces are how the sorting test above keeps `EntrySensor` on across 55+ scans without re-setting it every cycle: ``` def test_sorting_sequence(plc): """Full auto sort: box arrives, gets classified, exits to correct bin.""" with plc: SizeThreshold.value = 100 StartBtn.value = True plc.step() plc.force(EntrySensor, True) plc.force(SizeReading, 150) # Large box # Run past detection period into sorting plc.run(cycles=55) assert DiverterCmd.value is True # Extended for large box plc.unforce(EntrySensor) plc.run(cycles=250) # Past hold period assert DiverterCmd.value is False # Retracted after sort assert State.value == 0 # Back to idle ``` Forces deserve respect Forcing is a real debugging feature on every PLC platform. On real hardware, forces override the program's control of physical outputs and bypass safety interlocks — that's why real PLCs gate force mode behind confirmation dialogs. When you `force()`, you are telling the engine "ignore whatever the logic computes for this tag." Use forces for testing. Treat them with the same caution you'd give the real thing. ## When tests aren't enough Sometimes you need to watch logic execute step by step. pyrung includes a VS Code debugger that lets you set breakpoints on individual rungs, pause *between* rungs within a single scan, watch tag values update live, and force overrides from the debug console. Real PLC editors show you live rung state, but they can't stop the scan partway through -- the whole program executes as one atomic pass. pyrung can. See the [DAP Debugger guide](https://ssweber.github.io/pyrung/guides/dap-vscode/index.md) for setup. ## Exercise Write a test that covers the full conveyor lifecycle: start in auto mode, sort a large box (verify diverter extends and Bin B counter increments), sort a small box (verify diverter stays retracted and Bin A counter increments), then E-stop mid-sort and verify everything shuts down cleanly. Use `fork()` to test the large and small paths from a shared starting point. ______________________________________________________________________ The logic is tested, the tests pass. Now deploy it. Also known as... `plc.step()` is "single scan" — some PLC simulators expose it, many don't. `force`/`unforce` mirror the universal Force On/Off feature. `history[-N]` is like a trend or data log, except trends are sampled and lossy. `fork()`, deterministic `dt` time, and full-scan history have **no equivalent on real PLCs**. # Lesson 5: Timers ## The Python instinct ``` import time diverter_open = True time.sleep(2) # Block for 2 seconds while the box passes diverter_open = False ``` ## Why that's wrong here A PLC can't sleep. It has to keep scanning because sensors are still reading, safety interlocks are still being checked, and other rungs still need to execute. Blocking is not an option when you're controlling physical equipment. ## The ladder logic way Timers **accumulate** across scans: every scan where the rung is true, the timer adds a little more time, and when the accumulator reaches the preset, it fires. ``` Each scan: Rung true? --yes--> Acc += elapsed --> Acc >= Preset? --yes--> Done = True | | no no v v Acc resets to 0 keep timing ``` The diverter gate needs to stay open for 2 seconds while a box passes through. Here's how: ``` from pyrung import Bool, Timer, Program, Rung, PLC, on_delay, out EntrySensor = Bool("EntrySensor") DiverterCmd = Bool("DiverterCmd") HoldTimer = Timer.clone("HoldTimer") with Program() as logic: with Rung(EntrySensor): on_delay(HoldTimer, preset=2000) # 2 seconds with Rung(EntrySensor, ~HoldTimer.Done): out(DiverterCmd) # Hold diverter open while timing ``` This reads: "While the entry sensor sees a box, accumulate time. While the sensor is active and the timer hasn't finished, keep the diverter open." After 2 seconds, `HoldTimer.Done` goes true, `~HoldTimer.Done` goes false, and the diverter closes. If the sensor goes false early, the timer resets (that's `on_delay` / TON behavior). `Timer` is a built-in structured type with two fields: `.Done` (Bool) fires when the accumulator reaches the preset, and `.Acc` (Int) tracks elapsed time. `Timer.clone("HoldTimer")` creates a named timer clone — in the PLC tag table, that expands to `HoldTimer_Done` and `HoldTimer_Acc`. You'll see the same two-field model again with counters in the next lesson. Name your timers For real programs deploying to hardware, always use `Timer.clone("Name")`. When `Timer1_Done` shows up in a fault log six months later, it tells you nothing. `HoldTimer_Done` tells you everything. `Timer[n]` (anonymous, auto-numbered) is fine for throwaway simulation tests — but named instances are the 95% case. Unit aliases `unit=` accepts `"ms"`, `"sec"`, `"min"`, `"hour"`, `"day"` and their variants. Also accepted: `Tms`/`Ts`/`Tm`/`Th`/`Td` — great for tag names (`FillTimeTm` stays short, and `Tm` sidesteps the minute-vs-minimum ambiguity of `Min`). ## Test it deterministically ``` with PLC(logic, dt=0.010) as plc: EntrySensor.value = True plc.run(cycles=199) # 1.99 seconds assert DiverterCmd.value is True # Diverter still held open plc.step() # 2.00 seconds assert DiverterCmd.value is False # Released -- box has passed ``` `dt=0.010` advances the clock by exactly 10 ms each scan. No wall clock. Perfectly deterministic. In pytest you'd reach for `freezegun` or monkeypatch `time.time` — pyrung bakes determinism in because PLC time *is* the scan clock. This is why pyrung exists. Try writing this test against real hardware. ## Retentive on-delay The example above is a TON — it auto-resets when the rung goes false. What if you need the timer to *keep* its progress across rung-false cycles? That's a retentive on-delay (RTON). In pyrung, there's no separate instruction — chain `.reset()` and the behavior changes: ``` # TON — auto-resets when rung goes False on_delay(HoldTimer, preset=2000) # RTON — holds accumulator across rung-false; # only the explicit reset clears it on_delay(BatchTimer, preset=3600, unit="sec") \ .reset(BatchReset) ``` Without `.reset()`, the timer clears its accumulator the moment the rung drops — that's TON. With `.reset()`, the timer holds its accumulator and only clears when the reset condition fires — that's RTON. Same instruction, mode determined by the chain. This chained-builder pattern returns in [Lesson 6](https://ssweber.github.io/pyrung/learn/counters/index.md) with counters. Why is `.reset()` terminal? In most ladder editors, the reset input on a retentive timer is its own wire — you can power it from the rail with completely independent conditions. That flexibility makes rungs hard to read: reset logic *looks* tied to the main rung when it isn't. pyrung makes `.reset()` terminal so the syntax matches the semantics — conditions inside `.reset(...)` belong to the reset, not the rung. If you need more instructions after, write a separate rung. Counters use the same pattern. ## Exercise Build a startup delay: after pressing Start, the conveyor waits 3 seconds before the motor turns on (safety: gives workers time to clear the area). Test both paths: the full 3-second wait, and releasing Start early (timer resets, motor never starts). ______________________________________________________________________ The diverter holds long enough for one box. But how many boxes have gone to each bin? We need to count sensor edges without looping. Also known as... On-delay is `TON`; off-delay is `TOF`; retentive on-delay is `RTO`. The done bit is `.DN` or `.Q`; the accumulator is `.ACC` or `.ET`. # Getting Started # Core Concepts This page covers the vocabulary you need to write and understand a pyrung program. For engine internals and architecture, see [Architecture](https://ssweber.github.io/pyrung/guides/architecture/index.md). ## 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](https://ssweber.github.io/pyrung/instructions/index.md). ## 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](https://ssweber.github.io/pyrung/guides/click-cheatsheet/#system-points) 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](https://ssweber.github.io/pyrung/guides/physical-harness/index.md). ## Next steps - [Quickstart](https://ssweber.github.io/pyrung/getting-started/quickstart/index.md) — build and test a traffic light - [Instruction Reference](https://ssweber.github.io/pyrung/instructions/index.md) — full instruction reference - [Testing Guide](https://ssweber.github.io/pyrung/guides/testing/index.md) — patterns for deterministic testing - [Architecture](https://ssweber.github.io/pyrung/guides/architecture/index.md) — engine internals, scan phases, SystemState # Installation ## Requirements - Python 3.12 or later ## Install ``` pip install pyrung ``` ``` uv add pyrung ``` ``` uv sync --group dev ``` ## Verify ``` from importlib.metadata import version from pyrung import PLC, Program print("pyrung", version("pyrung")) print("imports ok:", PLC, Program) ``` ## Optional: Click dialect `pyrung.click` is available in the base install. It depends on `pyclickplc` for Click address metadata, nickname CSV I/O, and Modbus server/client support. ## Development install Clone the repository and install in editable mode: ``` git clone https://github.com/ssweber/pyrung cd pyrung uv sync --group dev --group docs make # lint + test ``` # Quickstart Build a traffic light that cycles green → yellow → red, then test it. ## Install ``` # Requires Python 3.12+ pip install -e . ``` ## Your first program A traffic light is a timer-driven state machine. Each phase runs for a set duration, then transitions to the next. ``` from pyrung import Char, Timer, Program, Rung, copy, on_delay # State holds the current phase: "g", "y", or "r" State = Char("State") # Each phase gets a named timer GreenTimer = Timer.clone("GreenTimer") YellowTimer = Timer.clone("YellowTimer") RedTimer = Timer.clone("RedTimer") with Program() as logic: # Green for 3 seconds, then yellow with Rung(State == "g"): on_delay(GreenTimer, preset=3000) with Rung(GreenTimer.Done): copy("y", State) # Yellow for 1 second, then red with Rung(State == "y"): on_delay(YellowTimer, preset=1000) with Rung(YellowTimer.Done): copy("r", State) # Red for 3 seconds, then green with Rung(State == "r"): on_delay(RedTimer, preset=3000) with Rung(RedTimer.Done): copy("g", State) ``` Read it like a ladder diagram: `with Rung(State == "g")` is the condition on the left rail. If it's true, power flows into the body — `on_delay` starts accumulating. When the timer hits its preset, `GreenTimer.Done` goes true, and the next rung copies a new state. ## Run it ``` from pyrung import PLC with PLC(logic, dt=0.010) as plc: State.value = "g" # Run for 10 seconds (1,000 scans × 10 ms) plc.run(cycles=1000) assert State.value == "g" # Back to green — it's been through a full cycle ``` `dt=0.010` advances simulation time by exactly 10 ms per scan. No wall-clock dependency, perfectly repeatable. ## Test it This is the point of pyrung. Put the same logic in a pytest file and make assertions: ``` from pyrung import PLC def test_traffic_light_cycle(): with PLC(logic, dt=0.010) as plc: State.value = "g" # Green phase lasts 3 seconds = 300 scans plc.run(cycles=299) assert State.value == "g" # Still green plc.step() assert State.value == "y" # Just turned yellow # Yellow lasts 1 second = 100 scans plc.run(cycles=100) assert State.value == "r" # Now red # Red lasts 3 seconds = 300 scans plc.run(cycles=300) assert State.value == "g" # Full cycle, back to green ``` Same logic, deterministic timing, real assertions. If this passes, the logic is correct — before it ever touches hardware. ## What's happening under the hood Each call to `plc.step()` executes one complete scan cycle: evaluate every rung top to bottom, update all outputs, produce an immutable state snapshot. `plc.run(cycles=N)` just calls `step()` N times. State snapshots are immutable — the runner keeps a history you can inspect, diff, or rewind to. Tags like `State.value` read from the runner's current state when inside the `with PLC(...) as plc:` block. Timers accumulate across scans. `on_delay` with a 3000 ms preset and a 10 ms scan step needs 300 scans to fire. That's why the math is exact and the tests are deterministic. ## Next steps The [full traffic light example](https://github.com/ssweber/pyrung/blob/main/examples/traffic_light.py) builds on this with structured tags (`@udt`), car counting with edge detection, and a speed history log using `blockcopy`. From here: - [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md) — the scan cycle, SystemState, and how tags work - [Instruction Reference](https://ssweber.github.io/pyrung/instructions/index.md) — full DSL reference: branching, counters, subroutines, structured tags - [Testing Guide](https://ssweber.github.io/pyrung/guides/testing/index.md) — patterns for unit testing with deterministic time - [Runner Guide](https://ssweber.github.io/pyrung/guides/runner/index.md) — time modes, scan history, forking # Instruction Reference # Instruction Reference For the scan model and DSL vocabulary, start with [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). This section is the reference for writing rungs. ``` with Rung(Start, ~Fault): out(MotorRunning) on_delay(RunDelay, preset=500) ``` Read it like a ladder diagram: conditions go on `Rung(...)`, instructions go in the body. Use the pages below by instruction family. ## Start here - [Rungs](https://ssweber.github.io/pyrung/instructions/rungs/index.md) — comments, branching, source order, and how power flows through a rung - [Conditions](https://ssweber.github.io/pyrung/instructions/conditions/index.md) — contacts, comparisons, AND/OR logic, and edge detection - [Coils](https://ssweber.github.io/pyrung/instructions/coils/index.md) — `out`, `latch`, `reset`, and immediate I/O ## Data movement - [Data Movement](https://ssweber.github.io/pyrung/instructions/copy/index.md) — `copy`, `blockcopy`, `fill`, converters, `pack_*`, and `unpack_*` - [Math](https://ssweber.github.io/pyrung/instructions/math/index.md) — `calc()`, overflow behavior, division rules, and range sums ## Time and control - [Timers & Counters](https://ssweber.github.io/pyrung/instructions/timers-counters/index.md) — `on_delay`, `off_delay`, `count_up`, and `count_down` - [Drum, Shift & Search](https://ssweber.github.io/pyrung/instructions/drum-shift-search/index.md) — step sequencers, shift registers, and range search - [Program Control](https://ssweber.github.io/pyrung/instructions/program-control/index.md) — `Program`, subroutines, `call`, and `forloop` ## Communication - [Communication](https://ssweber.github.io/pyrung/instructions/communication/index.md) — `send`, `receive`, Modbus targets, and status tags ## Exact signatures If you want signatures and parameters instead of examples, use the [Instruction Set API](https://ssweber.github.io/pyrung/reference/api/instruction-set/index.md). For the rest of the generated API surface, start at the [API Reference](https://ssweber.github.io/pyrung/reference/index.md). # Coils For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## `out` — energize output ``` with Rung(Button): out(Light) # Light = True while rung is True; False when rung is False with Rung(Button): out(Light, oneshot=True) # True for ONE scan on rung rising edge, then False ``` `out` follows rung power: True when rung is True, False when False. Last rung to write a tag wins within a scan. With `oneshot=True`, the output is True for only one scan on the rung's rising edge. ## `latch` — set and hold (SET) ``` with Rung(Start): latch(Motor) # Motor becomes True and stays True until reset ``` ## `reset` — clear latch (RESET) ``` with Rung(Stop): reset(Motor) # Motor becomes False ``` ## Immediate I/O For `InputTag` / `OutputTag` elements (from `InputBlock` / `OutputBlock`), `.immediate` bypasses the scan-cycle image table: ``` with Rung(SensorA.immediate): out(ValveB.immediate) ``` # Communication For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## Modbus send / receive `send` writes local values to a remote device. `receive` reads remote values into local tags. ### Target Define the remote device as a `ModbusTcpTarget` or `ModbusRtuTarget`: ``` peer = ModbusTcpTarget(name="peer", ip="192.168.1.10", port=502) rtu_device = ModbusRtuTarget(name="sensor", serial_port="/dev/ttyUSB0", device_id=2) ``` For codegen-only programs (no live simulation), pass a plain string name instead. ### Send ``` with Rung(Enable): send( target=peer, remote_start="DS1", source=DS.select(1, 10), sending=Sending, success=SendOK, error=SendErr, exception_response=ExCode, ) ``` ### Receive ``` with Rung(Enable): receive( target=peer, remote_start="DS1", dest=DS.select(11, 20), receiving=Receiving, success=RecvOK, error=RecvErr, exception_response=ExCode, ) ``` ### Status tags Both instructions require four status tags: - **sending** / **receiving** (`BOOL`) — True while the transaction is in progress - **success** (`BOOL`) — Latches True on successful completion; cleared when the next request starts - **error** (`BOOL`) — Latches True on failure; cleared when the next request starts - **exception_response** (`INT`) — Modbus exception code on error; cleared when the next request starts Once a request is submitted, it runs to completion even if `Enable` drops — `sending` / `receiving` stays True until the response (or timeout) is processed. `success` and `error` then latch and persist across disabled scans. They are only cleared when the next request is submitted (the rising-edge of Enable, or any scan where Enable is still high after a previous request finished). Because the flags latch, checking `success` or `error` directly in downstream logic will fire every scan while the value is stuck True. Use `rise(success)` / `rise(error)` to get a one-scan pulse on the completion edge: ``` with Rung(rise(SendOK)): out(SendComplete) # fires for exactly one scan on each success ``` This also handles a subtle hardware timing case: on real Click CPUs, if the TCP connection is busy with another Send/Receive, `sending` / `receiving` may not rise immediately — and during that brief delay, the previous cycle's `success` is still latched. `rise()` avoids triggering on the stale value. ### Remote addressing `remote_start` can be a Click address string (e.g. `"DS1"`) for Click-to-Click communication, or a `ModbusAddress` for raw Modbus devices. For more on the soft-PLC Modbus setup, see [Click PLC — ClickDataProvider](https://ssweber.github.io/pyrung/dialects/click/index.md). # Conditions For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). Everything that goes inside `Rung(...)`. All forms can be mixed freely. ``` Fault tag is truthy ~Fault tag is falsy MotorTemp > 100 comparison (== != < <= > >=) Fault, Pump comma = implicit AND Fault, MotorTemp > 100 implicit AND with comparison And(Fault, Pump, Valve) explicit AND (same as commas) Or(Low, High, Emergency) explicit OR Or(Start, And(Auto, Ready)) nested AND inside OR ``` ## Normally open (examine-on) ``` with Rung(Button): # True when Button is True out(Light) ``` ## Normally closed (examine-off) ``` with Rung(~Button): # True when Button is False out(FaultLight) ``` ## Rising and falling edge ``` with Rung(rise(Button)): # True for ONE scan on False→True transition latch(Motor) with Rung(fall(Button)): # True for ONE scan on True→False transition reset(Motor) ``` ## Multiple conditions (AND) ``` # Comma syntax — all must be True with Rung(Button, ~Fault, AutoMode): out(Motor) # And() — explicit AND with Rung(And(Button, ~Fault, AutoMode)): out(Motor) ``` ## OR conditions ``` # Or() — at least one must be True with Rung(Or(Start, RemoteStart)): latch(Motor) ``` ## Nested AND/OR ``` with Rung(Or(Start, And(AutoMode, Ready), RemoteStart)): latch(Motor) ``` ## Comparisons ``` with Rung(Step == 0): out(InitDone) with Rung(Temperature >= 100.0): latch(OverTempFault) with Rung(Counter != 5): out(NotAtTarget) ``` ## INT truthiness INT tags are True when non-zero: ``` with Rung(Step): # True if Step != 0 out(StepActive) with Rung(Or(Step, AlarmCode)): out(AnyActive) ``` ## Inline expressions ``` with Rung((PressureA + PressureB) > 100): latch(HighPressureFault) ``` Inline expressions work in simulation. The Click dialect validator will flag them if targeting Click hardware — rewrite as `calc()` instructions instead. # Data Movement For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## `copy` — copy single value ``` copy(Setpoint, DS[1]) # Copy tag to tag copy(42, DS[1]) # Copy literal to tag copy(DS[1], DS[DS[0]]) # Indirect addressing: DS[pointer] copy(DS[1], DS[1], oneshot=True) # Execute only on rung rising edge ``` Out-of-range values are **clamped** to the destination type's min/max. This is different from `calc()`, which wraps. ## `blockcopy` — copy a range ``` blockcopy(DS.select(1, 10), DS.select(11, 20)) # Copy DS1..DS10 → DS11..DS20 ``` Named arrays also support whole-instance copies: ``` blockcopy(RecipeProfile.instance(2), WorkingRecipe.select(1, 3)) ``` Source and destination ranges must have the same length. ## `fill` — write constant to range ``` fill(0, DS.select(1, 100)) # Zero out DS1..DS100 fill(Setpoint, Alarms.select(1, 8)) # Copy tag value to all 8 elements ``` ## Type conversion (copy converters) Copy converters handle conversions between numeric and text registers — the same options you see in the Click PLC Copy Single dialog. Pass them as the `convert` argument to `copy()`. ### Text → Numeric ``` copy(ModeChar, DS[1], convert=to_value) # CHAR '5' → numeric 5 (Copy Character Value) copy(ModeChar, DS[1], convert=to_ascii) # CHAR '5' → ASCII 53 (Copy ASCII Code Value) ``` ### Numeric → Text ``` copy(DS[1], Txt[1], convert=to_text()) # "123" (Suppress zero) copy(DS[1], Txt[1], convert=to_text(suppress_zero=False)) # "00123" (Do not Suppress zero) copy(DF[1], Txt[1], convert=to_text(exponential=True)) # "1.0000000E+04" (Exponential Numbering) copy(DS[1], Txt[1], convert=to_text(termination_code=0)) # "123" + NUL (Termination Code) copy(DS[1], Txt[1], convert=to_text(termination_code="$0D")) # "123" + CR (Termination Code, hex) copy(DS[1], Txt[1], convert=to_binary) # raw byte: 123 → '{' (Copy Binary) ``` `termination_code` appends a single ASCII character after the converted text. Pass an int (0–127), a one-character string, or a `$XX` hex string matching Click's native notation (e.g. `"$0D"` for carriage return). This matches the Click PLC Termination Code option (C0-1x and C2-x CPUs). ### Leading zeros with string literals In Click's programming software you can type `00026` directly into the source field to copy fixed-width text into text registers. Python won't allow leading zeros on integer literals — `00026` is a syntax error. Use a string instead: ``` copy("00026", Txt[1]) # Txt1..Txt5 = "0", "0", "0", "2", "6" ``` ### blockcopy and fill `blockcopy()` supports `convert=` but only for text→numeric conversions (`to_value` and `to_ascii`). This matches Click PLC hardware, which limits block copy to those two modes. ``` blockcopy(CH.select(1, 3), DS.select(1, 3), convert=to_value) blockcopy(CH.select(1, 3), DS.select(1, 3), convert=to_ascii) ``` `fill()` does not support `convert=` — it is plain value copy only. ### Converter reference | Converter | Direction | Click PLC equivalent | `copy` | `blockcopy` | `fill` | | ----------- | -------------- | --------------------------------- | ------ | ----------- | ------ | | `to_value` | Text → Numeric | Copy Character Value (Option 4b) | yes | yes | no | | `to_ascii` | Text → Numeric | Copy ASCII Code Value (Option 4b) | yes | yes | no | | `to_text()` | Numeric → Text | Copy Option 4a / 4c | yes | no | no | | `to_binary` | Numeric → Text | Copy Binary (Option 4a) | yes | no | no | `to_value`, `to_ascii`, and `to_binary` take no arguments — pass them bare (no parentheses needed, though `to_binary()` also works). `to_text()` accepts keyword arguments for formatting options. ## Pack / unpack ``` pack_bits(C.select(1, 16), DS[1]) # C1 -> bit 0, C16 -> bit 15 unpack_to_bits(DS[1], C.select(1, 16)) # bit 0 -> C1, bit 15 -> C16 pack_words(DS.select(1, 2), DD[1]) # DS1 = low word, DS2 = high word unpack_to_words(DD[1], DS.select(1, 2)) # DD low word -> DS1, high word -> DS2 pack_text(Txt.select(1, 4), DH[1]) # "ABCD" -> 0xABCD pack_text(Txt.select(1, 6), DF[1]) # "1e-2" -> 0.01 pack_text(Txt.select(1, 3), DS[1], allow_whitespace=True) # " 12" -> 12 ``` - `pack_bits()` packs a BOOL range into an `INT`, `WORD`, `DINT`, or `REAL`. Use up to 16 bits for `INT`/`WORD`, up to 32 bits for `DINT`/`REAL`. `REAL` destinations use the raw IEEE-754 bit pattern. - `unpack_to_bits()` reverses that mapping. `REAL` sources are unpacked from their raw IEEE-754 bit pattern. - `pack_words()` packs exactly two `INT`/`WORD` tags into one `DINT` or `REAL`. The first source is the low word. - `unpack_to_words()` reverses that mapping. It requires exactly two `INT`/`WORD` destinations. - `pack_text()` parses a `TXT`/`CHAR` range into an `INT`, `WORD`, `DINT`, or `REAL`. `INT`/`DINT` parse signed decimal, `WORD` parses hex, and `REAL` parses float text including exponential notation. Leading or trailing whitespace sets the out-of-range fault unless you pass `allow_whitespace=True`, which trims before parsing. - All pack/unpack instructions accept `oneshot=True`. - There is no `unpack_text()`. To write a text literal into `TXT`/`CHAR` memory, use plain `copy()` and it fans out across sequential tags: `copy("HELLO", Txt[1])`. For numeric → text, use `copy(..., convert=to_text())` or `copy(..., convert=to_binary)`. # Drum, Shift & Search For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## Shift register ``` shift(C.select(1, 8)) \ .clock(ClockBit) \ .reset(ResetBit) ``` - **Rung condition** is the data bit inserted at position 1 - **Clock** — shift occurs on the rising edge of the clock condition - **Reset** — level-sensitive: clears all bits in range while True - Terminal after `.clock(...).reset(...)`. Direction is determined by the range order: - `C.select(1, 8)` → shifts low-to-high (data enters at C1, exits at C8) - `C.select(1, 8).reverse()` → shifts high-to-low ## Event drum ``` with Rung(Running): event_drum( outputs=[DrumOut1, DrumOut2, DrumOut3], events=[DrumEvt1, DrumEvt2, DrumEvt3, DrumEvt4], pattern=[ [1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 0], ], current_step=DrumStep, completion_flag=DrumDone, ) \ .reset(ShiftReset) \ .jump((AutoMode, Found), step=DrumJumpStep) \ .jog(Clock, Found) ``` ## Time drum ``` with Rung(Running): time_drum( outputs=[DrumOut1, DrumOut2, DrumOut3], presets=[50, DS[1], 75, DS[2]], unit="ms", pattern=[ [1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 0], ], current_step=DrumStep, accumulator=DrumAcc, completion_flag=DrumDone, ) \ .reset(ShiftReset) \ .jump(Found, step=2) \ .jog(Start) ``` `event_drum(...)` and `time_drum(...)` are terminal builders. `.reset(...)` is required and finalizes the instruction. `.jump(...)` and `.jog(...)` are optional. ### Variadic condition chaining Builder condition arguments (`.down(...)`, `.clock(...)`, `.reset(...)`, `.jump(...)`, `.jog(...)`) all accept single conditions, multiple positional conditions, or tuple/list groups. All forms normalize to one AND expression: ``` event_drum(...).reset(ResetA, ResetB).jog(JogA, JogB) event_drum(...).jump((AutoMode, Found), step=2) ``` ## Search Find the first element in a range matching a condition: ``` search( DS.select(1, 100) >= 100, result=FoundAddr, found=FoundFlag, ) ``` The first argument is a comparison expression built from a `.select()` range — the same operator syntax tags use elsewhere in the DSL. - On success: `result = matched_address` (1-based), `found = True` - On miss: `result = -1`, `found = False` - `result` must be INT or DINT; `found` must be BOOL ### Continuous search (resume from last position) ``` search( DS.select(1, 100) >= 100, result=FoundAddr, found=FoundFlag, continuous=True, ) ``` - `result == 0` → restart at first address - `result == -1` → already exhausted; return miss without rescanning - otherwise → resume at first address after current result ### Text search ``` search( Txt.select(1, 50) == "AB", # Search for substring "AB" result=FoundAddr, found=FoundFlag, ) ``` Only `==` and `!=` are valid for CHAR ranges. Matches windowed substrings of length equal to the value string. # Math For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## `calc()` — evaluate expression ``` calc(DS[1] + DS[2], DS[3]) # DS3 = DS1 + DS2 (wraps to INT range) calc(DS[1] * 2, DS[3], oneshot=True) # One-shot: execute once per rung rising edge calc(DH[1] | DH[2], DH[3]) # WORD-only math infers hex mode ``` ## Overflow behavior **Math wraps** — overflow truncates to the destination type's bit width (modular arithmetic). This differs from `copy()` which clamps. | Expression | Destination | Result | | --------------------- | -------------------- | ---------------------- | | `DS1 + 1` (DS1=32767) | INT (16-bit signed) | −32768 (wraps) | | `50000 * 50000` | DINT (32-bit signed) | −1,794,967,296 (wraps) | | `40000` → `copy()` | INT | 32767 (clamped) | ## Division - Division by zero produces result = 0 and sets `system.fault.division_error`. - Integer division truncates toward zero: `−7 / 2 = −3`. ## Mode inference `calc()` infers arithmetic mode from referenced tag types (including destination): | Family | Inferred mode | | -------------------- | ------------------------------- | | WORD-only | `"hex"` (unsigned 16-bit wrap) | | Any non-WORD present | `"decimal"` (signed arithmetic) | For Click portability, do not mix WORD and non-WORD math in the same `calc()` expression. Click validation reports `CLK_CALC_MODE_MIXED` for mixed-family expressions. ## Numeric behavior summary | Operation | Out-of-range behavior | | ------------------- | --------------------------------------------- | | `copy()` | Clamps to destination min/max | | `calc()` | Wraps (modular arithmetic) | | Timer accumulator | Clamps at 32,767 | | Counter accumulator | Clamps at DINT min/max | | Division by zero | Result = 0, `system.fault.division_error` set | ## `BlockRange.sum()` — sum a range ``` calc(DS.select(1, 10).sum(), Result) # Result = DS1 + DS2 + … + DS10 calc(DH.select(1, 5).sum(), HDest) # hex mode (WORD range) ``` `.sum()` on a `BlockRange` returns a `SumExpr` — a lazy expression node that sums all tag values in the range at scan time. It's a full `Expression`, so it works anywhere expressions are accepted: ``` calc(DS.select(1, 10).sum() + Offset, Result) copy(DS.select(1, 5).sum(), Total) with Rung(DS.select(1, 10).sum() > 1000): out(Alarm) ``` Mode inference applies normally: WORD-only ranges infer hex mode, anything else infers decimal. Click ladder export renders as `SUM ( DS1 : DS10 )` with the native colon-range syntax. # Program Control For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## Programs Two equivalent ways to define a program: ``` # Context manager with Program() as logic: with Rung(Start): latch(Running) # Decorator @program def logic(): with Rung(Start): latch(Running) ``` Both produce a `Program` you pass to `PLC`. See [Core Concepts — Programs](https://ssweber.github.io/pyrung/getting-started/concepts/#programs) for details. ## Subroutines ### Context-manager style ``` with Program() as logic: with subroutine("startup"): with Rung(Step == 0): out(InitLight) with Rung(AutoMode): call("startup") ``` ### Decorator style ``` @subroutine("init") def init_sequence(): with Rung(): out(InitLight) with Program() as logic: with Rung(Button): call(init_sequence) # auto-registers and calls ``` ## For loops `forloop` repeats a block of instructions N times within a single scan: ``` with Rung(): with forloop(5): copy(Counter + 1, Counter) ``` The count can be a literal or a tag (resolved each scan): ``` with Rung(): with forloop(LoopCount): copy(Counter + 1, Counter) ``` Use `loop.idx` for indirect addressing inside the loop body: ``` with Rung(): with forloop(3) as loop: copy(Src[loop.idx + 1], Dst[loop.idx + 1]) ``` End and return instructions aren't needed — Python indentation handles scope. # Rungs For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## Rung comments Use `comment()` before a rung to attach a comment: ``` comment("Initialize the light system.") with Rung(Button): out(Light) ``` Multi-line comments use triple-quoted strings (automatically dedented and stripped): ``` comment(""" This rung controls the main light. It activates when Button is pressed. """) with Rung(Button): out(Light) ``` Comments are limited to 1400 characters. Exceeding this raises `ValueError`. ## Branching `branch()` creates a parallel path within a rung. The branch condition is ANDed with the parent rung's condition. ``` with Rung(First): # ① Evaluate: First out(Third) # ③ Execute with branch(Second): # ② Evaluate: First AND Second out(Fourth) # ④ Execute out(Fifth) # ⑤ Execute ``` Three rules: 1. **Conditions evaluate before instructions.** ① and ② are resolved before ③ ④ ⑤ run. A branch ANDs its own condition with the parent rung's. 1. **Instructions execute in source order.** ③ → ④ → ⑤, as written — not "all rung, then all branch." 1. **Each rung starts fresh.** The next rung sees the state as it was left after the previous rung's instructions. ### Nested branches Branches can nest inside other branches. All conditions at every depth evaluate against the same rung-entry snapshot. ``` with Rung(A): out(X) with branch(B): out(Y) with branch(C): out(Z) # Executes when A AND B AND C ``` This exists so codegen can faithfully represent imported ladder topologies. For hand-written logic, flat branches are clearer. ## Continued rungs `Rung.continued()` tells a rung to reuse the previous rung's condition snapshot instead of freezing a fresh one. All conditions in the continued rung evaluate against the pre-instruction state from the original rung. ``` with Rung(A): out(X) with Rung(B).continued(): out(Y) # B evaluated against pre-X state (same snapshot as A) ``` This models the Click ladder editor pattern where a single visual rung has multiple independent wires to the right power rail. Without `.continued()`, splitting into separate `Rung` blocks would give each its own snapshot — changing behavior if the first rung's instructions mutate a tag that the second rung's conditions reference. Multiple continued rungs chain — they all share the original snapshot: ``` with Rung(A): copy(10, Counter) with Rung(Counter == 0).continued(): out(X) # True: Counter was 0 at snapshot time with Rung(Counter == 10).continued(): out(Y) # False: Counter was 0 at snapshot time, not 10 yet ``` A normal (non-continued) rung breaks the chain and takes a fresh snapshot. Constraints: - `continued()` cannot be the first rung in a program or subroutine. - Continued rungs cannot have their own `.comment`. Like nested branches, this exists primarily for codegen round-tripping. For hand-written logic, separate rungs with fresh snapshots are easier to reason about. # Timers & Counters For an introduction to the DSL vocabulary, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## Timer and Counter types `Timer` and `Counter` are built-in structured types. Each has a `.Done` bit (Bool) and an `.Acc` accumulator (Int for timers, Dint for counters). ``` from pyrung import Timer, Counter # Named instances — the 95% case for real programs OvenTimer = Timer.clone("OvenTimer") CycleTimer = Timer.clone("CycleTimer") PartCounter = Counter.clone("PartCounter") # Anonymous instances — fine for throwaway simulation tests t = Timer[1] c = Counter[1] ``` Use `Timer.clone("Name")` for production code — `OvenTimer_Done` in a fault log tells you everything; `Timer1_Done` tells you nothing. ### Custom types Timer and counter instructions use a structural contract: any `@udt()` with a `Done: Bool` field and an `Acc: Int` or `Acc: Dint` field works with `on_delay`, `off_delay`, `count_up`, and `count_down`. ``` from pyrung import udt, Bool, Dint @udt() class MyCounter: Done: Bool Acc: Dint Faults: Dint # extra fields are fine ``` ## Timers ### On-delay timer (TON / RTON) ``` # TON: auto-reset when rung goes False on_delay(OvenTimer, preset=100) # RTON: hold accumulator when rung goes False (manual reset required) on_delay(OvenTimer, preset=100) \ .reset(ResetButton) ``` **TON behavior:** - Rung True → accumulator counts up; done = True when acc ≥ preset - Rung False → immediately resets acc and done **RTON behavior:** - Same as TON while rung is True - Rung False → holds acc and done (does not reset) - `.reset(tag)` → resets acc and done regardless of rung state `on_delay(...).reset(...)` (RTON) is terminal — no later instruction or branch can follow in the same flow. ### Off-delay timer (TOF) ``` off_delay(CoolDown, preset=100) ``` **TOF behavior:** - Rung True → done = True, acc = 0 - Rung False → accumulator counts up; done = False when acc ≥ preset TOF is non-terminal — instructions can follow it in the same rung. ### Time units | Argument | Aliases | Unit | | -------- | -------------------------- | ---------------------- | | `"ms"` | `"milliseconds"`, `"msec"` | Milliseconds (default) | | `"sec"` | `"s"`, `"seconds"` | Seconds | | `"min"` | `"m"`, `"minutes"` | Minutes | | `"hour"` | `"h"`, `"hr"`, `"hours"` | Hours | | `"day"` | `"d"`, `"days"` | Days | Also accepted: `Tms`/`Ts`/`Tm`/`Th`/`Td` — great for tag names (`FillTimeTm` stays short, and `Tm` sidesteps the minute-vs-minimum ambiguity of `Min`). The accumulator stores integer ticks in the selected unit. The time unit controls how `dt` is converted to accumulator ticks. ### Example: traffic light ``` GreenTimer = Timer.clone("GreenTimer") YellowTimer = Timer.clone("YellowTimer") RedTimer = Timer.clone("RedTimer") with Rung(State == "g"): on_delay(GreenTimer, preset=3000) with Rung(State == "y"): on_delay(YellowTimer, preset=1000) ``` See [Structured Tags](https://ssweber.github.io/pyrung/learn/structured-tags/index.md) for the full UDT pattern. ## Counters Counters use a `.Done` bit (Bool) and a `.Acc` accumulator (Dint). Counters count **every scan** while the condition is True — they are not edge-triggered. Use `rise()` on the rung condition if you want one increment per leading edge. ### Count up (CTU) ``` count_up(PartCounter, preset=100) \ .reset(ResetButton) ``` - Rung True → accumulator increments each scan; done = True when acc ≥ preset - `.reset(tag)` → resets acc and done when that tag is True `count_up(...).reset(...)` is terminal. ### Count down (CTD) ``` count_down(Dispense, preset=100) \ .reset(ResetButton) ``` - Accumulator starts at 0 and goes negative each scan - done = True when acc ≤ −preset `count_down(...).reset(...)` is terminal. ### Bidirectional counter ``` count_up(ZoneCounter, preset=100) \ .down(DownCondition) \ .reset(ResetButton) ``` Both up and down conditions are evaluated every scan; the net delta is applied once. ### Edge-triggered counting To count edges instead of scans, wrap the condition with `rise()`: ``` with Rung(rise(Sensor)): count_up(PartCounter, preset=9999) \ .reset(CountReset) ``` For chained builders (counters, shift registers, drums), complete the full chain (`.down(...)`, `.clock(...)`, `.reset(...)`) before any later DSL statement. # Guides # Analysis pyrung's scan engine records every state snapshot. The analysis tools turn that history into answers: what does my program touch, why did something happen, and is my test suite covering the program? Three layers, each building on the last: - **`plc.dataview`** — static structure. What tags exist, how they connect, what role they play. - **`plc.cause()` / `plc.effect()`** — dynamic behavior. What caused a transition, what it caused downstream, and what-if projections. - **`plc.query`** — test coverage. Which rungs never fired, which latched bits have no clear path. All three work in plain pytest. No VS Code required. ## DataView: what does my program touch? `plc.dataview` returns a chainable query over the program's static dependency graph. No scans needed — it reads the program structure directly. ``` from pyrung import Bool, PLC, Program, Rung, And, latch, reset, out StartBtn = Bool("StartBtn") StopBtn = Bool("StopBtn") Fault = Bool("Fault") Running = Bool("Running") MotorOut = Bool("MotorOut") with Program() as logic: with Rung(And(StartBtn, ~Fault)): latch(Running) with Rung(StopBtn): reset(Running) with Rung(Running): out(MotorOut) with PLC(logic) as plc: dv = plc.dataview ``` ### Role filters Every tag gets a role based on its position in the dependency graph: ``` dv.inputs() # only read, never written by logic — your physical inputs dv.pivots() # both read and written — internal state dv.terminals() # only written, never read — your physical outputs dv.isolated() # neither read nor written by any rung ``` Filters chain. `.inputs().contains("btn")` narrows to input tags matching "btn". ### Name matching `.contains()` does abbreviation-aware fuzzy matching: ``` dv.contains("cmd") # finds CommandRun, Cmd_Reset, etc. dv.contains("motor") # finds MotorOut, ConveyorMotor, etc. ``` It splits on camelCase and underscores, then expands both sides into consonant abbreviations — `"cmd"` finds `CommandRun`, and `"command"` finds `Cmd_Reset`. ### Dependency slicing ``` dv.upstream("MotorOut") # everything that can affect MotorOut dv.downstream("StartBtn") # everything StartBtn can affect ``` These return narrowed DataViews, so you can chain further: ``` dv.inputs().upstream("MotorOut") # which inputs feed into MotorOut? ``` ### Iteration DataView is iterable and supports `len`, `in`, and `bool`: ``` for tag_name in dv.inputs(): print(tag_name) assert "StartBtn" in dv assert len(dv.pivots()) > 0 ``` `.tags` returns the underlying `frozenset` of tag names. `.roles()` returns a `dict[str, TagRole]`. ### Static use without a runner `program.dataview()` returns the same thing without needing a `PLC`: ``` dv = logic.dataview() # works directly on the Program ``` Useful in test utilities or static analysis scripts that don't need to run scans. ## Simplified form: what does this output actually depend on? `program.simplified()` resolves each terminal tag's condition chain back to inputs, eliminating intermediate pivots. A 14-rung interlock chain through 10 intermediate tags becomes a two-term Boolean expression over the 8 inputs that actually matter. ``` from pyrung import Bool, Program, Rung, branch, out EStop = Bool("EStop") RunPermit = Bool("RunPermit") PlantMode = Bool("PlantMode") StartBtn = Bool("StartBtn") MaintOverride = Bool("MaintOverride") SafetyOK = Bool("SafetyOK") Permitted = Bool("Permitted") Running = Bool("Running") SealIn = Bool("SealIn") MotorOut = Bool("MotorOut") with Program() as logic: with Rung(~EStop): out(SafetyOK) with Rung(RunPermit, SafetyOK): out(Permitted) with Rung(Permitted): with branch(StartBtn): out(Running) with branch(SealIn): out(Running) with Rung(Running): out(SealIn) with Rung(): with branch(Running, ~EStop): out(MotorOut) with branch(MaintOverride): out(MotorOut) forms = logic.simplified() ``` Each entry is a `TerminalForm` with the resolved expression and resolution stats: ``` form = forms["MotorOut"] form.expr # the simplified Boolean expression tree form.writer_count # how many rungs write this tag form.pivot_count # how many intermediate tags were resolved away form.depth # deepest resolution chain traversed ``` Use `render()` for a human-readable string: ``` from pyrung.core.analysis.simplified import render render(forms["MotorOut"].expr) # 'Or(And(RunPermit, ~EStop, Or(StartBtn, Running), ~Fault), MaintOverride)' ``` ### What it tells you The simplified form strips away organizational structure — the intermediate tags that exist to break logic into reviewable chunks — and shows the actual dependency. A 14-rung → 2-term reduction tells you: 8 inputs matter, there are 2 independent paths, and `MaintOverride` bypasses everything. ### Branch topology is preserved Sibling branches produce `And(parent, Or(local₁, local₂))`, not the flat DNF form `Or(And(parent, local₁), And(parent, local₂))`. The series/parallel structure of the original program carries through resolution — shared preconditions appear once, with the distinguishing triggers nested inside. ### Cycles (seal-in) Feedback loops like seal-in latches are detected and left as-is. When resolution encounters a tag it has already visited in the current chain, it stops substituting. The seal-in tag appears in the output as a leaf — indicating the latch rather than infinitely expanding. ## Cause and effect: why did this happen? After running some scans, `plc.cause()` and `plc.effect()` explain what happened and why. ### Recorded cause — what caused this? ``` with PLC(logic) as plc: StartBtn.value = True plc.step() chain = plc.cause(Running) ``` `cause()` walks backward from `Running`'s most recent transition and returns a `CausalChain`: ``` chain.mode # 'recorded' chain.effect.tag_name # 'Running' chain.effect.to_value # True chain.effect.scan_id # 1 step = chain.steps[0] step.rung_index # 0 # What flipped the rung: step.proximate_causes # [Transition(StartBtn, 0→1)] # What was already holding the path open: step.enabling_conditions # [EnablingCondition(Fault, value=False, held_since=None)] ``` **Proximate** means the contact transitioned and flipped the rung. **Enabling** means it was already in the right state — necessary, but not what changed. The engine figures out which is which automatically. How attribution works The engine converts each rung's condition into a series-parallel (SP) tree, then applies a four-rule post-order walk to identify which contacts mattered for the evaluation. Intersecting "mattered" with the transition log produces the proximate/enabling split. Each step has a `fidelity` field: `"full"` when full SP-tree attribution was possible (the scan's state was in the cache), or `"timeline"` when only structural and firing-timeline data was available (cache miss). In timeline mode, `proximate_causes` becomes a superset of the true set and `enabling_conditions` is empty. A single chain can mix fidelities — recent steps full, deeper steps timeline-only. Raise `history_budget` or widen the `cache` window to get full fidelity across more of the chain. ### Recorded effect — what did this cause? ``` chain = plc.effect(StartBtn, scan=1) ``` Walks forward from `StartBtn`'s transition at scan 1. For each downstream rung, the engine checks whether the transition actually mattered — if the rung would have evaluated the same way without it, the transition is filtered out. Only load-bearing causes propagate forward. Counterfactual evaluation The forward walk uses counterfactual SP evaluation: flip the cause leaf in the rung's SP tree, re-evaluate, and compare to the original result. If the outcome doesn't change, the cause was incidental, not proximate. ### Projected cause — what *would* cause this? Add `to=` to switch from "what happened" to "what would need to happen": ``` with PLC(logic) as plc: StartBtn.value = True plc.step() # Running is now latched TRUE. How could it clear? chain = plc.cause(Running, to=False) chain.mode # 'projected' — a reachable path exists # StopBtn would need to transition 0→1 ``` Projected cause finds rungs that could produce the requested value, checks what conditions would need to hold, and verifies whether the required input transitions have actually been observed in recorded history. When no reachable path exists: Reachability rules Tags that no rung writes to (inputs in the dependency graph sense — buttons, sensors, HMI commands) are always considered reachable, since their value comes from outside the ladder. Tags that the ladder *does* write to are reachable only if they've taken the needed value in recorded history. This catches the common bug ("we wrote a clear rung but never fed it the conditions to fire") without false alarms about hypothetical input sequences. ``` chain.mode # 'unreachable' chain.blockers # [BlockingCondition(rung=1, blocked_contact=StopBtn, # reason=BlockerReason.NO_OBSERVED_TRANSITION)] ``` The blockers explain exactly which inputs the test suite has never demonstrated — either a coverage gap (write the test) or a deliberate omission (operator-only input, not testable from software). ### Projected effect — what *would* happen if...? ``` chain = plc.effect(StartBtn, from_=False) # What would happen if StartBtn went TRUE right now? ``` What-if analysis without mutating state. ### `assume={}` — scenario pinning All three projected methods accept `assume=` to pin tags to specific values during analysis: ``` plc.cause(Running, to=False, assume={"ResetReady": True}) plc.effect(StartBtn, from_=False, assume={"Guard": True}) plc.recovers(Fault, assume={"ResetBtn": True}) ``` The assumed values override the state snapshot before the walker runs, and assumed tags are treated as reachable regardless of history. Three uses: **Exploration.** REPL sweeps to discover which tests are worth writing: ``` for tag in fault_tags: if not plc.recovers(tag, assume={"ResetBtn": True}): print(f"Reset doesn't clear {tag}") ``` **Causal assertions in tests.** Assert the ladder actually connects inputs to outputs: ``` assert plc.cause("Motor_Running", assume={"StartBtn": True, "EStop": False}) assert not plc.cause("Motor_Running", assume={"EStop": True}) ``` **External tag reasoning.** Tags marked `external=True` normally return `True` from `recovers()` by declaration. With `assume=`, the shortcut is skipped and the analysis runs, so you can verify the recovery path works with specific inputs: ``` assert plc.recovers("Alarm_Ack", assume={"Alarm_Ack": False}) ``` `assume=` on a `readonly` tag raises `ValueError` — the tag is declared constant, so pinning it to a different value contradicts the declaration. `external` and `final` tags are fine to assume. `assume=` requires projected mode. Using it without `to=` on `cause()` or without `from_=` on `effect()` raises `ValueError`. ### `recovers()` — can this bit clear? ``` assert plc.recovers(Running) # True if a clear path exists ``` Convenience predicate over `cause()`. For the diagnostic on failure, use `cause()` directly: ``` chain = plc.cause(Running, to=False) assert chain.mode != "unreachable", chain ``` ## Query: is my test suite covering the program? `plc.query` runs whole-program surveys across recorded history. ### Cold and hot rungs ``` with PLC(logic) as plc: StartBtn.value = True plc.run(cycles=10) plc.query.cold_rungs() # rung indices that never fired plc.query.hot_rungs() # rung indices that fired every scan ``` Cold rungs are dead code or untested paths. Hot rungs may indicate always-true conditions worth reviewing. ### Stranded bits ``` stranded = plc.query.stranded_bits() ``` Returns `CausalChain` objects for each latched tag with no reachable reset path. Each chain carries blocker diagnostics pointing at the specific inputs that would need to transition. ### Coverage reports and merge Individual test findings are mostly noise — a single test only exercises a slice of the program. The signal emerges when you merge findings across a test suite. ``` from pyrung.core.analysis.query import CoverageReport def test_start_stop(plc): StartBtn.value = True plc.run(cycles=5) StopBtn.value = True plc.step() return plc.query.report() def test_fault_handling(plc): plc.force(Fault, True) plc.run(cycles=5) return plc.query.report() ``` `CoverageReport.merge()` combines findings across tests: ``` merged = report_a.merge(report_b) ``` Negative findings (cold rungs, stranded bits) merge by **intersection** — a rung is only cold in the merged view if *no* test fired it. Each test you add can only shrink the residuals. What remains after the full suite is what you actually need to investigate. Stranded bits merge by chain identity (tag + blocker fingerprint), so "stranded for a different reason" after a refactor is a distinct signal from "still stranded." ### Pytest plugin The manual merge above works, but the `pyrung.pytest_plugin` handles it automatically. Enable it in your `conftest.py`: ``` pytest_plugins = ["pyrung.pytest_plugin"] ``` Then wire the `pyrung_coverage` fixture into your PLC fixture: ``` @pytest.fixture def plc(pyrung_coverage): with PLC(logic, dt=0.1) as p: yield p pyrung_coverage.collect(p) ``` Every test that uses `plc` contributes a report. At session end, the plugin merges all reports and writes `pyrung_coverage.json`: ``` { "cold_rungs": [22, 91], "hot_rungs": [0, 2, 3], "stranded_chains": [] } ``` Control the output path with `--pyrung-coverage-json`: ``` pytest --pyrung-coverage-json=build/coverage.json # custom path pytest --pyrung-coverage-json= # disable output ``` ### Whitelist and CI gating A TOML whitelist declares known-acceptable findings — cold rungs you've decided are dormant by design, stranded bits that are operator-only and not testable from software: ``` # pyrung_whitelist.toml [cold_rungs] allow = [22, 91, 104] [stranded_chains] allow = ["Sts_SpecialFault", "Sts_ManualReset"] ``` Pass it with `--pyrung-whitelist`: ``` pytest --pyrung-whitelist=pyrung_whitelist.toml ``` New findings not in the whitelist fail the session (exitstatus 1) and print a summary: ``` =============================== pyrung coverage =============================== New cold rungs not in whitelist: [200, 201] New stranded bits not in whitelist: ['Sts_NewFault'] ``` The whitelist keys stranded bits by tag name only — not by blocker fingerprint. If a refactor changes *why* a bit is stranded, the whitelist still covers it, but the JSON report's chain identity will differ, surfacing the change for review. With one test, cold rungs and stranded bits are mostly noise. After hundreds of tests, anything still in the residual has had hundreds of chances to be exercised and wasn't. That's where the whitelist becomes a short list of deliberate decisions rather than a pile of false positives. ## Static validators Separate from the runtime analysis, static validators check program structure at build time — no scans needed. Call `logic.validate()` to run them all: ``` report = logic.validate() assert not report, report.summary() ``` `ValidationReport` is falsy when clean, truthy when there are findings. It's iterable — each finding carries a `.code`, `.target_name`, and `.message`. ### Selecting rules By default all rules run. Use `select` to limit or `ignore` to exclude by rule code: ``` report = logic.validate(select={"CORE_STUCK_HIGH", "CORE_STUCK_LOW"}) report = logic.validate(ignore={"CORE_ANTITOGGLE"}) ``` Unknown codes raise `ValueError`. ### Rule reference | Code | What it detects | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `CORE_CONFLICTING_OUTPUT` | Multiple `out`/timer/counter/drum/shift instructions targeting the same tag from non-mutually-exclusive paths. Last-writer-wins stomping every scan. | | `CORE_STUCK_HIGH` | Tag is latched but never reset anywhere in the program. | | `CORE_STUCK_LOW` | Tag is reset but never latched anywhere in the program. | | `CORE_READONLY_WRITE` | Write instruction targets a `readonly=True` tag. | | `CORE_POINTER_DEFAULT_BEFORE_BLOCK_START` | Exact indirect dereference like `DS[Ptr]` where `Ptr` defaults below the block start address. Most often this means a 1-based block is being indexed by a tag that still has the implicit `default=0`. | | `CORE_CHOICES_VIOLATION` | Literal-value write to a tag whose `choices` key set doesn't include that value. | | `CORE_FINAL_MULTIPLE_WRITERS` | More than one write site for a `final=True` tag — no mutual-exclusivity exemption. | | `CORE_RANGE_VIOLATION` | Literal-value write outside the tag's declared `min`/`max` range. | | `CORE_MISSING_PROFILE` | Tag has a `Physical` profile via `link` but the linked tag has no profile defined. | | `CORE_ANTITOGGLE` | Opposing writes to a feedback-linked tag pair within the same scan, risking physical oscillation. | `CORE_POINTER_DEFAULT_BEFORE_BLOCK_START` is intentionally syntax-level. It checks the actual dereference tag used in `Block[Ptr]`, not whether some earlier rung computed a different intermediate pointer. The physical-realism rules (`CORE_RANGE_VIOLATION`, `CORE_MISSING_PROFILE`, `CORE_ANTITOGGLE`) accept a `dt` parameter forwarded from `validate()`: ``` report = logic.validate(dt=0.05) ``` Stuck bits vs. stranded bits `CORE_STUCK_HIGH`/`CORE_STUCK_LOW` check structure — "is there a reset rung at all?" The runtime `plc.query.stranded_bits()` checks reachability — "is there a reset rung *and can it actually fire*?" Conflicting output exclusivity The validator detects `CompareEq` different-constant pairs, `BitCondition`/`NormallyClosedCondition` complements, and range-complement pairs (`Lt`/`Ge`, `Le`/`Gt`) on caller conditions. Different subroutines with provably exclusive callers are safe. ## Next steps - [Verification](https://ssweber.github.io/pyrung/guides/verification/index.md) — prove properties hold, fault coverage, lock files - [Testing Guide](https://ssweber.github.io/pyrung/guides/testing/index.md) — forces as fixtures, forking, monitors, breakpoints - [Runner Guide](https://ssweber.github.io/pyrung/guides/runner/index.md) — execution methods, history, time travel \\r # Architecture How the engine works under the hood. For the DSL vocabulary (tags, rungs, instructions), see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). For the execution API, see [Runner](https://ssweber.github.io/pyrung/guides/runner/index.md). ## The Redux model pyrung is architected like Redux: state is immutable, logic is a pure function, and execution is consumer-driven. ``` Logic(CurrentState) → NextState ``` Every `step()` call takes the current `SystemState`, evaluates all rungs as pure functions, and produces a new `SystemState`. The old state is still accessible. This makes programs deterministic, testable, and debuggable — the same state plus the same inputs always produce the same next state. ## SystemState ``` class SystemState(PRecord): scan_id : int # scan counter (resets to 0 on STOP→RUN/reboot) timestamp : float # simulation clock (seconds) tags : PMap # tag values, keyed by name string memory : PMap # engine-internal state (edge detection, timer fractionals) ``` `tags` is everything user code touches. `memory` is internal engine bookkeeping — edge detection bits (`rise`/`fall`), timer fractional accumulators, etc. `SystemState` is a [`PRecord`](https://pyrsistent.readthedocs.io/) from the pyrsistent library — a frozen, persistent data structure that shares structure between versions for memory efficiency. Each scan produces a new `SystemState` without modifying the previous one. ## Scan cycle Every `step()` executes exactly one complete scan cycle through nine phases: ``` Phase 0 SCAN START Dialect resets (e.g., Click auto-clears SC40/SC43/SC44) Phase 1 APPLY PATCH One-shot inputs from patch() written to context Phase 2 READ INPUTS InputBlock values copied from external source Phase 3 APPLY FORCES Pre-logic force pass (debug overrides) Phase 4 EXECUTE LOGIC Rungs evaluated top-to-bottom Phase 5 APPLY FORCES Post-logic force pass (re-assert force values) Phase 6 WRITE OUTPUTS OutputBlock values pushed to external sink Phase 7 ADVANCE CLOCK scan_id += 1, timestamp updated per time mode Phase 8 SNAPSHOT New SystemState committed ``` All writes within a scan are batched and committed atomically at phase 8. Rungs see each other's writes immediately — a write in rung 3 is visible to rung 4 in the same scan. ## Batched writes Inside a scan, tag writes and memory updates accumulate in a mutable working space. The engine creates this space at scan start, instructions write into it throughout execution, and phase 8 commits everything at once to produce the next immutable `SystemState`. User code never interacts with this layer directly. Inside a `with PLC(...) as plc:` block, tag reads and writes are routed through the runner's pending scan state and committed atomically when the scan completes. ## Consumer-driven execution The engine never runs unsolicited. The consumer drives execution at whatever granularity it needs: ``` runner.step() # one complete scan runner.run(cycles=100) # N scans runner.run_for(1.0) # advance by simulation time runner.run_until(~Motor) # stop on condition ``` This inversion of control is what makes pyrung suitable for testing, GUIs, and debuggers. A pytest test calls `step()` and asserts. A VS Code extension calls `scan_steps_debug()` and renders decorations. A soft PLC calls `step()` in a loop driven by a Modbus server. ## Source location capture During the DSL build phase (`Rung`, `rise()`, `out()`, operators, builder-style APIs), each element captures its source file and line number. This metadata enables mapping from engine objects back to user code for editor integration. Captured metadata per element: - `source_file: str | None` - `source_line: int | None` - `end_line: int | None` (for block contexts like `Rung` and `branch`; best-effort via AST `end_lineno`) Builder flows (`shift(...).clock(...).reset(...)`, `count_up(...).reset(...)`, etc.) preserve the original callsite metadata through the chain. If rungs are built in a loop, multiple rung objects may share source lines. The mapping is best-effort in this case; explicit DSL declarations maintain a clean one-to-one mapping. ## Debug stepping APIs These APIs are used by the DAP adapter and are not part of the typical user workflow. ### `scan_steps()` — rung-boundary generator ``` for rung_index, rung, ctx in runner.scan_steps(): print(f"After rung {rung_index}: {dict(ctx._tags_pending)}") # scan commits when generator is exhausted ``` Executes one scan, yielding after each top-level rung evaluation. The scan only commits atomically when the generator is fully exhausted. Partially consuming the generator leaves the runner in a partially-evaluated state. ### `scan_steps_debug()` — instruction-level stepping ``` for step in runner.scan_steps_debug(): print(step.rung_index, step.kind, step.source_line, step.enabled_state) ``` Yields `ScanStep` objects at rung, branch, subroutine, and instruction boundaries. This is the API the DAP adapter uses to drive execution with source location information. Same commit semantics as `scan_steps()`. ## Rung inspection `runner.debug.rung_trace()` and `runner.debug.last_event()` return debug trace data for the most recently committed debug scan. Only one scan's worth of trace is retained — a non-debug commit (`step()`/`run()`) wipes the slot. ### `runner.debug.rung_trace(rung_id)` Returns a `RungTrace` for one rung in the current scan: - `RungTrace.scan_id` — committed scan id - `RungTrace.rung_id` — top-level rung index (0-based) - `RungTrace.events` — ordered tuple of `RungTraceEvent` Each `RungTraceEvent` captures one debug boundary: - `kind`: `"rung"` | `"branch"` | `"subroutine"` | `"instruction"` - `source_file`, `source_line`, `end_line` - `subroutine_name`, `depth`, `call_stack` - `enabled_state`, `instruction_kind` - `trace`: `TraceEvent | None` Raises `KeyError(rung_id)` when no debug trace exists for the current scan. ### `runner.debug.last_event()` Returns the latest debug-trace event as `(scan_id, rung_id, RungTraceEvent)` or `None`: - Prefers in-flight events from an active `scan_steps_debug()` scan - Falls back to latest committed retained debug-path event - Returns `None` when no debug trace context exists # Your First P1AM Program Wire up a discrete input and output on a P1AM-200, test the logic locally, generate CircuitPython code, and deploy to hardware. ## Configure hardware ``` from pyrung import Bool, Int, Program, Rung, PLC, out, copy, rise from pyrung.circuitpy import P1AM, write_circuitpy hw = P1AM() inputs = hw.slot(1, "P1-08SIM") # 8-ch discrete input outputs = hw.slot(2, "P1-08TRS") # 8-ch relay output Button = inputs[1] Light = outputs[1] PressCount = Int("PressCount", retentive=True) ``` `hw.slot()` returns typed blocks matching the physical module. Slot numbers must be contiguous from 1, matching the wiring order on the DIN rail. `PressCount` is marked `retentive=True` — its value persists to SD card across power cycles. ## Write logic ``` with Program() as logic: with Rung(Button): out(Light) with Rung(rise(Button)): copy(PressCount + 1, PressCount) ``` Button held → Light on. Each rising edge of Button increments the press counter. Same DSL as any pyrung program — nothing CircuitPython-specific here. ## Test locally ``` def test_button_press(): with PLC(logic, dt=0.1) as plc: # Press button — light turns on, counter increments Button.value = True plc.step() assert Light.value is True assert PressCount.value == 1 # Hold button — light stays on, counter doesn't increment (no new edge) plc.run(cycles=5) assert Light.value is True assert PressCount.value == 1 # Release and press again — second count Button.value = False plc.step() Button.value = True plc.step() assert PressCount.value == 2 # Release — light turns off (out de-energizes when rung is false) Button.value = False plc.step() assert Light.value is False ``` Run with `pytest`. The logic is verified before it touches hardware. ## Generate code ``` write_circuitpy(logic, hw, target_scan_ms=10.0, watchdog_ms=500, output_dir="./build") ``` This writes two files to `./build/`: - **`code.py`** — your program compiled to a CircuitPython scan loop. Regenerate every time you change logic. - **`pyrung_rt.py`** — the pyrung runtime library (Modbus helpers, protocol state machines). Same for every project. For faster boot and lower memory use, replace `pyrung_rt.py` with the pre-compiled `pyrung_rt.mpy` from the [releases page](https://github.com/ssweber/pyrung/releases). ## Deploy to hardware ### One-time board setup 1. Install [CircuitPython](https://circuitpython.org/board/p1am_200/) on the P1AM-200 1. Install the [CircuitPython P1AM library](https://github.com/facts-engineering/CircuitPython_P1AM) and its dependencies into `CIRCUITPY/lib/` 1. Download `pyrung_rt.mpy` from the [pyrung releases page](https://github.com/ssweber/pyrung/releases) and copy it to `CIRCUITPY/lib/` 1. Insert a FAT-formatted SD card (required for retentive tags like `PressCount`) ### Iterate Copy `code.py` to the P1AM-200's `CIRCUITPY` drive. It runs automatically on boot. The board switch works as RUN/STOP out of the box — switch down to stop execution (outputs go off), switch up to resume. Retentive tags are saved automatically on RUN→STOP and every 30 seconds when values change. ## What you get for free - **RUN/STOP** via the board switch (debounced, default on) - **Retentive persistence** — tagged values survive power loss via SD card with crash-safe writes - **Watchdog** — hardware reset if the scan loop stalls beyond `watchdog_ms` - **Scan pacing** — the loop targets `target_scan_ms` and tracks overruns ## Next steps - [CircuitPython Dialect](https://ssweber.github.io/pyrung/dialects/circuitpy/index.md) — hardware model, all 35 modules, validation, board peripherals, SD commands - [CircuitPython Modbus TCP](https://ssweber.github.io/pyrung/dialects/circuitpy-modbus/index.md) — expose tags over the network via the P1AM-ETH shield - [Testing Guide](https://ssweber.github.io/pyrung/guides/testing/index.md) — forces, time travel, forking, pytest patterns # Click PLC Cheatsheet Quick reference for writing pyrung programs targeting AutomationDirect Click PLCs. ## Imports ``` from pyrung import ( Bool, Int, Dint, Real, Word, Char, # tag types Timer, Counter, # built-in UDTs named_array, Field, auto, # structures PLC, Program, Rung, # PLC skeleton comment, branch, # rung structure And, Or, rise, fall, system, # conditions out, latch, reset, # coils calc, # math copy, blockcopy, fill, # data movement to_value, to_ascii, to_text, to_binary, # copy converters pack_bits, pack_words, pack_text, # pack unpack_to_bits, unpack_to_words, # unpack on_delay, off_delay, # timers count_up, count_down, # counters shift, event_drum, time_drum, search, # drum/shift/search call, subroutine, forloop, return_early, # program control send, receive, ModbusTcpTarget, # communication ) from pyrung.click import x, y, c, ds, dd, dh, df, t, td, ct, ctd, sc, sd, txt, xd, yd, TagMap ``` ## Memory banks | Bank | pyrung | Type | Range | Notes | | ---- | ------ | ---- | ------ | ---------------------- | | X | `x` | BOOL | 1-816 | **Sparse** — see below | | Y | `y` | BOOL | 1-816 | **Sparse** — see below | | C | `c` | BOOL | 1-2000 | Bit memory | | DS | `ds` | INT | 1-4500 | 16-bit signed | | DD | `dd` | DINT | 1-1000 | 32-bit signed | | DH | `dh` | WORD | 1-500 | 16-bit unsigned | | DF | `df` | REAL | 1-500 | 32-bit float | | T | `t` | BOOL | 1-500 | Timer done bits | | TD | `td` | INT | 1-500 | Timer accumulators | | CT | `ct` | BOOL | 1-250 | Counter done bits | | CTD | `ctd` | DINT | 1-250 | Counter accumulators | | SC | `sc` | BOOL | 1-1000 | System control bits | | SD | `sd` | INT | 1-1000 | System data words | | TXT | `txt` | CHAR | 1-1000 | Text memory | | XD | `xd` | WORD | 0-8 | Input word images | | YD | `yd` | WORD | 0-8 | Output word images | ### Sparse X/Y banks X and Y use module-based addressing. Valid address ranges: ``` 1-16, 21-36, 101-116, 201-216, 301-316, 401-416, 501-516, 601-616, 701-716, 801-816 ``` `.select()` automatically filters to valid addresses: `x.select(1, 21)` yields X001..X016 and X021. ## System points Access via `system..`. Import: `from pyrung import system`. ### Clocks (`system.sys`) | Point | Click addr | Behavior | | ------------------------ | ---------- | ------------------- | | `system.sys.clock_10ms` | SC4 | Toggles every 10ms | | `system.sys.clock_100ms` | SC5 | Toggles every 100ms | | `system.sys.clock_500ms` | SC6 | Toggles every 500ms | | `system.sys.clock_1s` | SC7 | Toggles every 1s | | `system.sys.clock_1m` | SC8 | Toggles every 1min | | `system.sys.clock_1h` | SC9 | Toggles every 1hr | ### Other sys points | Point | Click addr | Notes | | --------------------------------- | ---------- | -------------------- | | `system.sys.always_on` | SC1 | Constant True | | `system.sys.first_scan` | SC2 | True on scan 0 only | | `system.sys.scan_clock_toggle` | SC3 | Alternates each scan | | `system.sys.mode_run` | SC11 | True in RUN mode | | `system.sys.scan_counter` | SD9 | Current scan count | | `system.sys.scan_time_current_ms` | SD10 | Current scan time | ### Fault flags (`system.fault`) | Point | Click addr | | ----------------------------------- | ---------- | | `system.fault.plc_error` | SC19 | | `system.fault.division_error` | SC40 | | `system.fault.out_of_range` | SC43 | | `system.fault.address_error` | SC44 | | `system.fault.math_operation_error` | SC46 | | `system.fault.code` | SD1 | ### Real-time clock (`system.rtc`) `year4`, `year2`, `month`, `day`, `weekday`, `hour`, `minute`, `second` (read-only, SD19-SD26). Set via `new_year4`, `new_month`, `new_day`, `new_hour`, `new_minute`, `new_second` + `apply_date` / `apply_time`. ## Conditions ``` with Rung(Tag): # normally open (truthy) with Rung(~Tag): # normally closed (falsy) with Rung(rise(Tag)): # rising edge (one scan) with Rung(fall(Tag)): # falling edge (one scan) with Rung(Temp > 100): # comparison (== != < <= > >=) with Rung(A, B, C): # AND (comma = all must be True) with Rung(And(A, B, C)): # AND (explicit) with Rung(Or(A, B)): # OR with Rung(Or(Start, And(Auto, Ready))): # nested AND/OR ``` Click requires explicit comparisons for INT tags — use `Step != 0` instead of bare `Step`. ## Coils ``` out(Light) # follows rung: True when rung True, False when False out(Light, oneshot=True) # True for one scan on rising edge latch(Motor) # set and hold until reset reset(Motor) # clear latch out(ValveB.immediate) # immediate I/O (bypasses image table) ``` ## Math ``` calc(A + B, Result) # Result = A + B (wraps on overflow) calc(A * 2, R, oneshot=True) # one-shot: execute once per rising edge calc(DH[1] | DH[2], DH[3]) # WORD-only → hex mode (unsigned) calc(DS.select(1, 10).sum(), Total) # sum a range ``` - `calc()` wraps on overflow (modular arithmetic). `copy()` clamps. - Division by zero → result = 0, fault flag set. - Integer division truncates toward zero. - Don't mix WORD and non-WORD tags in one `calc()`. ## Data movement ``` copy(Source, Dest) # single value (clamps) copy(42, DS[1]) # literal copy(DS[1], DS[DS[0]]) # indirect addressing blockcopy(DS.select(1, 10), DS.select(11, 20)) # range copy fill(0, DS.select(1, 100)) # fill range with constant ``` ### Copy converters ``` copy(CharTag, DS[1], convert=to_value) # CHAR '5' → 5 copy(CharTag, DS[1], convert=to_ascii) # CHAR '5' → 53 copy(DS[1], Txt[1], convert=to_text()) # 123 → "123" copy(DS[1], Txt[1], convert=to_binary) # raw byte ``` ### Pack / unpack ``` pack_bits(C.select(1, 16), DS[1]) # 16 bools → INT unpack_to_bits(DS[1], C.select(1, 16)) # INT → 16 bools pack_words(DS.select(1, 2), DD[1]) # 2 INTs → DINT unpack_to_words(DD[1], DS.select(1, 2)) # DINT → 2 INTs ``` ## Timers Built-in `Timer` type: `.Done` (Bool) + `.Acc` (Int). Units: `"ms"`, `"sec"`, `"min"`, `"hour"`, `"day"` (default: `"ms"`). ``` MyTimer = Timer.clone("MyTimer") # TON — auto-reset when rung goes False on_delay(MyTimer, 500) # RTON — retentive, needs manual reset on_delay(MyTimer, 500).reset(ResetBtn) # TOF — off-delay off_delay(CoolDown, 500) ``` ## Counters Built-in `Counter` type: `.Done` (Bool) + `.Acc` (Dint). Counts every scan while True — use `rise()` for edge-triggered. ``` PartCounter = Counter.clone("PartCounter") count_up(PartCounter, 100).reset(ResetBtn) count_down(Dispense, 100).reset(ResetBtn) # Edge-triggered counting with Rung(rise(Sensor)): count_up(PartCounter, 9999).reset(CountReset) # Bidirectional count_up(ZoneCounter, 100).down(DownCondition).reset(ResetBtn) ``` ## Program structure ``` with Program() as logic: comment("Section header") with Rung(A): out(X) with branch(B): # branch ANDs with parent rung out(Y) # Subroutines with Program() as logic: with subroutine("init"): with Rung(): out(InitLight) with Rung(AutoMode): call("init") # For loops with Rung(): with forloop(5) as loop: copy(Src[loop.idx + 1], Dst[loop.idx + 1]) ``` ## Shift register ``` with Rung(DataBit): shift(C.select(1, 8)).clock(ClockBit).reset(ResetBit) ``` ## Search ``` search(DS.select(1, 100) >= 100, result=FoundAddr, found=FoundFlag) search(DS.select(1, 100) >= 100, result=Addr, found=Flag, continuous=True) search(Txt.select(1, 50) == "AB", result=Addr, found=Flag) # text search ``` ## Communication ``` peer = ModbusTcpTarget("peer", "192.168.1.10") with Rung(Enable): send(target=peer, remote_start="DS1", source=DS.select(1, 10), sending=Sending, success=SendOK, error=SendErr, exception_response=ExCode) with Rung(Enable): receive(target=peer, remote_start="DS1", dest=DS.select(11, 20), receiving=Receiving, success=RecvOK, error=RecvErr, exception_response=ExCode) ``` ## Named arrays Single-type structures for grouping related registers. The most common way to organize Click data. ``` from pyrung import named_array, Int, Real @named_array(Int, count=4) class Sensor: Raw = 0 Scaled = 0 Setpoint = 100 Sensor[1].Raw # first sensor's raw reading Sensor[3].Setpoint # third sensor's setpoint Sensor.select(1, 3) # fields 1-3 as a BlockRange Sensor.instance(2) # all fields for instance 2 Sensor.instance_select(1, 2) # all fields for instances 1-2 ``` Use `.instance()` and `.instance_select()` with range instructions: ``` blockcopy(Sensor.instance(2), ds.select(201, 203)) fill(0, Sensor.instance_select(1, 4)) ``` ### Mapping named arrays to hardware ``` Sensor.map_to(ds.select(101, 112)) # 4 instances * 3 fields = 12 slots # Sensor[1].Raw → DS101, Sensor[1].Scaled → DS102, Sensor[1].Setpoint → DS103 # Sensor[2].Raw → DS104, ... ``` For UDTs (mixed-type structures) and advanced options like stride, cloning, and `auto()` defaults, see [Tag Structures](https://ssweber.github.io/pyrung/guides/tag-structures/index.md). ## Common patterns ### EMA filter (exponential moving average) Formula: ``` Avg = Avg + (Raw - Avg) * (FilterFactor / 10) ``` In pyrung: ``` with Rung(rise(system.sys.clock_500ms)): calc(Avg + (Raw - Avg) * (FilterFactor / 10), Avg) ``` `FilterFactor` range: `1-9` - Low (`1-3`): heavy smoothing, slow response, best for noisy signals - Mid (`4-6`): balanced smoothing and response - High (`7-9`): light smoothing, fast response, best for clean signals Adjust `FilterFactor` based on: - Sensor noise level - Required response time - Process stability needs ### Oneshot on first scan ``` with Rung(system.sys.first_scan): copy(DefaultValue, Parameter) ``` ### Timed periodic action ``` with Rung(rise(system.sys.clock_1s)): calc(Counter + 1, Counter) ``` ### Timer-driven state machine Use `on_delay` per state, `copy` to advance on done: ``` State = Char("State") GreenTimer = Timer.clone("GreenTimer") YellowTimer = Timer.clone("YellowTimer") RedTimer = Timer.clone("RedTimer") with Rung(State == "g"): on_delay(GreenTimer, 3000) with Rung(GreenTimer.Done): copy("y", State) with Rung(State == "y"): on_delay(YellowTimer, 1000) with Rung(YellowTimer.Done): copy("r", State) with Rung(State == "r"): on_delay(RedTimer, 3000) with Rung(RedTimer.Done): copy("g", State) ``` ### Shift log (history window) Shift a range up, then write newest into slot 1: ``` DS = Block("DS", TagType.INT, 1, 5) with Rung(rise(LogEnable)): blockcopy(DS.select(1, 4), DS.select(2, 5)) # shift up copy(NewValue, DS[1]) # newest into slot 1 ``` ### Task sequencer For step-based task sequencing with Call/Active/Step/Advance patterns, see [simple_task_example.py](https://github.com/ssweber/pyrung/blob/main/examples/simple_task_example.py) and [task_example.py](https://github.com/ssweber/pyrung/blob/main/examples/task_example.py). ## TagMap ``` mapping = TagMap({ StartButton: x[1], # single tag → single address MotorRunning: y[1], Speed: df[1], Alarms: c.select(101, 200), # block → hardware range }) # Validate against Click hardware restrictions report = mapping.validate(logic, mode="warn") ``` Built-in `Timer` and `Counter` UDTs are automatically mapped — `Timer[n].Done` → T*n*, `Timer[n].Acc` → TD*n*, etc. No explicit entries needed. Named arrays use `.map_to()` instead of TagMap — see [Named arrays](#named-arrays) above. # Your First Click PLC Program Write a motor start/stop circuit, test it, map it to Click hardware addresses, export ladder CSV, and load it into Click via [ClickNick](https://github.com/ssweber/clicknick). ## The program A sealed motor circuit: press Start to latch the motor on, press Stop to reset it off. A speed input copies through only while the motor runs. ``` from pyrung import Bool, Real, PLC, Program, Rung, copy, latch, reset, rise from pyrung.click import x, y, ds, df, TagMap # Semantic tags — no hardware addresses yet StartButton = Bool("StartButton") StopButton = Bool("StopButton") MotorRunning = Bool("MotorRunning") Speed = Real("Speed") DisplaySpeed = Real("DisplaySpeed") with Program() as logic: with Rung(rise(StartButton)): latch(MotorRunning) with Rung(rise(StopButton)): reset(MotorRunning) with Rung(MotorRunning): copy(Speed, DisplaySpeed) ``` `rise()` triggers on the rising edge — one scan pulse when the button transitions from off to on. Without it, holding Start would re-latch every scan (harmless here, but wrong for counting or toggling). ## Test it ``` def test_motor_start_stop(): with PLC(logic, dt=0.1) as plc: # Start the motor StartButton.value = True plc.step() assert MotorRunning.value is True # Release button — motor stays latched StartButton.value = False plc.run(cycles=5) assert MotorRunning.value is True # Stop the motor StopButton.value = True plc.step() assert MotorRunning.value is False # Speed only copies while running StopButton.value = False StartButton.value = True Speed.value = 75.0 plc.step() assert DisplaySpeed.value == 75.0 StartButton.value = False StopButton.value = True plc.step() Speed.value = 99.0 plc.step() assert DisplaySpeed.value == 75.0 # Didn't update — motor is off ``` Same logic, deterministic timing, real assertions. Run with `pytest`. ## Map to Click hardware Once the logic is correct, map semantic tags to Click memory addresses: ``` mapping = TagMap({ StartButton: x[1], # X001 — discrete input StopButton: x[2], # X002 MotorRunning: y[1], # Y001 — discrete output Speed: df[1], # DF1 — float register (analog input) DisplaySpeed: df[11], # DF11 }) ``` Validate that the program fits Click's constraints: ``` report = mapping.validate(logic, mode="warn") print(report.summary()) ``` The validator checks type compatibility, instruction support, and addressing limits. Fix any findings, then tighten to `mode="strict"` when the program is clean. ## Export ladder CSV ``` from pyrung.click import pyrung_to_ladder bundle = pyrung_to_ladder(logic, mapping) bundle.write("./output") # writes main.csv + subroutines/*.csv ``` This produces deterministic Click ladder CSV files ready for import. ## Load into Click via ClickNick [ClickNick](https://github.com/ssweber/clicknick) loads the exported CSV into Click Programming Software via the clipboard. In the GUI, use **Ladder → Open in Guided Paste...** and point it at the output folder. Or from the command line: ``` clicknick-rung program load ./output ``` Either way, ClickNick walks you through pasting each rung and subroutine into Click. The addresses, nicknames, and logic are all wired up. ## Next steps - [Click PLC Dialect](https://ssweber.github.io/pyrung/dialects/click/index.md) — pre-built blocks, TagMap details, validation findings, nickname CSV I/O - [Click Python Codegen](https://ssweber.github.io/pyrung/dialects/click-codegen/index.md) — round-trip: import Click ladder CSV back into pyrung - [Testing Guide](https://ssweber.github.io/pyrung/guides/testing/index.md) — forces, time travel, forking, pytest patterns - [ClickNick](https://github.com/ssweber/clicknick) — the companion tool for Click clipboard I/O and nickname management # The Commissioning Workflow You wrote logic and tested it. Now close the loop: declare what your program talks to, prove it's correct, and commission it with confidence. pyrung's commissioning workflow has four stages. Each builds on the last. ## Declare: tell pyrung about the real world Annotate your UDT fields with `physical=` to describe how feedback behaves, and `link=` to connect it to the command that drives it. The autoharness reads these annotations and synthesizes feedback patches in tests automatically. ``` @udt() class ConveyorIO: Motor: Bool = Field(public=True) MotorFb: Bool = Field( external=True, physical=Physical("MotorFb", on_delay="500ms", off_delay="200ms"), link="Motor", ) ``` `physical=` describes timing — how long the real device takes to respond. `link=` tells the harness which command drives this feedback. Tag flags (`public`, `external`, `readonly`, `final`) declare intent and are enforced by static validators. Standalone tags can use `link=` too — useful for process-level physics like "diverter fires → box arrives at sensor." See [Physical Annotations and Autoharness](https://ssweber.github.io/pyrung/guides/physical-harness/index.md) for the full guide: standalone tags, value triggers, profile functions, validation, and forces override behavior. ## Analyze: inspect the program Three layers of static and dynamic analysis, all usable from plain pytest: ``` with PLC(logic) as plc: dv = plc.dataview dv.inputs() # what the program reads dv.upstream("MotorOut") # everything that feeds MotorOut chain = plc.cause(Running) # why did Running turn on? plc.query.cold_rungs() # which rungs never fired? ``` - **`plc.dataview`** — chainable queries over the program's dependency graph. Filter by role, name, or upstream/downstream slice. - **`plc.cause()` / `plc.effect()`** — causal chain analysis. What caused a transition, what it caused downstream, and what-if projections with `assume=`. - **`plc.query`** — test coverage surveys. Cold rungs, hot rungs, stranded bits with blocker diagnostics. Merge across a test suite with the pytest plugin. Static validators run at build time via `logic.validate()` — conflicting outputs, stuck bits, readonly writes, pointer defaults below block starts, choices violations, and physical realism checks. See [Analysis](https://ssweber.github.io/pyrung/guides/analysis/index.md) for the full guide. ## Verify: prove it holds Analysis answers questions about recorded history. Verification answers a different question: does a property hold across **every** reachable state? ``` from pyrung.core.analysis import prove, Proven result = prove(logic, Or(~Running, EstopOK)) assert isinstance(result, Proven) ``` `prove()` exhaustively explores every reachable state via BFS. Pair it with `harness.couplings()` for automated fault coverage — batch all conditions into a single `prove()` call to share work across properties: ``` couplings = list(harness.couplings()) conditions = [ Or(~plc.tags[c.en_name], plc.tags[c.fb_name], AlarmExtent != 0) for c in couplings ] results = prove(logic, conditions) ``` Lock files capture reachable behavior as a committed artifact. `pyrung lock` writes it; `pyrung check` diffs against it in CI. Behavioral changes show up in PRs. See [Verification](https://ssweber.github.io/pyrung/guides/verification/index.md) for the full guide: condition syntax, result types, fault coverage workflows, lock file configuration, and CLI reference. ## Commission: run against hardware With physical annotations in place, the VS Code debugger auto-installs the harness. Step through scans, force tags, and watch values live. - **[VS Code Debugger](https://ssweber.github.io/pyrung/guides/dap-vscode/index.md)** — breakpoints, monitors, Data View, Graph View, history scrubber with Chain tab for causal queries. - **[pyrung live](https://ssweber.github.io/pyrung/guides/dap-vscode/#pyrung-live)** — attach to a running debug session from another terminal. Chain commands, force tags, run causal queries. Pair VS Code (human stepping through scans) with `pyrung live` (an LLM or script running causal queries and forcing tags) for assisted commissioning. - **[Session capture](https://ssweber.github.io/pyrung/guides/dap-vscode/#session-capture)** — record a debug session as a replayable transcript. Condense to a minimal reproducer, mine invariants, generate pytest files. For hardware deployment, see [Click PLC](https://ssweber.github.io/pyrung/dialects/click/index.md) (TagMap, validation, soft-PLC) or [CircuitPython](https://ssweber.github.io/pyrung/dialects/circuitpy/index.md) (P1AM code generation). ## Where to go from here - [Physical Annotations](https://ssweber.github.io/pyrung/guides/physical-harness/index.md) — declare device behavior, autoharness - [Analysis](https://ssweber.github.io/pyrung/guides/analysis/index.md) — dataview, cause/effect, coverage queries, static validators - [Verification](https://ssweber.github.io/pyrung/guides/verification/index.md) — prove(), fault coverage, lock files - [Testing](https://ssweber.github.io/pyrung/guides/testing/index.md) — pytest patterns, forces, bounds checking - [VS Code Debugger](https://ssweber.github.io/pyrung/guides/dap-vscode/index.md) — step through scans live # DAP Debugger in VS Code pyrung includes a Debug Adapter Protocol (DAP) server that exposes PLC scan execution to VS Code. ## Features - Source-line breakpoints - Conditional breakpoints using the pyrung condition DSL - Hit-count breakpoints - Logpoints - Snapshot logpoints with `Snapshot: label` - Data breakpoints for monitored tags - Monitor values in the Variables panel under `PLC Monitors` - Custom debug events in Output channel `pyrung: Debug Events` - Trace decorations and inline condition annotations ## Requirements - VS Code with the Python extension - `pyrung` installed: `pip install pyrung` - The pyrung VS Code extension — download `pyrung-debug-0.1.0.vsix` from the [GitHub releases](https://github.com/ssweber/pyrung/releases) page, then install: ``` code --install-extension pyrung-debug-0.1.0.vsix ``` ## Launch configuration Add to `.vscode/launch.json`: ``` { "version": "0.2.0", "configurations": [ { "name": "pyrung DAP", "type": "pyrung", "request": "launch", "program": "${file}" } ] } ``` ## Breakpoints - Stop on a rung: click gutter - Conditional breakpoint: right-click gutter -> Add Conditional Breakpoint - Hit count: edit breakpoint hit count (fires on every Nth hit: N, 2N, 3N...) - Logpoint: right-click gutter -> Add Logpoint - Snapshot logpoint: set log message to `Snapshot: my_label` - Logpoints and snapshot logpoints fire during both Continue and stepping commands. Condition expressions use the pyrung DSL, for example: - `Fault` - `~Fault` - `MotorTemp > 100` - `Fault, Pump` - `Running | (Mode == 1)` - `And(Fault, Pump)` - `Or(Low, High)` When you use `&` or `|` with comparisons, parenthesize the comparison terms: - Valid: `Fault & (MotorTemp > 100)` - Valid: `Running | (Mode == 1)` - Invalid: `Fault & MotorTemp > 100` ## Monitors Commands: - `pyrung: Add Monitor` - `pyrung: Remove Monitor` - `pyrung: Find Label` The status bar shows `M:` while a pyrung debug session is active. Monitors appear in: - Variables panel scope: `PLC Monitors` - Output channel: `pyrung: Debug Events` ## Data breakpoints After adding a monitor, you can set a data breakpoint from the monitored variable to stop when its value changes. ## Watch expressions Use the VS Code `Watch` panel for read-only expression evaluation. - Bare tag/memory names return the current raw value (`Counter`, `Fault`, `Step[CurStep]`). - Predicate expressions return `True`/`False` (`Fault & (MotorTemp > 100)`, `Mode == 1`). - Unknown names fail with an explicit error so typos are visible. Watch evaluation uses the same visible state as the Variables panel during stepping, including pending mid-scan values. ## Debug Console The Debug Console accepts typed commands for all PLC operations. Use `help` to list them, or `Watch` for predicate evaluation. ### Forces and patches ``` force Button true # persistent override, held across scans unforce Button # remove a force clear_forces # remove all forces patch Button true # one-shot input, consumed after one scan ``` ### Stepping and running ``` step # advance one scan step 5 # advance 5 scans run 100 # run 100 scans run 500ms # run for 500ms of sim time run 2 s # run for 2 seconds (split unit ok) continue # run continuously (background) pause # stop a running continue ``` `step` and `run` process breakpoints and logpoints during execution. If a breakpoint fires, the command stops early and reports it. Duration parsing accepts `ms`, `s`, `min`, `h` — same format as Physical delay declarations. `continue` starts a background scan loop (same as the VS Code Continue button). `pause` signals it to stop after the current scan. ### Causal queries ``` cause Light # why did Light last change? cause Light@5 # why did Light change at scan 5? cause Light:true # how could Light reach true? (projected) effect Button # what did Button's last change cause? effect Button:true # what would happen if Button became true? recovers Light # can Light return to its resting value? ``` ### DataView queries ``` dataview Motor # tags containing "Motor" dataview i: # all input tags dataview p:Motor # pivots containing "Motor" dataview upstream:Light # upstream dependencies of Light dataview downstream:Button # downstream effects of Button upstream Light # shorthand for dataview upstream:Light downstream Button # shorthand for dataview downstream:Button ``` The query language supports role prefixes (`i:` inputs, `p:` pivots, `t:` terminals, `x:` isolated) and slice prefixes (`upstream:`, `downstream:`). Multiple tokens are applied left to right. ### Simplified form ``` simplified # show all terminals' simplified Boolean forms simplified MotorOut # show one terminal, resolved to inputs ``` ### Monitors ``` monitor Button # watch Button for value changes unmonitor Button # stop watching ``` Monitors also appear in the Variables panel under `PLC Monitors` and can be promoted to data breakpoints. ### Notes and log Annotate the timeline with free-text notes and review recent activity. ``` note testing startup sequence # attach a note to the current scan note momentary start press # another note before the next action log # show last 20 scans of activity log 50 # show last 50 scans ``` `log` shows patches, force changes, tag transitions, and notes — everything that happened in the scan window. When commands come from `pyrung live`, the scan header is tagged with `(live)` so you can tell who did what in a pair-programming session. ``` scan 12 forces: EstopOK=True scan 10: # testing startup sequence scan 11: (live) patch StartBtn True ConveyorMotor: False → True Running: False → True ``` ### Session capture Record a sequence of console commands as a replayable transcript. ``` record start_machine # begin recording action "start_machine" patch State 1 run 500ms patch State 2 step 1 record stop # stop recording, print transcript ``` On stop, the transcript is printed as plain text — one command per line with a `# action:` comment header. The transcript is the session file format: paste it back into the console or save it to a file for replay. If forces are active when recording starts, a warning is printed. ``` replay session.txt # feed a transcript file back through the console ``` Replay reads the file, skips `#` comment lines and blank lines, and executes each command in order. If a breakpoint fires or a command fails, replay halts and reports the line. Commands executed during replay are captured normally — if a recording is active, they appear in the transcript. The `record`, `replay`, and `help` verbs themselves are never captured. ### Autoharness in the debug session If your program uses [Physical annotations](https://ssweber.github.io/pyrung/guides/physical-harness/index.md) with `physical=` and `link=` declarations, the adapter installs the autoharness automatically at launch. A banner appears in the Debug Console: ``` Harness: 3 feedback loop(s) (2 bool, 1 analog) — `harness status` for details ``` No configuration needed — if you annotated your UDTs, the harness activates. Feedback patches land at the same pre-scan phase as manual patches and forces. Forces still win: force a feedback tag to hold it regardless of what the harness schedules. ``` harness status # show couplings, pending patches, active state harness remove # disable the harness for this session harness install # re-install after removal ``` `harness status` lists each coupling with its timing or profile. Value-triggered couplings show the trigger with `==`: ``` Harness: active bool Gripper_En -> Gripper_Fb_Contact (on=5ms, off=5ms) bool Gripper_En -> Gripper_Fb_Vacuum (on=20ms, off=80ms) bool Sorter_State==2 -> Sorter_BinSensor (on=2000ms, off=500ms) analog Heater_En -> Heater_Fb_Temp profile=generic_thermal [active] analog Oven_Mode==2 -> Oven_Temp profile=zone_thermal [active] ``` When the harness applies patches, they appear in the Debug Console output prefixed with `[harness]`: ``` [harness] Gripper_Fb_Contact=True [harness] Heater_Fb_Temp=25.3 ``` If a session recording is active, harness patches are captured with provenance tags (`harness:nominal` for bool feedback, `harness:analog:` for analog). In the transcript they appear as comment lines — visible for review, skipped on replay: ``` # action: test_gripper_cycle patch Cmd true run 100ms # harness:nominal: patch Gripper_Fb_Contact True # harness:nominal: patch Gripper_Fb_Vacuum True # harness:analog:generic_thermal: patch Heater_Fb_Temp 25.3 step 5 ``` On replay, the harness re-synthesizes its own patches from the program state — the comment lines are documentation, not inputs. ## pyrung live Attach to a running debug session from another terminal or process. When the DAP adapter launches, it starts a TCP server on localhost and writes the port to a session file. The session name defaults to the program filename stem (`logic.py` → `logic`) or can be set explicitly in the launch configuration: ``` { "type": "pyrung", "request": "launch", "program": "${file}", "session": "my_session" } ``` ### CLI usage ``` pyrung live # list active sessions pyrung live step 5 # works when only one session is active pyrung live -s my_session step 5 # explicit session when multiple are running pyrung live "force Button true; step 5; cause Light" # chain commands with ; pyrung live -h # show all available commands ``` When only one session is active, `--session` can be omitted. With multiple sessions, the CLI lists them and asks you to pick one. Running with no arguments lists active sessions. Commands can be chained with `;` in a single invocation — they run sequentially over one connection, halting on first error. Every console command works over the live connection — forces, patches, stepping, queries, record/replay. The response is printed to stdout. Exit code is 0 on success, 1 on error. ### Python library ``` from pyrung.dap.live import send_command, list_sessions sessions = list_sessions() # ["logic", "pump_test"] ok, text = send_command("logic", "step 5") print(text) # "Stepped 5 scan(s), now at scan 10" ``` ### Pair commissioning One person drives VS Code (breakpoints, Data View, Graph View), another runs `pyrung live` from a terminal. Both hit the same PLC state. Commands from the live client show `(live)` in the `log` output so you can tell who did what: ``` scan 10: patch StartBtn True Running: False → True scan 11: (live) force EntrySensor True State: 0 → 1 ``` The live client can do everything the Debug Console can — forces, patches, causal queries, recording. Stepping from either side advances the same scan counter. ### How it works The server is a single-threaded TCP listener that dispatches commands through the same `console.dispatch()` path as the Debug Console. Commands are serialized by the adapter's state lock — a live client waits if a scan is in progress. Session discovery uses port files in the system temp directory (`/pyrung/pyrung-.port`). The file is created on launch and removed on disconnect. Stale port files from crashed processes are pruned automatically when listing sessions. ## Hot-reload Re-execute the program file while preserving PLC state — tags, memory, timer accumulators, counter values, and forces all carry over. History clears. Same experience as downloading a new program to a running PLC. ### Manual reload ``` reload # from the Debug Console pyrung live reload # from another terminal ``` The reload re-runs your program file, discovers the new runner, and patches the old state onto it. Scan ID and timestamp continue from where they were. Forces are re-applied. Debug registrations (breakpoints, monitors) are cleared since rung indices may have shifted. ### Auto-reload on file save ``` watch # start watching the program file unwatch # stop watching ``` `watch` polls the program file's modification time once per second. When it changes (e.g. you save in your editor), the program reloads automatically. No new dependency — uses `os.stat` polling. If `continue` is running when a file change is detected, the watcher skips the reload and logs a message. Pause first, then save to trigger the reload. ### Tag type changes If a tag changes type between reloads (e.g. `Bool('X')` becomes `Int('X')`), the old value is dropped and the tag gets its new default. A warning is printed: ``` Reloaded at scan 42 (5 tag(s)) Warnings: X: type changed BOOL -> INT, using new default ``` New tags added to the program get their defaults. Removed tags become harmless orphans in state. ### Errors If the program file has a syntax error or the runner can't be discovered, the reload aborts and the old runner stays active. Fix the file and reload again. ## DAP to runner mapping | VS Code action | Runner API | | ----------------------------------------- | ----------------------------------------------- | | Step Over / Into / Out / `pyrungStepScan` | `runner.scan_steps_debug()` | | Continue | Adapter loop over `scan_steps_debug()` | | Conditional breakpoints | Adapter expression parser + compiled predicates | | Monitor values | `runner.monitor(tag, callback)` | | Snapshot labels | `runner.history.find_labeled(label)` | | Data breakpoints | Monitor-backed change listeners | | Debug Console commands | `console.dispatch()` registry | See [Architecture — Debug stepping APIs](https://ssweber.github.io/pyrung/guides/architecture/#debug-stepping-apis) for details on `scan_steps_debug()` and rung inspection. ## Data View The Data View panel (in the debug sidebar) shows watched tags with live values, types, and editing controls. ### Adding tags Right-click a tag name in the editor and select **Add to Data View**. Structured tags (UDTs, named arrays) auto-promote to expandable groups with collapsible member rows. ### Editing values - **Bool tags**: click True/False buttons to stage, double-click to write immediately. - **Choice tags**: selecting from the dropdown writes immediately — no "Write Values" click needed. - **Other types**: type a value, then click **Write Values** to patch all pending values in one scan. - **Force**: click the Force button to lock a tag to its staged value across scans. Click again to unforce. ### Tag flag badges Badges appear next to the tag name when flags are set: - **RO** — read-only tag. Editing controls are locked by default. Click the lock icon to unlock for debugging; click again to re-lock. - **P** — public tag. Part of the intended API surface (setpoints, mode commands, status bits). ### Public filter A **Public** checkbox sits above the tag table. It starts disabled and greyed out ("Start debugger to enable"). Once the debugger sends tag metadata, it enables. When checked, only tags declared `public=True` are shown — plumbing tags are hidden. Group headers hide when none of their members are public. Unchecking restores all rows. The filter resets when the debug session ends. ## History The History panel (in the debug sidebar) has two tabs: **Tags** and **Chain**. ### Tags tab Shows tag values at each retained scan. The slider scrubs through the scan cache; tag values update live as you drag. Values update automatically during `continue` runs. Right-click a tag to add it from the Data View or Graph View. ### Chain tab Run causal queries interactively. Type a query in the input field using the same syntax as the `cause`/`effect`/`recovers` console commands: ``` cause:MotorOut effect:StartBtn@1 recovers:FaultLatch cause:Running:True ``` The result renders inline — chain steps with proximate causes, enabling conditions, and fidelity indicators. The query dispatches to the `pyrungCausal` DAP handler, which calls the same `plc.cause()`/`plc.effect()`/`plc.recovers()` methods available in tests. ## Graph View **pyrung: Open Graph View** opens an interactive tag dependency graph in the editor area. The graph shows tags as nodes and rungs as edges connecting them, laid out left-to-right with dagre. ### Node roles Tags are colored by role: blue for inputs (nothing writes them), amber for pivots (read and written), green for terminals (written, nothing reads them), grey for isolated (no connections). Rung nodes show the rung index and source location. ### Interactions - **Click** a tag to highlight its direct neighbors. - **Double-click** a tag to slice the graph to its upstream and downstream dependencies. - **Right-click** a tag for a context menu: slice upstream, slice downstream, add to Data View, add to History, copy name, hide. - **Right-click** a rung to go to source or copy rung info. - **Drag** a node to pin its position (persisted in workspace state). - **Search** filters tags by name with abbreviation matching (typing `btn` finds `StartButton`). - **Role toggles** (I/P/T/X buttons) show or hide nodes by role. - **Rung Order** sorts the layout vertically by execution order for a ladder-like top-down view. - **Reset** clears all pins, hidden nodes, filters, and slices. During debugging, live value badges appear on tag nodes. The graph rescopes automatically when you switch editor tabs, showing only the rungs and tags from the active file. ## Trace event The adapter emits `pyrungTrace` after each stop with the current step and region evaluation details used by decorations. # Physical Annotations and Autoharness Once you have a working program with UDTs, you can annotate the physical behavior of feedback signals. On a feedback field, `physical=` describes the feedback response and `link=` names the command/enable field that drives it. The autoharness reads those annotations and synthesizes feedback patches in tests automatically, so you stop writing boilerplate that toggles inputs by hand. ## The problem A typical device-heavy test file is 80% feedback toggling: ``` def test_gripper_cycle(): with PLC(logic, dt=0.001) as plc: Cmd.value = True plc.step() # En rises plc.run_for(0.005) Gripper[1].Fb_Contact.value = True # manual toggle plc.run_for(0.075) Gripper[1].Fb_Vacuum.value = True # manual toggle plc.run_for(0.050) assert Gripper[1].Sts.value is True ``` Twenty devices, twenty feedback loops, twenty blocks of this — maintained by hand, diverging from reality over time, and wrong in subtle ways when someone changes a delay in the test but not the device, or vice versa. ## Declaring physical behavior `Physical` describes how a feedback signal responds in the real world. There are two kinds: **Bool feedback** — a signal that asserts or deasserts after a delay (limit switches, proximity sensors, pressure switches): ``` from pyrung import Physical LIMIT_SWITCH = Physical("LimitSwitch", on_delay="5ms", off_delay="5ms") VACUUM_SENSOR = Physical("VacuumSensor", on_delay="80ms", off_delay="50ms") ``` **Profile-driven feedback** — a signal driven by a custom response function (thermocouples, pressure transmitters, flow meters, shaft encoders): ``` THERMOCOUPLE = Physical("Thermocouple", profile="generic_thermal") ENCODER = Physical("Encoder", profile="shaft_encoder") ``` A `Physical` has either timing (on_delay/off_delay) or a profile name, never both. Bool fields accept either form — use timing for simple delayed transitions (contactors, limit switches) and profiles for signals that need custom state like pulse trains. Delays accept duration strings: `"5ms"`, `"2s"`, `"1s500ms"`. ## Linking feedback to commands The `link=` field on a `Field` declaration says "this feedback responds to that command." The `physical=` field says how: ``` from pyrung import udt, Bool, Real, Field @udt() class Gripper: Cmd: Bool = Field(public=True) Sts: Bool = Field(public=True, final=True) En: Bool Fb_Contact: Bool = Field(physical=LIMIT_SWITCH, link="En") Fb_Vacuum: Bool = Field(physical=VACUUM_SENSOR, link="En") ``` `Fb_Contact` and `Fb_Vacuum` both link to `En`. When `En` rises, both feedback signals will respond — each with the timing declared by their `Physical`. The link must refer to a field in the same structure. Do not put `physical=` on `En` just because `En` represents a real output. The harness discovers couplings from linked feedback fields (`Fb_*` with `link="En"`). An unlinked bool `physical=` annotation is metadata only and does not create a harness loop. ### Standalone tags — linking across the program `link=` also works on standalone tags, not just UDT fields. This is useful for modeling process physics — responses that happen in the real world but aren't electrical feedback on the same device. A conveyor sorts large boxes by extending a diverter. After the diverter fires, a box arrives at the bin sensor — that's a physical consequence with a real delay: ``` from pyrung import Bool, Physical DiverterCmd = Bool("DiverterCmd") BinSensor = Bool("BinSensor", physical=Physical("BinSensor", on_delay="2s", off_delay="500ms"), link="DiverterCmd", ) ``` When `DiverterCmd` goes True, the harness schedules `BinSensor=True` 2 seconds later. When it drops, `BinSensor` clears after 500ms. No UDT needed — the link names any tag in the program. The distinction: UDT links model device-level feedback (motor command → motor contactor feedback). Standalone links model process-level physics (diverter fires → box arrives at bin). Both use the same `Physical` timing and the same harness machinery. ### Value triggers — linking to specific states Plain `link=` watches for bool edges (truthy ↔ falsy). When the enable tag is an Int with a choices map — a state machine, a mode selector — you often want feedback to fire when the tag enters a *specific* value, not just any nonzero value. The `link="Tag:value"` syntax triggers on a specific value. The part after the colon is either a choices label or a literal integer: ``` from pyrung import Int, Bool, Field, Physical, udt, named_array @named_array(Int, count=1) class SortState: IDLE = 0 RUNNING = 1 SORTING = 2 @udt() class Sorter: State: Int = Field(choices=SortState) BinSensor: Bool = Field( physical=Physical("BinSensor", on_delay="2s", off_delay="500ms"), link="State:SORTING", ) ``` When `State` transitions to the value matching `SORTING` (2), the harness schedules `BinSensor=True` after 2 seconds. When `State` transitions away from that value — to anything else — the harness schedules `BinSensor=False` after 500ms. Both forms are valid: - `link="State:SORTING"` — resolves `SORTING` through the tag's choices map - `link="State:2"` — uses the literal integer directly, no choices map needed If the part after the colon is a valid integer literal, it's used directly. Otherwise it's looked up in the enable tag's choices map. A missing choices map is only an error when the value isn't a numeric literal. Value triggers also work on Char tags for string matching: ``` Status = Char("Status") Ready = Bool("Ready", physical=Physical("Ready", on_delay="100ms", off_delay="50ms"), link="Status:Y", ) ``` Value triggers work with profile-driven feedback too. The profile function receives `en=True` when the enable tag matches the trigger value, `en=False` otherwise — the same interface as a plain bool link: ``` THERMOCOUPLE = Physical("Thermocouple", profile="zone_thermal") @udt() class Oven: Mode: Int = Field(choices={0: "OFF", 1: "PREHEAT", 2: "BAKE"}) Temp: Real = Field(physical=THERMOCOUPLE, link="Mode:BAKE", min=0, max=300, uom="degC") ``` When `Mode` enters `BAKE`, the profile sees `en=True` and ramps up. When `Mode` leaves `BAKE`, it sees `en=False` and can model ambient decay. Multiple feedback fields can watch the same enable tag with different trigger values: ``` @udt() class Station: State: Int = Field(choices={0: "IDLE", 1: "RUNNING", 2: "SORTING"}) RunFb: Bool = Field(physical=FAST_SENSOR, link="State:RUNNING") SortFb: Bool = Field(physical=LIMIT_SWITCH, link="State:SORTING") ``` A transition from `RUNNING` to `SORTING` fires `RunFb` off-edge and `SortFb` on-edge simultaneously. Analog feedback works the same way, with `profile=` on the `Physical`: ``` THERMOCOUPLE = Physical("Thermocouple", profile="generic_thermal") @udt() class Heater: Cmd: Bool = Field(public=True) Sts: Bool = Field(public=True, final=True) En: Bool Fb_Contact: Bool = Field(physical=LIMIT_SWITCH, link="En") Fb_Temp: Real = Field(physical=THERMOCOUPLE, link="En", min=0, max=250, uom="degC") ``` `Fb_Contact` is bool — the harness drives it with on/off delays. `Fb_Temp` is analog — the harness drives it with a profile function. Both link to the same `En` and respond independently. ## Using the autoharness Install a `Harness` on a PLC and it synthesizes all feedback patches automatically: ``` from pyrung import Harness, PLC with PLC(logic, dt=0.010) as plc: harness = Harness(plc) harness.install() Cmd.value = True plc.run_for(0.200) assert Gripper[1].Sts.value is True ``` No manual feedback toggling. The harness discovered the `En → Fb_Contact` and `En → Fb_Vacuum` couplings from the UDT declaration, installed edge monitors on `En`, and scheduled `Fb` patches using the declared timing. ### How bool feedback works When the harness sees `En` rise, it schedules `Fb=True` at `now + on_delay`. When `En` falls, it schedules `Fb=False` at `now + off_delay`. Delays are rounded up to scan ticks based on the PLC's `dt`: | `on_delay` | `dt` | Ticks | | ---------- | ------- | ----------- | | `20ms` | `0.010` | 2 | | `20ms` | `0.001` | 20 | | `20ms` | `0.100` | 1 (minimum) | A scheduled patch always arrives at least 1 tick later — you can't schedule in the past. Multiple `Fb` fields linked to the same `En` schedule independently, each with its own `Physical` timing. A vacuum gripper's `Fb_Contact` (5ms) and `Fb_Vacuum` (80ms) arrive at different times from the same `En` edge. ### How profile-driven feedback works Profile-driven feedback delegates to a registered profile function. Register one with the `@profile` decorator: ``` from pyrung import profile @profile("generic_thermal") def generic_thermal(cur, en, dt): if en: return cur + 0.5 * dt # 0.5 degrees per second return cur # hold on En fall ``` The function is called once per scan tick while the coupling is active. It receives: - `cur` — current value of the Fb tag - `en` — current state of the linked En (`True`/`False`) - `dt` — PLC scan period in seconds Write rate-per-second math; `dt` makes the result stable across scan rates. A profile running at `dt=0.001` and `dt=0.100` should converge to the same value over the same wall-clock duration. The program's own logic controls when `En` drops. A heater program turns off `En` when `Fb_Temp` hits the setpoint — the profile was ramping upward, but the program cut it off at 180°C. The harness doesn't need to know the settling point; the program does. ``` @profile("120BTU_burner") def burner_120btu(cur, en, dt): if en: return cur + 0.8 * dt # 0.8 degrees per second return cur - 0.05 * dt # slow ambient decay @profile("generic_pressure") def generic_pressure(cur, en, dt): if en: return cur + 10.0 * dt # 10 PSI per second return cur - 5.0 * dt # bleed down ``` ### Bool fields with profiles Profiles aren't limited to analog tags. A Bool field can use `profile=` instead of `on_delay`/`off_delay` when the feedback needs custom state — the most common case is a discrete pulse sensor like a shaft encoder or flow meter pulse output. Since `cur` is a Bool (`True`/`False`), it can't carry phase state. Use a closure: ``` ENCODER = Physical("Encoder", profile="shaft_encoder") def make_encoder_profile(rpm=60): phase = [0.0] period = 60.0 / rpm @profile("shaft_encoder") def shaft_encoder(cur, en, dt): if not en: phase[0] = 0.0 return False phase[0] += dt return (phase[0] % period) < (period / 2) return shaft_encoder @udt() class Conveyor: En: Bool Fb_Encoder: Bool = Field(physical=ENCODER, link="En") ``` The closure holds the accumulated phase; the profile toggles the Bool at the right frequency. A counter instruction in the logic counts the rising edges — the harness produces the pulse train, the program counts it. One profile registration per name, so two encoders at different RPMs need two profile names. ## Validation pyrung validates UDT and named-array field annotations at construction time: - **Bool Fb field + `link=` but no physical** — rejected. A linked bool feedback field must declare either `physical=Physical(..., on_delay=..., off_delay=...)` or `physical=Physical(..., profile=...)`. - **Physical profile without `link=`** — rejected on tags and fields. A profile defines a response to a linked command; without a link there's nothing to respond to. - **Trigger value on Bool enable** — rejected. `link="En:1"` where `En` is a Bool field is invalid; use plain `link="En"` for Bool enables. - **Unknown choices label** — `link="State:MISSING"` raises `ValueError` when `MISSING` is not in the enable field's choices map. - **Non-numeric trigger without choices** — `link="State:SORTING"` on an Int field with no choices map raises `ValueError`. Use `link="State:2"` for literal values. `Program.validate()` also checks the full program. In addition to range violations and feedback timing hazards, it reports linked analog feedback that does not declare `physical=Physical(..., profile=...)`. ## Forces override the harness Forces take precedence over harness patches. If you force a feedback tag to a specific value, the harness patch lands but the force re-applies on top of it: ``` with PLC(logic, dt=0.010) as plc: harness = Harness(plc) harness.install() plc.force(Gripper[1].Fb_Contact, False) # hold Fb off Cmd.value = True plc.run_for(0.050) assert Gripper[1].Fb_Contact.value is False # force wins ``` This is how you test "what happens when feedback never arrives" — force the Fb off and let the program's fault timer trip. ## Tag metadata: min, max, uom Alongside `physical=` and `link=`, fields accept value-domain metadata: ``` Fb_Temp: Real = Field(physical=THERMOCOUPLE, link="En", min=0, max=250, uom="degC") ``` The static validator catches literal writes outside these bounds (`CORE_RANGE_VIOLATION`), and the runtime bounds checker flags dynamic writes that land outside the declared range after each scan — see [Testing: Checking bounds](https://ssweber.github.io/pyrung/guides/testing/#checking-bounds). Values are never clamped; the check sets a warning and populates `plc.bounds_violations`. The debugger's Data View shows declared ranges as hints. Profile functions receive only `(cur, en, dt)`, so pass constants explicitly if a profile needs bounds. ## Fault coverage For fault coverage — proving every device has an alarm path — see [Verification](https://ssweber.github.io/pyrung/guides/verification/#fault-coverage). The workflow uses `harness.couplings()` to iterate device couplings and `prove()` to check structural detection paths. ## Next steps - [Verification](https://ssweber.github.io/pyrung/guides/verification/index.md) — prove(), fault coverage, lock files - [Testing Guide](https://ssweber.github.io/pyrung/guides/testing/index.md) — deterministic testing patterns, forces, monitors - [Analysis](https://ssweber.github.io/pyrung/guides/analysis/index.md) — dataview, cause/effect, coverage queries, static validators - [VS Code Debugger](https://ssweber.github.io/pyrung/guides/dap-vscode/index.md) — Data View, breakpoints, step-through debugging - [Harness in the debugger](https://ssweber.github.io/pyrung/guides/dap-vscode/#autoharness-in-the-debug-session) — auto-installs when annotations exist, `harness status/remove/install` console verbs, capture provenance # Runner `PLC` is the execution engine. It takes a program, holds the current state, and exposes methods to drive execution scan by scan. ## Creating a runner ``` from pyrung import PLC runner = PLC(logic) ``` The constructor accepts: - A `Program` (the common case) - A list of rungs (`[rung1, rung2]`) - `None` for an empty program (useful in tests) Optional keyword arguments: - `initial_state` — a `SystemState` to start from instead of the default - `history` — retention window for the scan log and checkpoints. Duration string (`"1h"`, `"30m"`), scan count (int), or `None` (unlimited, default). Prevents unbounded memory growth on long runs. - `cache` — instant-lookup window for full `SystemState` snapshots. Same formats as `history`. `None` (default) uses byte-budget-only eviction. - `history_budget` — byte ceiling for the recent-state cache (default: 100 MB; minimum 1 MB). Acts as a safety net when duration-based policies aren't enough. ## Time modes ``` runner = PLC(logic, dt=0.010) # fixed-step, 10 ms per scan (default) runner = PLC(logic, realtime=True) # wall-clock ``` | Mode | Behavior | Use case | | --------------- | --------------------------------- | -------------------------------- | | `dt=0.010` | `timestamp += dt` each scan | Tests, offline simulation | | `realtime=True` | `timestamp` = actual elapsed time | Live hardware, integration tests | `dt=` is the default. Timer and counter instructions use `timestamp`, so fixed steps give perfectly reproducible results. `realtime=True` is intentionally non-deterministic — scan `dt` follows host elapsed time. ## Real-time clock Logic that depends on time of day (shift changes, scheduled events) uses the RTC system points (`system.rtc.year4`, `system.rtc.month`, `system.rtc.hour`, etc.). By default, these track wall-clock time. See [System points](https://ssweber.github.io/pyrung/getting-started/concepts/#system-points) for the full namespace overview. `set_rtc` pins the RTC to a specific datetime: ``` from datetime import datetime runner.set_rtc(datetime(2026, 3, 5, 6, 59, 50)) ``` The RTC then advances with simulation time: `rtc = base_datetime + (current_sim_time - sim_time_at_set)`. With a fixed `dt`, this makes time-of-day logic fully deterministic. With `realtime=True`, it effectively offsets the wall clock. ## Execution methods ### `step()` — one scan ``` state = runner.step() ``` Executes one complete scan cycle (all phases) and returns the committed `SystemState`. ### `run(cycles)` — N scans ``` state = runner.run(cycles=300) ``` Runs exactly N scans, unless a [pause breakpoint](https://ssweber.github.io/pyrung/guides/testing/#predicate-breakpoints-and-snapshots) fires first. Returns the final state. ### `run_for(seconds)` — advance by time ``` state = runner.run_for(1.0) # advance simulation clock by at least 1 second ``` Keeps stepping until the simulation clock has advanced by the given amount (or a pause breakpoint fires). ### `run_until(*conditions)` — stop on condition ``` state = runner.run_until(~MotorRunning, max_cycles=10000) ``` Accepts the same condition expressions used inside `Rung()`. Multiple conditions are AND-ed: ``` runner.run_until(Motor & ~Fault) runner.run_until(Temp > 150.0) runner.run_until(Or(AlarmA, AlarmB, AlarmC)) ``` Stops when the condition is true, a pause breakpoint fires, or `max_cycles` is reached — whichever comes first. ### `run_until_fn(predicate)` — callable predicate For conditions that aren't expressible as tag/condition expressions: ``` state = runner.run_until_fn( lambda s: s.scan_id >= 100, max_cycles=10000, ) ``` The predicate receives the committed `SystemState` each scan. ## Injecting inputs ### `patch()` — one-shot ``` runner.patch({Button: True, Step: 5}) ``` Values are applied at the start of the next `step()` and then discarded. Multiple patches before a step merge — last write per tag wins. ### `.value` via context manager Inside `with PLC(...) as plc:` (or `with runner:`), tag `.value` reads and writes go through the runner's current state: ``` with PLC(logic) as plc: Button.value = True # queues a patch print(Step.value) # reads current value plc.step() # executes with the queued patch assert Motor.value is True ``` ### Forces Forces persist across scans, re-applied at two points each scan: ``` Phase 3: APPLY FORCES (pre-logic) ← sets force values before any rung runs Phase 4: EXECUTE LOGIC ← logic may overwrite forced values mid-scan Phase 5: APPLY FORCES (post-logic) ← re-asserts force values after all logic ``` This means: - Forced values are present at scan start and scan end. - Logic may temporarily change a forced value mid-scan (for example, `latch()` on a forced-False tag sets it True temporarily, but the post-logic force pass restores it). - Edge detection (`rise`/`fall`) sees the post-force values that carry across scans. If a tag is both patched and forced in the same scan, the pre-logic force pass overwrites the patched value. The patch is consumed but has no effect. For force usage patterns in tests, see [Testing — Forces](https://ssweber.github.io/pyrung/guides/testing/#forces). ## Mode control ### `stop()` — enter STOP mode ``` runner.stop() ``` Sets PLC mode to STOP. Does not clear tags. Idempotent. ### Auto-restart from STOP Any execution method (`step`, `run`, `run_for`, `run_until`) performs a STOP→RUN transition before executing: - Non-retentive tags reset to defaults - Retentive tags preserve values - Runtime scope resets (`scan_id=0`, `timestamp=0.0`, history/patches/forces cleared) ### `reboot()` — power-cycle ``` runner.reboot() ``` Simulates a power cycle. Tag behavior depends on battery: - Battery present (default): all tags preserve - Battery absent: all tags reset to defaults Runtime scope resets the same as STOP→RUN. Runner returns in RUN mode. ``` runner.battery_present = False runner.reboot() # all tags reset ``` ## Inspecting state ``` runner.current_state # SystemState snapshot at latest committed scan runner.simulation_time # shorthand for current_state.timestamp runner.time_mode # current TimeMode runner.forces # read-only view of active force overrides ``` `SystemState` fields: ``` state.scan_id # int — monotonic scan counter (starts at 0) state.timestamp # float — simulation clock in seconds state.tags # PMap[str, value] — all tag values state.memory # PMap[str, value] — internal engine state ``` Both `scan_id` and `timestamp` reset to 0 on STOP→RUN transition or `reboot()`. ## History Enable history retention to keep immutable state snapshots: ``` runner = PLC(logic) # 100 MB default cache, all scans addressable runner.history.at(5) # state at scan 5 runner.history.range(3, 7) # [scan 3, 4, 5, 6] runner.history.latest(10) # up to 10 most recent (oldest → newest) ``` Every scan from 0 to the current tip is addressable. Recent scans are served from an in-memory state cache (byte-bounded, default 100 MB); older scans are reconstructed on demand from the scan log and checkpoints. To bound memory on long runs, set a retention window: ``` runner = PLC(logic, history="1h") # keep 1 hour of replayable history runner = PLC(logic, history="1h", cache="5m") # last 5 minutes instant, rest via replay runner = PLC(logic, history_budget=20 * 1024 * 1024) # 20 MB byte ceiling ``` `history_budget` must be at least 1 MB (raises `ValueError` below that). ## Time-travel playhead The playhead is a read-only cursor into history. It doesn't affect execution — `step()` always appends at the history tip. ``` runner.playhead # current inspection scan_id runner.seek(scan_id=5) # jump to a historical scan runner.rewind(seconds=1.0) # move backward by simulation time snapshot = runner.history.at(runner.playhead) ``` `rewind(seconds)` finds the nearest state where `timestamp <= target`. ## Diff Compare two retained scans to see what changed: ``` changes = runner.diff(scan_a=5, scan_b=10) # {"Motor": (True, False), "Step": (3, 7)} ``` Returns string-keyed dicts — only tags whose values differ. Missing tags appear as `None`. ## Fork Create an independent runner from a snapshot: ``` alt = runner.fork() # from current state (common case) alt = runner.fork(scan_id=10) # from a retained historical scan alt = runner.fork_from(scan_id=10) # alias ``` The fork starts with the snapshot's state and the same time mode. It has clean runtime state — no forces, patches, breakpoints, or monitors carry over. Only the fork snapshot is in its initial history. See [Testing — Forking](https://ssweber.github.io/pyrung/guides/testing/#forking-test-alternate-outcomes) for the alternate-outcomes pattern. ## Breakpoints and monitors `when()` creates condition breakpoints evaluated after each committed scan. `monitor()` watches a tag for value changes. Both return handles with `.remove()`, `.enable()`, `.disable()`. ``` runner.when(Fault).pause() # halt run()/run_for()/run_until() runner.when(Fault).snapshot("fault_seen") # label scan in history runner.monitor(Motor, lambda curr, prev: print(f"{prev} → {curr}")) ``` See [Testing — Monitoring changes](https://ssweber.github.io/pyrung/guides/testing/#monitoring-changes) and [Testing — Predicate breakpoints](https://ssweber.github.io/pyrung/guides/testing/#predicate-breakpoints-and-snapshots) for usage patterns. # Tag Structures Advanced patterns for UDTs, named arrays, and block configuration. For basic tag and UDT usage, see [Core Concepts](https://ssweber.github.io/pyrung/getting-started/concepts/index.md). ## UDT naming A singleton UDT (`count=1`) generates compact tag names — no instance number: ``` @udt() class Motor: Running: Bool Speed: Int # Tags: Motor_Running, Motor_Speed ``` With `count > 1`, tags are numbered by instance: ``` @udt(count=3) class Pump: Running: Bool Flow: Real # Tags: Pump1_Running, Pump1_Flow, Pump2_Running, ... ``` If you want numbered names even for a singleton, use `always_number=True`: ``` @udt(count=1, always_number=True) class Heater: On: Bool Temp: Real # Tags: Heater1_On, Heater1_Temp (not Heater_On) ``` This is useful when your naming convention requires consistency across singletons and counted structures. ## Field options Plain annotations give you the type default (0 for `Int`, `False` for `Bool`, etc.). `Field` lets you override: ``` @udt(count=3) class Alarm: Id: Int = Field(default=100) Active: Bool Message: Char = Field(retentive=True) ``` `retentive=True` means the field survives a STOP→RUN transition. By default, UDT fields inherit the type's retentive policy. You can also assign a plain literal as a default: ``` @udt() class Config: Mode: Int = 2 Threshold: Real = 75.0 ``` ### Per-instance sequences with auto() `auto()` generates a different default for each instance — useful for IDs and addresses: ``` @udt(count=3) class Alarm: Id: Int = auto(start=10, step=5) Active: Bool # Alarm[1].Id defaults to 10 # Alarm[2].Id defaults to 15 # Alarm[3].Id defaults to 20 ``` `auto()` only works on numeric types: `Int`, `Dint`, `Word`. ## Named arrays A `@named_array` is a single-type structure where all fields share the same `TagType`. Fields are declared as class attributes (not annotations): ``` from pyrung import named_array @named_array(Int, count=4) class Sensor: Reading = 0 Setpoint = 100 ``` This creates 4 instances, each with an `Int`-typed `Reading` and `Setpoint`. Access works the same as UDTs: ``` Sensor[1].Reading # first sensor's reading Sensor[3].Setpoint # third sensor's setpoint ``` ### Selecting whole instances You can select one or more complete instances as a contiguous `BlockRange`. This works with both dense and sparse layouts — stride is known, so instance boundaries are always well-defined: ``` @named_array(Int, count=3) class RecipeProfile: MixSeconds = 0 HoldSeconds = 0 TargetTemp = 0 RecipeProfile.instance(2) # one complete profile RecipeProfile.instance_select(1, 2) # the first two profiles ``` This is useful with range-based instructions such as `blockcopy()` and `fill()`: ``` blockcopy(RecipeProfile.instance(2), ds.select(201, 203)) fill(0, RecipeProfile.instance_select(1, 2)) ``` For sparse layouts the returned `BlockRange` spans the full stride (including gap slots), while the tag list contains only the named fields. ### Stride `stride` controls how many hardware slots each instance spans. When stride exceeds the field count, the extra slots are gaps: ``` @named_array(Int, count=2, stride=4) class DataPack: Id = auto() Value = 0 ``` Instance 1 occupies slots 1–4, instance 2 occupies slots 5–8. Only slots 1–2 and 5–6 hold named fields; slots 3–4 and 7–8 are gaps. This matters when mapping to hardware with fixed slot widths. ## Cloning `.clone()` creates an independent copy of a structure with a new name. Same field layout, fresh tags: ``` @udt(count=2) class Motor: Running: Bool Speed: Int Pump = Motor.clone("Pump") # Same layout, count=2 Fan = Motor.clone("Fan", count=4) # Same layout, 4 instances ``` For named arrays, you can also override stride: ``` @named_array(Int, count=2, stride=3) class Slot: Id = auto() Value = 0 WideSlot = Slot.clone("WideSlot", stride=5) ``` ### Flag overrides `clone()` accepts optional flag overrides. `None` (the default) inherits from the parent: ``` BinACounter = Counter.clone("BinACounter", public=True) ``` Available flags: `readonly`, `external`, `final`, `public`, `lock`. See [Tag flags](#tag-flags) for details. Use case: define a template structure once, clone it for each subsystem. ## Mapping to hardware Named arrays can map their interleaved layout onto a hardware block range with `.map_to()`: ``` from pyrung.click import ds @named_array(Int, count=3, stride=2) class Channel: Id = auto() Value = 0 entries = Channel.map_to(ds.select(101, 106)) ``` This maps: - Channel[1].Id → DS101, Channel[1].Value → DS102 - Channel[2].Id → DS103, Channel[2].Value → DS104 - Channel[3].Id → DS105, Channel[3].Value → DS106 The target range must have exactly `count * stride` addresses. Each instance claims `stride` consecutive slots, with fields filling from the front and gaps (if any) at the end. For UDTs, use `TagMap` to map individual field blocks — see the [Click Dialect](https://ssweber.github.io/pyrung/dialects/click/index.md) guide. ## Block configuration Blocks support per-slot overrides for name, retentive policy, default value, and comment. All configuration must happen **before** you index the slot (before tag materialization). The unified `slot()` method handles inspection, configuration, and reset. ### Configuring slots ``` ds = Block("DS", TagType.INT, 1, 100) # Name a slot ds.slot(1, name="SpeedCommand") ds.slot(2, name="SpeedFeedback") ds[1].name # "SpeedCommand" ds[2].name # "SpeedFeedback" ds[3].name # "DS3" (default) # Override retentive policy and default ds.slot(10, retentive=True, default=999) # Configure a range (retentive and default only) ds.slot(20, 30, retentive=True) ``` Range configuration applies to all valid addresses in the inclusive window. For sparse blocks, it only affects addresses within the block's valid ranges. ### default_factory Set a function that computes defaults by address when creating a block: ``` ds = Block("DS", TagType.INT, 1, 10, default_factory=lambda addr: addr * 10) ds[1].default # 10 ds[5].default # 50 ``` Per-slot overrides from `slot()` take precedence over `default_factory`. ### Inspecting slots `slot()` without configuration kwargs returns a live `SlotView` — no tag materialization: ``` sv = ds.slot(10) sv.name # Effective tag name sv.retentive # Effective retentive policy sv.default # Effective default value sv.name_overridden # True if name was overridden sv.retentive_overridden # True if retentive was overridden sv.default_overridden # True if default was overridden ``` The `*_overridden` flags tell you whether a value comes from an explicit override or from the block's inherited defaults. ### Resetting overrides ``` ds.slot(10).reset() # Clear all overrides for slot 10 ds.slot(20, 30).reset() # Clear all overrides for range 20–30 ``` ## Tag flags Tags carry metadata flags that control validation, presentation, and lock file projection. Three semantic flags are enforced by static validators; two metadata flags control presentation and verification. ### Semantic flags ``` SizeThreshold = Int("SizeThreshold", readonly=True) # zero writers after startup HmiSetpoint = Int("HmiSetpoint", external=True) # written outside the ladder FilteredVal = Int("FilteredVal", final=True) # exactly one writer ``` **`readonly`** — the tag is initialized from its declared default and never written again. The `CORE_READONLY_WRITE` validator flags any write site. The stuck-bits validator skips readonly tags. **`external`** — something outside the ladder (HMI, SCADA, comms) is the writer. The stuck-bits validator treats the external source as satisfying the missing latch or reset side. `plc.recovers()` returns `'external'` instead of `False`. **`final`** — exactly one instruction in the ladder may write this tag. The `CORE_FINAL_MULTIPLE_WRITERS` validator flags any tag with more than one write site, regardless of mutual exclusivity. Mutual exclusivity: `readonly` + `final` and `readonly` + `external` raise `ValueError` at construction. `external` + `final` is allowed (one ladder writer plus external writers). ### Metadata flags ``` Running = Bool("Running", public=True) # operator-facing status State = Int("State", choices=SortState, public=True) MotorOut = Bool("MotorOut", lock=True) # tracked in lock file ``` **`public`** — part of the intended API surface. Setpoints, mode commands, alarms, key status bits. The VS Code Data View shows a **P** badge and provides a **Public** filter checkbox to hide plumbing tags. No validator consequence. The absence of `public` means plumbing — not hidden, not forbidden, just not the featured interface. Same convention as Python's `foo` vs `_foo`. **`lock`** — included in the default `pyrung lock` projection. Tags with `lock=True` define the behavioral contract tracked by the lock file. Unlike the semantic flags, `lock` has no validation constraints and no mutual exclusivity — it can combine freely with any other flag. Programs using Click `TagMap` get this automatically: output-mapped tags are stamped `lock=True` at construction. ### Flags on structures Flags set on a `@udt()` or `@named_array()` decorator apply to all fields. Individual fields can override with `Field()`: ``` @udt(external=True, public=True) class Cmd: Speed: Int # inherits external=True, public=True Mode: Int = Field(external=False) # overrides: ladder writes this one @named_array(Int, stride=4, readonly=True) class SortState: IDLE = 0 DETECTING = 1 ``` `clone()` inherits flags from the parent but accepts overrides: ``` BinACounter = Counter.clone("BinACounter", public=True) ``` ### Click comment convention All flags round-trip through the Click nickname CSV comment parser using bracket syntax: ``` [readonly] [external] [final] [public] [lock] [readonly, choices=Off:0|On:1] [choices=Bool] ``` Use `[choices=Bool]` for int-backed boolean fields stored in Click register memory. It round-trips as the same metadata as `choices={0: "False", 1: "True"}` and replaces the longer `[choices=False:0|True:1]` spelling on export. # Testing The whole point of pyrung is to test logic before it touches hardware. Every scan is deterministic, every state is a snapshot, and pytest works out of the box. ## Your first test ``` from pyrung import Bool, PLC, Program, Rung, latch, reset Start = Bool("Start") Stop = Bool("Stop") Motor = Bool("Motor") with Program() as logic: with Rung(Start): latch(Motor) with Rung(Stop): reset(Motor) def test_start_latches_motor(): with PLC(logic, dt=0.1) as plc: Start.value = True plc.step() assert Motor.value is True # Release start — motor stays latched Start.value = False plc.step() assert Motor.value is True def test_stop_resets_motor(): with PLC(logic, dt=0.1) as plc: Start.value = True plc.step() Start.value = False Stop.value = True plc.step() assert Motor.value is False ``` Tags are defined at module level (just like PLC addresses), and each test gets a fresh PLC. Inside the `with PLC(...) as plc:` block, `.value` reads and writes go through the runner's current state. Set a value, step, assert — that's the pattern. ## Testing timers Timers accumulate time across scans. With a fixed `dt`, the math is exact: ``` from pyrung import Bool, Timer, PLC, Program, Rung, on_delay Enable = Bool("Enable") MyTimer = Timer.clone("MyTimer") with Program() as logic: with Rung(Enable): on_delay(MyTimer, preset=100) def test_timer_fires_at_preset(): with PLC(logic, dt=0.001) as plc: Enable.value = True # 99 scans = 99 ms — not yet plc.run(cycles=99) assert MyTimer.Done.value is False assert MyTimer.Acc.value == 99 # One more scan — 100 ms, timer fires plc.step() assert MyTimer.Done.value is True ``` A 100 ms preset with 1 ms scans takes exactly 100 steps. No timing jitter, no flaky tests. ## Testing time-of-day logic Logic that depends on the real-time clock (shift changes, scheduled events, lighting) can be tested with `set_rtc`. With a fixed `dt`, the RTC advances with simulation time — no wall-clock dependency: ``` from datetime import datetime def test_shift_changeover(): with PLC(logic, dt=0.1) as plc: plc.set_rtc(datetime(2026, 3, 5, 6, 59, 50)) # 10 seconds before 7 AM plc.run(cycles=100) # 10 seconds at 0.1s/scan assert ShiftActive.value is True # Logic triggered at 7:00:00 ``` ## Testing edge detection `rise()` fires for exactly one scan on a false → true transition: ``` from pyrung import Bool, PLC, Program, Rung, out, rise Sensor = Bool("Sensor") Pulse = Bool("Pulse") with Program() as logic: with Rung(rise(Sensor)): out(Pulse) def test_rise_fires_once(): with PLC(logic, dt=0.1) as plc: Sensor.value = True plc.step() assert Pulse.value is True # Rising edge — fires plc.step() assert Pulse.value is False # Still true, but no edge — doesn't fire ``` ## Forces Forces override tag values independently of logic — the simulation equivalent of PLC "override" mode. Use them for edge-case testing, known-state setup, and debugging. ### Force vs patch | | `patch()` | `force()` | | -------- | ---------------------------- | ------------------------------------ | | Duration | One scan | Until explicitly removed | | Applied | Pre-logic, once | Pre-logic AND post-logic, every scan | | Use case | Momentary inputs, test steps | Persistent overrides, test fixtures | ### Forces as test fixtures When you need an input held across many scans, forces are cleaner than setting `.value` before every step: ``` def test_motor_runs_for_duration(): with PLC(logic, dt=0.1) as plc: plc.force(Enable, True) plc.run(cycles=50) assert Motor.value is True plc.unforce(Enable) ``` The `forced()` context manager scopes forces to a block and cleans up automatically: ``` def test_fault_during_operation(): with PLC(logic, dt=0.1) as plc: with plc.forced({Enable: True}): plc.run(cycles=10) with plc.forced({Fault: True}): plc.step() assert Motor.value is False # Fault killed the motor # Fault released, Enable still forced # All forces released ``` Safe to nest — inner blocks add to (and restore from) the outer block's forces: ``` with plc.forced({AutoMode: True}): plc.run(cycles=3) with plc.forced({Fault: True}): # adds Fault while AutoMode stays forced plc.run(cycles=2) # Fault removed; AutoMode still True # AutoMode removed ``` ### Adding and removing forces ``` plc.force(Button, True) plc.force(Temperature, 75.5) plc.unforce(Button) plc.clear_forces() # remove all ``` ### Inspecting active forces ``` plc.forces # read-only Mapping[str, value] ``` ### Supported tag types Any writable tag (`BOOL`, `INT`, `DINT`, `REAL`, `WORD`, `CHAR`) can be forced. Read-only system tags cannot be forced and raise `ValueError`. > Forces and patches also accept string keys (`plc.force("Enable", True)`) for cases where you're working with tag names directly. ## Running until a condition For tests where you care about *what* happens, not *when*, `run_until` accepts the same condition expressions you use inside `Rung()`: ``` def test_motor_eventually_stops(): with PLC(logic, dt=0.1) as plc: Start.value = True plc.step() Stop.value = True plc.run_until(~Motor, max_cycles=100) assert Motor.value is False ``` Conditions compose the same way they do in rungs: ``` runner.run_until(Motor & ~Fault) # Motor on, no fault runner.run_until(Temp > 150.0) # Temperature exceeded runner.run_until(Or(AlarmA, AlarmB, AlarmC)) # Any alarm triggered ``` `run_until` stops as soon as the condition is met, or after `max_cycles` — whichever comes first. ## Forking: test alternate outcomes Get your process to a decision point once, then fork and test both paths independently: ``` def test_fault_vs_normal(): with PLC(logic, dt=0.01) as plc: Start.value = True plc.run(cycles=200) # What happens if a fault occurs? fault_path = plc.fork() with fault_path: Fault.value = True fault_path.run(cycles=50) assert Motor.value is False # What happens under normal operation? normal_path = plc.fork() with normal_path: normal_path.run(cycles=50) assert Motor.value is True ``` Each fork is an independent runner starting from the same snapshot. No need to duplicate a long warmup sequence in every test. ## Monitoring changes `monitor` watches a tag and fires a callback whenever its value changes: ``` def test_motor_transitions(): transitions = [] with PLC(logic, dt=0.1) as plc: plc.monitor(Motor, lambda curr, prev: transitions.append((prev, curr))) Start.value = True plc.step() Stop.value = True plc.step() assert transitions == [(False, True), (True, False)] ``` ## Predicate breakpoints and snapshots `when` uses the same condition expressions to pause execution or label a scan in history: ``` def test_capture_fault_state(): with PLC(logic, dt=0.1) as plc: plc.when(Fault).snapshot("fault_triggered") Start.value = True plc.run(cycles=500) snap = plc.history.find_labeled("fault_triggered") if snap is not None: assert snap.scan_id > 0 ``` `when(condition).pause()` halts `run()` / `run_for()` / `run_until()` after committing the triggering scan — useful for debugging a long simulation without stepping through every scan. ## Comparing states For debugging tests, `diff` shows exactly what changed between two scans: ``` def test_inspect_changes(): with PLC(logic, dt=0.1) as plc: Start.value = True plc.step() # scan 1 Stop.value = True plc.step() # scan 2 changes = plc.diff(scan_a=1, scan_b=2) # {"Motor": (True, False), "Stop": (False, True), ...} ``` ## Checking bounds Tags with `min`/`max` or `choices` get runtime bounds checking at the end of every scan. Values are never clamped — the write goes through, but a warning fires and the violation lands in `plc.bounds_violations`: ``` from pyrung import Bool, Int, Real, PLC, Program, Rung, calc, copy Pressure = Real("Pressure", min=0, max=100) Mode = Int("Mode", choices={0: "Off", 1: "On", 2: "Auto"}) Enable = Bool("Enable") Src = Int("Src") with Program() as logic: with Rung(Enable): calc(Pressure + 60, Pressure) copy(Src, Mode) def test_pressure_stays_in_range(): with PLC(logic, dt=0.1) as plc: plc.patch({Enable: True, Pressure: 50.0, Src: 1}) plc.step() # 50 + 60 = 110, exceeds max=100 assert "Pressure" in plc.bounds_violations assert plc.bounds_violations["Pressure"].kind == "range" # Value goes through unclamped — the program sees its real output assert Pressure.value == 110.0 def test_mode_rejects_unknown_choice(): with PLC(logic, dt=0.1) as plc: plc.patch({Enable: True, Src: 5}) plc.step() assert "Mode" in plc.bounds_violations assert plc.bounds_violations["Mode"].kind == "choices" def test_clean_scan_clears_violations(): with PLC(logic, dt=0.1) as plc: plc.patch({Enable: True, Pressure: 50.0, Src: 1}) plc.step() assert plc.bounds_violations # violation from Pressure plc.patch({Enable: False}) plc.step() assert plc.bounds_violations == {} ``` Violations also emit `warnings.warn()`, so `pytest -W error::UserWarning` will fail any test that triggers one — useful as a catch-all without explicit assertions. The check runs after all instructions, forces, and system runtime writes — it sees the final committed values. Tags without `min`/`max`/`choices` are never checked (zero overhead). `copy()` still clamps to type limits (INT ±32768, etc.) before the bounds check runs, so a `copy(40000, IntTag)` where `IntTag` has `max=500` will show a violation for 32767 (type-clamped), not 40000. ## Pytest fixtures For a shared program across multiple tests: ``` import pytest from pyrung import Bool, PLC, Program, Rung, latch, reset Start = Bool("Start") Stop = Bool("Stop") Motor = Bool("Motor") with Program() as logic: with Rung(Start): latch(Motor) with Rung(Stop): reset(Motor) @pytest.fixture def plc(): return PLC(logic, dt=0.1) def test_latch(plc): with plc: Start.value = True plc.step() assert Motor.value is True def test_stop_after_start(plc): with plc: Start.value = True plc.step() Stop.value = True plc.step() assert Motor.value is False ``` ## Running tests ``` make test # recommended pytest tests/ # or directly ``` ## Autoharness: automatic feedback for device-heavy programs When your UDTs declare `physical=` and `link=` on feedback fields, the autoharness can drive all feedback patches automatically — no manual toggling. See [Physical Annotations and Autoharness](https://ssweber.github.io/pyrung/guides/physical-harness/index.md). ## Next steps - [Physical Annotations and Autoharness](https://ssweber.github.io/pyrung/guides/physical-harness/index.md) — annotate devices, eliminate feedback boilerplate - [Analysis](https://ssweber.github.io/pyrung/guides/analysis/index.md) — dataview, cause/effect chains, coverage queries - [Verification](https://ssweber.github.io/pyrung/guides/verification/index.md) — prove(), fault coverage, lock files - [Runner Guide](https://ssweber.github.io/pyrung/guides/runner/index.md) — time modes, execution methods, numeric behavior - [Quickstart](https://ssweber.github.io/pyrung/getting-started/quickstart/index.md) — the traffic light example # Verification The [analysis tools](https://ssweber.github.io/pyrung/guides/analysis/index.md) answer questions about recorded history — what happened, why, and did your tests cover the program. Verification answers a different question: **does a property hold across every reachable state, not just the states your tests happened to visit?** ``` from pyrung import Bool, Or, Program, Rung, latch, reset from pyrung.core.analysis import prove, Proven EstopOK = Bool("EstopOK", external=True) Start = Bool("Start", external=True) Running = Bool("Running") with Program(strict=False) as logic: with Rung(Start, EstopOK): latch(Running) with Rung(~EstopOK): reset(Running) result = prove(logic, Or(~Running, EstopOK)) assert isinstance(result, Proven) ``` `prove()` exhaustively explores every reachable state via BFS over the compiled replay kernel. If the property holds everywhere, you get `Proven`. If not, `Counterexample` with a trace you can replay on a real PLC. ## Condition syntax `prove()` accepts the same condition expressions as `Rung()` and `when()`: ``` prove(logic, Or(~Running, EstopOK)) # condition expression prove(logic, ~Running, EstopOK) # implicit AND prove(logic, lambda s: s["X"] + s["Y"] < 100) # callable fallback ``` Condition expressions are preferred — the verifier extracts referenced tags and automatically restricts input enumeration to the upstream cone. Callable predicates work but don't get auto-scoping. ### Result types ``` from pyrung.core.analysis import Proven, Counterexample, Intractable result = prove(logic, Or(~Running, EstopOK)) if isinstance(result, Proven): print(f"Holds across {result.states_explored} states") elif isinstance(result, Counterexample): # Replay the trace on a real PLC with PLC(logic, dt=0.010) as plc: for step in result.trace: plc.patch(step.inputs) for _ in range(step.scans): plc.step() # The violation is now visible in plc state elif isinstance(result, Intractable): print(result.reason) # "unbounded domain on Pressure" print(result.tags) # ["Pressure"] — add choices or min/max for hint in result.hints: print(hint) # "Pressure: 65536 values (Int, no choices/min/max)" ``` `Intractable` means the state space is too large. The `hints` list describes the largest dimensions — which tags are blowing up the state space and why. The fix is usually adding `choices` or `min`/`max` metadata to the unbounded tags — the same metadata you'd declare anyway for Data View dropdowns and static validation. ### Scoping With condition expressions, scope is derived automatically from the referenced tags. Override with `scope=` when needed: ``` prove(logic, Or(~Running, EstopOK), scope=["Running", "EstopOK"]) ``` Scoping restricts input enumeration to the upstream cone of the named tags — the verifier only explores inputs that can actually influence the property. ### How it works The verifier classifies every tag into one of three roles: - **Combinational** — OTE-only writes, derived from inputs each scan. Not a state dimension. - **Stateful** — latch/reset, timer/counter, copy, calc. Tracked in the visited set. - **Nondeterministic** — external inputs. Enumerated at each state. `InputBlock` tags (e.g., `x[1]`) are automatically nondeterministic. Semantic tags mapped to input banks via `TagMap` are also auto-detected (see [Click dialect](https://ssweber.github.io/pyrung/dialects/click/#tagmap--mapping-to-hardware)). Value domains come from the expression tree: comparison literals in conditions, `choices` metadata, `min`/`max` bounds. A tag compared against `== 1` and `== 2` gets domain `{1, 2, unmatched}` — three values instead of 65K. Don't-care pruning skips inputs that are masked by the current state. `And(StateBit, Input)` with `StateBit=False` means `Input` doesn't matter — the verifier skips it entirely. Timer and counter Done bits use a three-valued abstraction: `False`, `Pending` (accumulating), and `True` (done). The verifier fast-forwards through accumulation rather than stepping one tick at a time. When evaluating a property, the verifier settles all pending timers/counters to a stable state first — a timer-gated alarm that is structurally reachable but hasn't elapsed yet won't produce a spurious counterexample. ## Fault coverage The harness knows every device coupling. `Harness.couplings()` iterates them as `Coupling` dataclasses so you can automate fault coverage without maintaining a manual device list: ``` from pyrung import Coupling, Harness, PLC plc = PLC(logic, dt=0.001) harness = Harness(plc) harness.install() for coupling in harness.couplings(): print(coupling.en_name, "→", coupling.fb_name) ``` Each `Coupling` has `en_name`, `fb_name`, `physical`, and `trigger_value` (None for plain bool links, the matched value for `link="Tag:value"` triggers). Fault coverage decomposes into two passes over the same coupling list: **Structural coverage** — does a path from the fault to an alarm exist at all? `prove()` answers this exhaustively. Batch all conditions into a single call — the verifier shares work across properties: ``` from pyrung.core.analysis import prove, Proven, Counterexample couplings = list(harness.couplings()) conditions = [ Or(~plc.tags[c.en_name], plc.tags[c.fb_name], AlarmExtent != 0) for c in couplings ] results = prove(logic, conditions) for coupling, result in zip(couplings, results): assert isinstance(result, Proven), f"{coupling.fb_name}: no alarm path" ``` Each condition reads: "in every reachable state, either the enable is off, the feedback is healthy, or the alarm caught it." A `Counterexample` means there exists a reachable state where the feedback has failed and no alarm fired — a structural detection gap. `prove()` uses a three-valued timer abstraction (`False`/`Pending`/`True`) that collapses accumulator state to make BFS tractable. It settles pending timers before evaluating, so timer-gated alarm paths prove correctly. But it's timing-blind by design — it answers "can the alarm fire?" not "does it fire in time?" **Timing coverage** — does the fault timer trip fast enough under real timing? Force-based tests answer this: ``` for coupling in harness.couplings(): plc2 = PLC(logic, dt=0.001) h2 = Harness(plc2) h2.install() plc2.force(coupling.en_name, True) plc2.run_for(1.5) plc2.force(coupling.fb_name, False) plc2.run_for(6.0) with plc2: assert AlarmExtent.value != 0, f"{coupling.fb_name}: too slow" ``` This catches fault timers that exist structurally but are too slow — the alarm path exists but takes longer than the machine can safely tolerate. Run `prove()` first — there's no point testing timing on a coupling that never reaches an alarm. Then run the force-based tests for timing validation on the ones that passed. See `examples/fault_coverage.py` for a complete working example. ## Lock files The lock file captures your program's full reachable behavior as a committed artifact — same mental model as `uv.lock` or `package-lock.json`. ``` pyrung lock my_program # compute reachable states, write pyrung.lock pyrung check my_program # recompute, diff against pyrung.lock, exit 1 if changed ``` The lock projects to tags marked `lock=True` by default — the outputs that define your program's observable behavior. Programs using Click `TagMap` get this automatically (output-mapped tags are stamped `lock=True`). Programs without `TagMap` need explicit `lock=True` on output tags, or use `__lock__` or `--project`: ``` pyrung lock my_program --project Running MotorOut StatusLight ``` Tags with `choices=` metadata get their labels in the lock file instead of raw integers — `"FAST"` instead of `2`. Tags with `band=` metadata collapse numeric values into categorical labels — see [Bands](#bands--collapsing-numeric-values-into-categories) below. ### `__lock__` — per-module projection override For programs where the `lock=True` default misses something (a pivot that matters behaviorally) or includes something cosmetic, define `__lock__` at module level: ``` __lock__ = { "include": ["AlarmExtent", "BatchCount"], "exclude": ["Sts_DisplayText"], } ``` - `include` adds tags the default misses. - `exclude` drops tags the default includes. - `group` declares correlated input groups (see below). - All keys are optional. Most programs won't need `__lock__` at all. - `--project` on the CLI still overrides everything for one-off checks. Common patterns: ``` # Lock down the operator-facing interface too dv = logic.dataview() __lock__ = { "include": list(dv.public().tags), } # Lock Modbus registers __lock__ = { "include": list(dv.contains("Modbus").tags), } ``` ### Bands — collapsing numeric values into categories The `band` attribute maps ranges of concrete values to categorical labels in the lock file output. Band metadata is purely a post-processing reduction — it is not used during BFS exploration or `prove()`: ``` AlarmExtent = Int("AlarmExtent", lock=True, band={"ZERO": 0, "POSITIVE": "> 0"}) ``` In the lock file, `AlarmExtent=3` becomes `AlarmExtent="POSITIVE"`. The lock captures *which band* the value falls into, not the exact number — so adding a new alarm source doesn't change the lock file as long as the alarm extent stays positive. Band predicates: | Predicate | Matches | | ---------- | ------------------------------------------------------ | | `0` | Exact value 0 | | `"*"` | Any value (wildcard) | | `">= 100"` | Comparison (supports `==`, `!=`, `>`, `>=`, `<`, `<=`) | | `"0..10"` | Inclusive range | Predicates are checked in declaration order — the first match wins. Use `"*"` as a catch-all last entry. ### Input groups — joint multi-flip inputs Single-flip BFS only changes one input per successor state. The `group` key in `__lock__` declares groups whose members are additionally explored jointly — the BFS generates multi-flip combinations on top of the normal single-flip successors. ``` __lock__ = { "group": { "panel": ["SwitchA", "SwitchB"], }, } ``` Also available programmatically via `input_groups=` on `reachable_states()` and `prove()`. It's generally unwise to ship logic that depends on multiple inputs changing in the exact same scan cycle — real-world I/O doesn't change atomically. If the verifier only finds a property violation via a multi-flip path, that's usually a signal the logic needs fixing, not that single-flip is too conservative. ### Three levels of lock **Lock everything** — full state space equality. For purely cosmetic refactoring (renaming tags, reordering rungs that don't interact). Any behavioral change is flagged. ``` states = reachable_states(logic) # default: lock=True tags ``` **Lock I/O** — project to inputs and terminals only. For restructuring internal logic where pivots can change freely. ``` dv = logic.dataview() states = reachable_states(logic, project=sorted(dv.terminals().tags)) ``` **Lock a subset** — scope to specific tags. "I'm changing the diverter logic, but motor control shouldn't be affected." ``` dv = logic.dataview() motor_tags = sorted(dv.upstream("Running", "Conv_Motor").tags) states = reachable_states(logic, scope=["Running", "Conv_Motor"], project=motor_tags) ``` ### Diffing ``` from pyrung.core.analysis import reachable_states, diff_states before = reachable_states(original, project=["Running", "MotorOut"]) after = reachable_states(refactored, project=["Running", "MotorOut"]) diff = diff_states(before, after) assert not diff.added and not diff.removed # behavioral equivalence ``` In a PR, the lock file diff tells the story. States omit `false` values — each entry reads as "what's ON": ``` "reachable": [ {}, {"Conv_Motor": true, "Running": true}, + {"Conv_Motor": true} ] ``` Reviewer sees: "Conv_Motor can now be on while Running is off." Either intentional (regenerate with `pyrung lock`) or a bug. ### Programmatic use ``` from pyrung.core.analysis.prove import check_lock, write_lock, program_hash # Write states = reachable_states(logic, project=["Running"]) write_lock(Path("pyrung.lock"), states, ["Running"], program_hash(logic)) # Check diff = check_lock(logic, Path("pyrung.lock")) assert diff is None # None means no change ``` ### CLI reference ``` pyrung lock # write pyrung.lock pyrung lock -o out.lock # custom output path pyrung lock --project Running MotorOut # explicit projection pyrung lock --max-depth 100 # deeper BFS pyrung lock --profile out.prof # write cProfile stats pyrung check # diff against pyrung.lock, exit 1 on change pyrung check --lock custom.lock # custom lock path pyrung check --profile out.prof # write cProfile stats ``` `--profile` dumps cProfile stats even on `KeyboardInterrupt`. Analyze with `pstats.Stats("out.prof")` or `uvx snakeviz out.prof`. The `` argument is a Python module path (e.g., `my_program` or `examples.conveyor`). The module must contain a `Program` instance. ## Next steps - [Analysis](https://ssweber.github.io/pyrung/guides/analysis/index.md) — dataview, cause/effect, coverage queries, static validators - [Physical Annotations](https://ssweber.github.io/pyrung/guides/physical-harness/index.md) — declare device behavior, autoharness - [Testing](https://ssweber.github.io/pyrung/guides/testing/index.md) — forces as fixtures, forking, monitors, breakpoints - [Runner Guide](https://ssweber.github.io/pyrung/guides/runner/index.md) — execution methods, history, time travel # Dialects # CircuitPython Modbus TCP `pyrung.circuitpy` can generate a Modbus TCP server, client, or both for the P1AM-200 via the P1AM-ETH shield. The register layout matches a real Click PLC — C-more HMIs, pyclickplc, and SCADA systems connect without translation. See [CircuitPython Dialect](https://ssweber.github.io/pyrung/dialects/circuitpy/index.md) for the base hardware model and code generation. ## Hardware requirements The [P1AM-ETH](https://facts-engineering.github.io/modules/P1AM-ETH/P1AM-ETH.html) shield provides a W5500 Ethernet controller on SPI with chip-select on `board.D5`. Static IPv4 only — no DHCP. The `adafruit_wiznet5k` library must be installed on the CIRCUITPY drive alongside the CircuitPython P1AM library. ## Server ``` from pyrung import Bool, Int, Program, Rung, out from pyrung.circuitpy import ModbusServerConfig, P1AM, generate_circuitpy from pyrung.click import TagMap, c, ds # Hardware hw = P1AM() inputs = hw.slot(1, "P1-08SIM") outputs = hw.slot(2, "P1-08TRS") Button = inputs[1] Light = outputs[1] Setpoint = Int("Setpoint") # Logic with Program() as logic: with Rung(Button): out(Light) # Map to Click addresses for Modbus visibility mapping = TagMap({ Setpoint: ds[1], Light: c[1], }) # Generate with Modbus server source = generate_circuitpy( logic, hw, target_scan_ms=10.0, watchdog_ms=500, modbus_server=ModbusServerConfig(ip="192.168.1.200"), tag_map=mapping, ) ``` The generated code starts a Modbus TCP listener on the configured IP and port. Any Modbus client reading DS1 gets the current value of `Setpoint`; writing DS1 updates it. Reading coil C1 returns the state of `Light`. The register layout is identical to a real Click PLC — same Modbus addresses, same data encoding. | Field | Type | Default | Notes | | ------------- | ----- | ----------------- | ------------------------------------------------------------------------- | | `ip` | `str` | — | Static IPv4 for the P1AM-ETH shield | | `subnet` | `str` | `"255.255.255.0"` | | | `gateway` | `str` | `"192.168.1.1"` | | | `dns` | `str` | `"0.0.0.0"` | | | `port` | `int` | `502` | 1–65535 | | `max_clients` | `int` | `2` | 1–7 concurrent connections (W5500 has 8 sockets, 1 reserved for listener) | Supported function codes: FC 1 (read coils), FC 2 (read discrete inputs), FC 3 (read holding registers), FC 4 (read input registers), FC 5 (write single coil), FC 6 (write single register), FC 15 (write multiple coils), FC 16 (write multiple registers). ## Client — send and receive ``` from pyrung import Bool, Int, Block, Program, Rung, TagType from pyrung.circuitpy import ModbusClientConfig, P1AM, generate_circuitpy from pyrung.click import ModbusTcpTarget, TagMap, send, receive hw = P1AM() hw.slot(1, "P1-08SIM") Enable = Bool("Enable") LocalSetpoint = Int("LocalSetpoint") RemoteWords = Block("RemoteWords", TagType.INT, 1, 4) CommSending = Bool("CommSending") CommReceiving = Bool("CommReceiving") CommSuccess = Bool("CommSuccess") CommError = Bool("CommError") CommEx = Int("CommEx") with Program() as logic: with Rung(Enable): send( target="plc1", remote_start="DS1", source=LocalSetpoint, sending=CommSending, success=CommSuccess, error=CommError, exception_response=CommEx, ) with Rung(Enable): receive( target="plc1", remote_start="DS100", dest=RemoteWords.select(1, 4), receiving=CommReceiving, success=CommSuccess, error=CommError, exception_response=CommEx, ) source = generate_circuitpy( logic, hw, target_scan_ms=10.0, modbus_client=ModbusClientConfig( targets=(ModbusTcpTarget(name="plc1", ip="192.168.1.20"),) ), tag_map=TagMap(), ) ``` `send` writes local tag values to a remote Click address. `receive` reads remote Click addresses into local tags. The `target` string must match a `ModbusTcpTarget.name`. Remote addresses use Click address format (`DS1`, `C1`, `X001`, etc.). ### Raw Modbus addresses When the remote device isn't a Click PLC, use `ModbusAddress` instead of a Click address string. This gives direct control over the register address and register type. ``` from pyrung.core.instruction.send_receive import ModbusAddress, RegisterType vfd = ModbusTcpTarget(name="vfd", ip="192.168.1.30") with Program() as logic: # Read a 32-bit speed value from holding registers 0x200–0x201, word-swapped with Rung(Enable): receive( target="vfd", remote_start=ModbusAddress(0x200, RegisterType.HOLDING), dest=Speed, receiving=CommReceiving, success=CommSuccess, error=CommError, exception_response=CommEx, word_swap=True, ) # Write a setpoint to a single holding register at 0x100 with Rung(Enable): send( target="vfd", remote_start=ModbusAddress(0x100), source=Setpoint, sending=CommSending, success=CommSuccess, error=CommError, exception_response=CommEx, ) ``` `ModbusAddress` accepts MODBUS 984 addresses (e.g. `400001` for holding, `300001` for input) or raw register offsets (0–0xFFFE). Hex strings with an `h` suffix (e.g. `"0h"`) are also supported — these require an explicit `register_type` since the offset alone is ambiguous. | Field | Type | Default | Notes | | --------------- | -------------- | --------- | ------------------------------------------------------------- | | `address` | `int` or `str` | — | 984-style int (400001), raw int (0–0xFFFE), or hex str ("0h") | | `register_type` | `RegisterType` | `HOLDING` | Inferred for 984 addresses; required for hex | The codegen maps `register_type` to the correct Modbus function code: | Type | Send | Receive | | ---------------- | -------------------------------- | ------- | | `HOLDING` | FC 6 (single) / FC 16 (multiple) | FC 3 | | `INPUT` | — | FC 4 | | `COIL` | FC 5 (single) / FC 15 (multiple) | FC 1 | | `DISCRETE_INPUT` | — | FC 2 | `word_swap` on `send()`/`receive()` controls how 32-bit values (DINT, REAL) are split across register pairs. `False` (default) = high word first (big-endian, common in VFDs and power meters). `True` = low word first. RTU (serial) targets are not yet supported for CircuitPython code generation. | Field | Type | Default | Notes | | ------------ | ----- | ------- | ---------------------------------------------------------- | | `name` | `str` | — | Unique identifier, referenced by `target=` in send/receive | | `ip` | `str` | — | Remote PLC address | | `port` | `int` | `502` | | | `device_id` | `int` | `1` | Modbus unit ID (0–255) | | `timeout_ms` | `int` | `1000` | Per-transaction timeout | Unlike the Click dialect's threaded `send`/`receive`, the CircuitPython versions generate a non-blocking state machine. Each transaction advances one step per scan (connect → send request → wait for response → apply result). The scan loop is never blocked. Status tags (`sending`/`receiving`, `success`, `error`, `exception_response`) update as the transaction progresses. When the rung condition goes false, status tags reset to defaults. ## TagMap and mapped_tag_scope `tag_map` is required when `modbus_server` or `modbus_client` is set. It determines which tags are visible over Modbus — the TagMap maps semantic tags to Click hardware addresses, and the codegen uses those addresses as Modbus register addresses. `mapped_tag_scope` controls how many TagMap entries get backing variables in the generated code: | Value | Behavior | | ----------------------------- | ----------------------------------------------------------- | | `"referenced_only"` (default) | Tags used in logic and tags with non-default initial values | | `"all_mapped"` | Every entry in the TagMap gets a backing variable | The default avoids allocating RAM for tags that no rung references and start with type-default values. Use `"all_mapped"` when an HMI or SCADA system needs to write values via Modbus even though no ladder rung touches them. ## Scan cycle with Modbus 1. Read physical inputs 1. Execute rungs 1. Write physical outputs 1. Service Modbus server 1. Service Modbus client 1. Edge snapshots, watchdog pet, scan sleep The server and client service calls run unconditionally — including in STOP mode. This matches Click behavior: an HMI can still read tag state and see `sys.mode_run` as `False` while the PLC is stopped. ## Both server and client The P1AM-200 can be both server and client simultaneously. A single Ethernet setup is shared. ``` source = generate_circuitpy( logic, hw, target_scan_ms=10.0, modbus_server=ModbusServerConfig(ip="192.168.1.200"), modbus_client=ModbusClientConfig( targets=(ModbusTcpTarget(name="plc1", ip="192.168.1.20"),) ), tag_map=mapping, ) ``` # CircuitPython Dialect `pyrung.circuitpy` generates a self-contained CircuitPython scan loop from the same program you already tested in simulation. The same pyrung logic runs in Python *and* on real hardware — write once, simulate, deploy. It targets the [ProductivityOpen P1AM-200](https://facts-engineering.github.io/modules/P1AM-200/P1AM-200.html): tinkerer-friendly PLC hardware with a CircuitPython runtime, and currently the only PLC-class controller where this kind of codegen is this straightforward. The hardware model (35 modules), code generator, and validation are fully implemented and tested. ## Installation ``` pip install pyrung ``` ``` from pyrung import Bool, Int, Real, PLC, Program, Rung, out, copy, rise from pyrung.circuitpy import P1AM, RunStopConfig, board, generate_circuitpy, validate_circuitpy_program ``` ## Hardware setup — P1AM The [ProductivityOpen P1AM-200](https://facts-engineering.github.io/modules/P1AM-200/P1AM-200.html) is a base unit with up to 15 slots for [Productivity1000 I/O modules](). Configure hardware with the `P1AM` class: ``` hw = P1AM() inputs = hw.slot(1, "P1-08SIM") # 8-ch discrete input → InputBlock(Bool) outputs = hw.slot(2, "P1-08TRS") # 8-ch discrete output → OutputBlock(Bool) analog = hw.slot(3, "P1-04ADL-1") # 4-ch analog input → InputBlock(Int) ``` Each `hw.slot()` call returns: - **`InputBlock`** for input-only modules - **`OutputBlock`** for output-only modules - **`tuple[InputBlock, OutputBlock]`** for combo modules (e.g. `P1-16CDR`) Slots must be numbered 1–15 and contiguous from 1 (matching physical wiring order). Use the optional `name` keyword to override the default `"Slot{N}"` prefix: ``` hw.slot(1, "P1-08SIM", name="Sensors") # tags named Sensors.1 .. Sensors.8 ``` ### Supported modules The built-in `MODULE_CATALOG` includes 35 modules from the [Productivity1000 series](https://facts-engineering.github.io/) across six categories: | Category | Count | Examples | | ----------------- | ----- | ------------------------------------- | | Discrete input | 7 | P1-08SIM, P1-16ND3, P1-08NA, P1-08NE3 | | Discrete output | 9 | P1-08TRS, P1-16TR, P1-04TRS, P1-08TA | | Combo discrete | 3 | P1-16CDR, P1-15CDD1, P1-15CDD2 | | Analog input | 7 | P1-04AD, P1-04ADL-1, P1-08ADL-1 | | Analog output | 4 | P1-04DAL-1, P1-04DAL-2, P1-08DAL-1 | | Temperature input | 3 | P1-04RTD, P1-04THM, P1-04NTC | | Combo analog | 2 | P1-4ADL2DAL-1, P1-4ADL2DAL-2 | Type mapping: discrete → `Bool`, analog → `Int`, temperature → `Real`. ### Excluded modules (v2) [`P1-04PWM`](https://facts-engineering.github.io/modules/P1-04PWM/P1-04PWM.html) (PWM) and [`P1-02HSC`](https://facts-engineering.github.io/modules/P1-02HSC/P1-02HSC.html) (high-speed counter) require a multi-tag channel model and are deferred to v2. ## Writing a CircuitPython program Programs use the same DSL as any other pyrung dialect — only the hardware setup and export step are dialect-specific. ``` from pyrung import Bool, Int, PLC, Program, Rung, out, copy, rise from pyrung.circuitpy import P1AM, write_circuitpy # 1. Configure hardware hw = P1AM() inputs = hw.slot(1, "P1-08SIM") outputs = hw.slot(2, "P1-08TRS") Button = inputs[1] Light = outputs[1] Counter = Int("Counter") # 2. Write logic — identical to any other pyrung program with Program() as logic: with Rung(Button): out(Light) # 3. Simulate with PLC(logic, dt=0.1) as plc: Button.value = True plc.step() assert Light.value is True # 4. Generate code.py — copy to CIRCUITPY drive write_circuitpy(logic, hw, target_scan_ms=10.0, watchdog_ms=500, output_dir=".") ``` ## Code generation ``` from pyrung.circuitpy import write_circuitpy # Write code.py to a directory — the common case write_circuitpy(logic, hw, target_scan_ms=10.0, output_dir=".") ``` `write_circuitpy` generates and writes `code.py` to `output_dir`. It accepts the same parameters as `generate_circuitpy` plus `output_dir`. For programmatic use (no file I/O): ``` from pyrung.circuitpy import generate_circuitpy result = generate_circuitpy(logic, hw, target_scan_ms=10.0) result.code # code.py content (str) result.runtime # pyrung_rt.py content (str) — for maintainer use ``` | Parameter | Type | Description | | ------------------ | ---------------------------- | --------------------------------------------------------------------------------------------------------------- | | `program` | `Program` | Ladder logic program | | `hw` | `P1AM` | Hardware configuration | | `target_scan_ms` | `float` | Target scan cycle time in milliseconds (must be > 0) | | `watchdog_ms` | `int \| None` | Hardware watchdog timeout in ms, or `None` to disable | | `runstop` | `RunStopConfig \| None` | Optional board-switch RUN/STOP mapping with debounce | | `modbus_server` | `ModbusServerConfig \| None` | Modbus TCP server config; see [Modbus TCP](https://ssweber.github.io/pyrung/dialects/circuitpy-modbus/index.md) | | `modbus_client` | `ModbusClientConfig \| None` | Modbus TCP client config; see [Modbus TCP](https://ssweber.github.io/pyrung/dialects/circuitpy-modbus/index.md) | | `tag_map` | `TagMap \| None` | Click address mapping for Modbus-visible tags; required with server/client | | `mapped_tag_scope` | `MappedTagScope` | `"referenced_only"` (default) or `"all_mapped"` | The generator runs strict validation internally and checks the generated source for syntax errors before returning. ### Generated output Code generation produces two files: - **`code.py`** — your program. Tags, ladder logic, I/O, scan loop. Regenerated every time you change your logic. - **`pyrung_rt.mpy`** — the pyrung runtime library (pre-compiled). Helper functions, Modbus TCP server/client state machine. Same for every project — install once. `code.py` imports from `pyrung_rt` at runtime. The `.mpy` format loads faster and uses less memory than `.py` on CircuitPython. ### code.py structure 1. **Imports** — `time`, `json`, `board`, `busio`, `P1AM`, `sdcardio`, `storage`, `microcontroller`, `pyrung_rt` 1. **Configuration** — `TARGET_SCAN_MS`, `WATCHDOG_MS`, slot module list, retentive schema hash 1. **Hardware bootstrap** — `P1AM.Base()`, `rollCall()`, optional watchdog init 1. **Tag declarations** — one variable per scalar tag, one list per block 1. **Memory buffers** — edge-detection state, scan timing 1. **SD mount / retentive load/save** — generated when retentive tags exist 1. **Modbus address mapping** — wires tags to the runtime's Modbus server/client 1. **Ladder logic** — compiled rungs 1. **`while True` scan loop** — reads inputs, executes rungs, writes outputs, paces to target scan time ### Retentive tag persistence Tags marked `retentive=True` are automatically persisted to an SD card: - **Storage path:** `/sd/memory.json` (atomic writes via temp file) - **Schema hash:** SHA-256 of tag names and types. On load, a schema mismatch (e.g. after a firmware change) skips the stale file and starts from defaults. - **NVM dirty flag:** `microcontroller.nvm[0]` is set to `1` before writing and cleared to `0` after. If the controller restarts mid-write, the dirty flag prevents loading a corrupt file. ### SD system command bits The generated runtime supports system SD command bits: - `board.save_memory_cmd` triggers `save_memory()` from ladder logic. - `system.storage.sd.delete_all_cmd` removes only retentive files (`/sd/memory.json` and `/sd/_memory.tmp`). - `system.storage.sd.eject_cmd` calls `storage.umount("/sd")` and keeps SD unavailable until reboot/remount. Commands are auto-cleared after servicing and pulse `system.storage.sd.write_status` for that scan. Command-operation failures set `system.storage.sd.error = True` and `system.storage.sd.error_code = 3`. Example ladder trigger: ``` with Program() as logic: with Rung(Bool("PersistNow")): out(board.save_memory_cmd) ``` ## Onboard board model `pyrung.circuitpy` exposes first-class onboard peripheral tags: - `board.switch` (`InputTag`, BOOL) - `board.led` (`OutputTag`, BOOL) - `board.neopixel.r/g/b` (`OutputTag`, INT channels, clamped 0..255 in generated writes) - `board.save_memory_cmd` (`OutputTag`, BOOL save trigger) These board tags can be used even when no slots are configured (zero-slot code generation). ## Optional RUN/STOP mapping Pass `RunStopConfig(...)` to `generate_circuitpy()` to map `board.switch` to runtime RUN/STOP mode: - debounced switch sampling (`debounce_ms`, default `30`) - `run_when_high` polarity control - optional exposure of `sys.mode_run` and `sys.cmd_mode_stop` (`expose_mode_tags=True`) - STOP skips rung execution and forces physical outputs off each scan - STOP->RUN resets non-retentive runtime state while preserving retentive values ### Watchdog When `watchdog_ms` is set, the generated code calls `base.config_watchdog()` and `base.start_watchdog()` at boot, then `base.pet_watchdog()` each scan. If the scan loop stalls longer than the timeout, the P1AM hardware resets the controller. ### Scan timing and overrun detection The scan loop paces itself to `target_scan_ms` using `time.monotonic()`. If a scan takes longer than the target, the overrun is counted and optionally printed (controlled by `PRINT_SCAN_OVERRUNS` in the generated code). ### Modbus TCP The generated code can include a Modbus TCP server, client, or both via the P1AM-ETH shield. Configuration and usage: - [CircuitPython Modbus TCP](https://ssweber.github.io/pyrung/dialects/circuitpy-modbus/index.md) ## Validation ``` report = validate_circuitpy_program(program, hw, mode="warn") print(report.summary()) for finding in report.errors + report.warnings + report.hints: print(f" {finding.severity}: [{finding.code}] {finding.message}") ``` | Parameter | Type | Default | Description | | --------- | -------------------- | -------- | --------------------------------------------------------------------- | | `program` | `Program` | — | Program to validate | | `hw` | `P1AM \| None` | `None` | Hardware config for I/O traceability checks | | `mode` | `"warn" \| "strict"` | `"warn"` | `"warn"` emits hints; `"strict"` enforces blocking findings as errors | ### Finding codes | Code | Trigger | Description | | -------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | `CPY_FUNCTION_CALL_VERIFY` | `FunctionCallInstruction` in program | Callable will be embedded via `inspect.getsource()` — verify it uses only CircuitPython-compatible APIs | | `CPY_IO_BLOCK_UNTRACKED` | I/O tag not traceable to a `P1AM` slot | Tag was created outside `hw.slot()` — it won't be wired to physical I/O in generated code | | `CPY_TIMER_RESOLUTION` | `on_delay` / `off_delay` with `Tms` timing | Millisecond timer accuracy depends on scan time; effective resolution is one scan | In `"warn"` mode these produce hints. In `"strict"` mode, `CPY_IO_BLOCK_UNTRACKED` is an error, while `CPY_FUNCTION_CALL_VERIFY` and `CPY_TIMER_RESOLUTION` remain advisory hints. `generate_circuitpy()` runs strict validation internally and blocks on validation errors. ## Deploying to hardware ### One-time board setup 1. Install [CircuitPython](https://circuitpython.org/board/p1am_200/) on the P1AM-200 1. Install the [CircuitPython P1AM library](https://github.com/facts-engineering/CircuitPython_P1AM) and its dependencies into `CIRCUITPY/lib/` 1. Download `pyrung_rt.mpy` from the [pyrung releases page](https://github.com/ssweber/pyrung/releases) and copy it to `CIRCUITPY/lib/` 1. Insert a FAT-formatted SD card for retentive tag storage (if using retentive tags) ### Iterate ``` write_circuitpy(logic, hw, target_scan_ms=10.0, output_dir=".") ``` Copy the generated `code.py` to the P1AM-200's `CIRCUITPY` drive. It runs automatically on boot. If your program uses `FunctionCallInstruction`, the callable's source is embedded verbatim. Ensure it only uses CircuitPython-compatible modules and APIs. ## CircuitPython constraints The P1AM-200 runs CircuitPython, which imposes limits beyond what the pyrung simulator allows: - **No hardware interrupts.** All I/O is polled each scan. Fast external signals can be missed between scans — choose `target_scan_ms` accordingly. - **No TLS.** Modbus TCP and any network traffic run unencrypted. Keep the P1AM-200 on a trusted, isolated network. - **Single-threaded.** The scan loop is cooperative. Long-running `FunctionCallInstruction` callables block the entire scan (and may trip the watchdog). - **Limited memory.** CircuitPython has a small heap. Programs with many tags or large blocks may hit memory limits — test on hardware early. ## External resources - [P1AM-200 documentation](https://facts-engineering.github.io/modules/P1AM-200/P1AM-200.html) — hardware specs, pinout, getting started - [CircuitPython P1AM library](https://github.com/facts-engineering/CircuitPython_P1AM) — the runtime library used by generated code - [Productivity1000 I/O module docs](https://facts-engineering.github.io/) — per-module wiring diagrams and specs - [P1AM-200 on AutomationDirect]() — ordering and datasheets - [CircuitPython documentation](https://docs.circuitpython.org/) — language reference for the target runtime # Click Python Codegen `ladder_to_pyrung()` and `ladder_to_pyrung_project()` convert Click ladder data back into executable pyrung Python source. This is the reverse of [`pyrung_to_ladder()`](https://ssweber.github.io/pyrung/dialects/click/#ladder-csv-export) — import from Click instead of export to Click. ## Single-file codegen `ladder_to_pyrung()` accepts a file path (to a CSV or directory) or a `LadderBundle` for in-memory round-trip without disk I/O. Generated timer and counter calls use pyrung's modern surface syntax: presets render positionally, the default millisecond unit is omitted, and non-default timer units use friendly strings like `"sec"` and `"hour"`. ``` from pyrung.click import ladder_to_pyrung code = ladder_to_pyrung("main.csv") # from CSV file code = ladder_to_pyrung("ladder_dir/") # from directory with subroutines/*.csv code = ladder_to_pyrung(bundle) # from LadderBundle (no disk) code = ladder_to_pyrung("main.csv", output_path="generated.py") # write to file ``` ### Round-trip ``` from pyrung.click import pyrung_to_ladder, ladder_to_pyrung bundle = pyrung_to_ladder(logic, mapping) code = ladder_to_pyrung(bundle) # no CSV files needed ``` ### Nickname substitution Three ways to provide nicknames for readable variable names: 1. `nickname_csv=` — path to a Click nickname CSV (Address.csv). Recommended, because it also enables structured type inference (see below). 1. `nicknames=` — pre-parsed `{operand: nickname}` dict (e.g. `{"X001": "start_button"}`). 1. Neither — raw operand names used as-is (`X001`, `DS1`, etc.). Cannot provide both `nickname_csv` and `nicknames`. ``` code = ladder_to_pyrung("main.csv", nickname_csv="Address.csv") code = ladder_to_pyrung("main.csv", nicknames={"X001": "start_button", "Y001": "motor"}) ``` ### Structured type inference When `nickname_csv=` is provided, codegen calls `TagMap.from_nickname_file()` internally. It reconstructs semantic metadata only from explicit markers such as `:block`, `:udt`, and `:named_array(...)`. Bare tags remain grouping-only, so the generated code keeps them as flat tags or raw bank ranges instead of inventing pyrung structures. Without `nickname_csv`, a named-array group comes back flat: ``` Channel1_Id = Int("Channel1_Id") Channel1_Val = Int("Channel1_Val") Channel2_Id = Int("Channel2_Id") Channel2_Val = Int("Channel2_Val") # in the program: copy(Channel1_Id, Channel2_Val) # in TagMap: mapping = TagMap({ Channel1_Id: ds[101], Channel1_Val: ds[102], ... }) ``` With `nickname_csv=` pointing to a CSV that has named-array markers: ``` @named_array(Int, count=2) class Channel: Id = 0 Val = 0 # in the program: copy(Channel[1].Id, Channel[2].Val) # in TagMap: mapping = TagMap([ *Channel.map_to(ds.select(101, 104)), ], include_system=False) ``` Named-array instance windows round-trip as whole-instance selects when the ladder uses an exact aligned span: ``` blockcopy(RecipeProfile.instance(2), WorkingRecipe.select(1, 3)) fill(0, RecipeProfile.instance_select(1, 2)) ``` This works for both dense and sparse layouts — the range length must be an exact multiple of the stride and start at an instance boundary. For UDTs (fields spanning different memory banks), per-field `map_to` is emitted: ``` @udt(count=2) class Motor: Running: Bool = False Speed: Int = 0 mapping = TagMap([ Motor.Running.map_to(c.select(101, 102)), Motor.Speed.map_to(ds.select(1001, 1002)), ], include_system=False) ``` Singleton structures (count=1) use dotted access without indexing: `Config.Timeout`, not `Config[1].Timeout`. If the CSV uses numbered names for a singleton (e.g. `Config1_Timeout`), the importer infers `always_number=True` and emits `@named_array(Int, always_number=True)`. For details on `@named_array` and `@udt` syntax, see the [Tag Structures guide](https://ssweber.github.io/pyrung/guides/tag-structures/index.md). For the CSV marker format, see [CSV marker format](https://ssweber.github.io/pyrung/dialects/click/#csv-marker-format). ### What codegen infers Tag types from operand prefixes (`X`→Bool, `DS`→Int, etc.), block ranges from `DS100..DS102` notation, OR expansion via `Or()`, branch conditions, timer/counter pin chains, `for`/`next` loops, and comments. For the CSV format that codegen reads, see the [laddercodec CSV format guide](https://ssweber.github.io/laddercodec/guides/csv-format/). ### Round-trip guarantee The generated code is designed to round-trip: `exec()` the output, then `pyrung_to_ladder(logic, mapping)` reproduces the original CSV. This is tested extensively. ## Multi-file project codegen `ladder_to_pyrung_project()` generates a complete Python project instead of a single file. Each subroutine gets its own file with a `@subroutine` decorator, tags and the TagMap live in `tags.py`, and `main.py` ties everything together. ``` from pyrung.click import ladder_to_pyrung_project files = ladder_to_pyrung_project("ladder_dir/") files = ladder_to_pyrung_project("ladder_dir/", nickname_csv="Address.csv") files = ladder_to_pyrung_project("ladder_dir/", output_dir="pump_project_py/") ``` The return value is a `dict[str, str]` mapping relative paths to content: ``` tags.py # tag declarations, structures, TagMap main.py # Program context, main rungs, call() statements subroutines/ __init__.py startup.py # @subroutine("startup") decorated function alarm_handler.py ``` ### How subroutines are represented Each subroutine file defines a decorated function that auto-registers with the Program when called: ``` # subroutines/startup.py from pyrung import Rung, subroutine, out from tags import SubLight @subroutine("startup") def startup(): with Rung(): out(SubLight) ``` `main.py` imports and calls it by reference (not by string name): ``` from subroutines.startup import startup with Program() as logic: with Rung(Button): call(startup) ``` ### Per-file imports Each generated file imports only what it uses. A subroutine that touches `X001` and `Y001` won't import `X002` or `DS1`. `tags.py` is the single source of truth for all tag declarations; other files import from it. ### Nickname and structure support Same as `ladder_to_pyrung()` — pass `nickname_csv=` for readable variable names and automatic `@named_array` / `@udt` inference, or `nicknames=` for a pre-parsed dict. `tags.py` suppresses the inline `# X001` address comments since the TagMap and nickname CSV already provide that mapping. # Click PLC Dialect `pyrung.click` adds Click-PLC-specific blocks, type aliases, address mapping, nickname file I/O, validation, and a soft-PLC adapter on top of the hardware-agnostic core. ## Installation ``` pip install pyrung ``` `pyrung.click` uses `pyclickplc` for address metadata, nickname CSV I/O, and soft-PLC server/client integration. ## Imports ``` from pyrung import Bool, Int, PLC, Program, Rung, copy, latch, reset, rise from pyrung.click import x, y, c, ds, TagMap ``` ## Workflow: write first, validate later pyrung is intentionally permissive. Write logic with semantic tag names and native Python expressions — no address mapping required — and simulate freely. Hardware constraints are opt-in. The natural progression: 1. **Write** — define semantic tags (`StartButton`, `MotorRunning`, `Speed`) and express logic in Python 1. **Simulate** — run tests with a fixed `dt`; set inputs, assert outputs, iterate 1. **Map** — create a `TagMap` linking semantic tags to Click hardware addresses 1. **Validate** — `mapping.validate(logic, mode="warn")` surfaces Click-incompatible patterns 1. **Iterate** — fix findings, tighten to `mode="strict"` when the program is clean The validator tells you exactly what Click can't do — inline expressions, unsupported pointer modes, type mismatches — before you discover it at deploy time. ## Pre-built blocks `pyrung.click` exports pre-built blocks for every Click memory bank: | Variable | Bank | Type | Block kind | | -------- | ------------------- | ---- | ----------- | | `x` | X (inputs) | BOOL | InputBlock | | `y` | Y (outputs) | BOOL | OutputBlock | | `c` | C (bit memory) | BOOL | Block | | `ds` | DS (int memory) | INT | Block | | `dd` | DD (double int) | DINT | Block | | `dh` | DH (hex memory) | WORD | Block | | `df` | DF (float memory) | REAL | Block | | `t` | T (timer done) | BOOL | Block | | `td` | TD (timer acc) | INT | Block | | `ct` | CT (counter done) | BOOL | Block | | `ctd` | CTD (counter acc) | DINT | Block | | `sc` | SC (system control) | BOOL | Block | | `sd` | SD (system data) | INT | Block | | `txt` | TXT (text memory) | CHAR | Block | | `xd` | XD (word image) | WORD | InputBlock | | `yd` | YD (word image) | WORD | OutputBlock | Addresses use canonical Click display names: ``` x[1].name # "X001" y[1].name # "Y001" c[1].name # "C1" ds[1].name # "DS1" ``` ### Sparse banks X and Y are sparse banks with non-contiguous valid addresses. `.select()` filters to valid addresses automatically: ``` x.select(1, 21) # yields X001..X016 and X021 (17 tags, not 21) ``` ### Per-slot configuration Pre-built blocks support per-slot runtime policy for retention, default values, and naming. Configure before first access to a slot: ``` ds.slot(10, name="RecipeStep") ds.slot(200, retentive=True, default=123) td.slot(1, 5, retentive=False, default=0) ``` If a slot is already materialized (`block[n]` accessed), later configuration for that slot raises `ValueError`. ## Type aliases Click-style constructor aliases as alternatives to IEC names: | Click alias | IEC equivalent | | ----------- | -------------- | | `Bit` | `Bool` | | `Int2` | `Dint` | | `Float` | `Real` | | `Hex` | `Word` | | `Txt` | `Char` | ## DSL naming philosophy This DSL follows Click PLC instruction naming as closely as possible, departing only when a Python conflict exists **and** the replacement name is genuinely better in a Python-hosted context. 1. **Keep the Click name** when it's a clear action verb with no conflict: `out`, `reset`, `fill`, `copy`, `blockcopy`. 1. **Use a domain synonym** when Click's name shadows a Python builtin or standard library module: `set` → `latch`, `math` → `calc`. Both are well-understood PLC terminology. 1. **Use clarified intent** when Python's execution model changes the semantics: `return` → `return_early`. In Click, every subroutine needs an explicit `RET`. In this DSL, normal subroutine completion is implicit, so the only use is early exit — and the name should say so. | Click instruction | pyrung DSL | Reason | | ----------------- | -------------- | ------------------------------------------------------- | | `SET` | `latch` | Shadows Python builtin `set` | | `MATH` | `calc` | Shadows Python stdlib `math` | | `RET` | `return_early` | Normal return is implicit; only early exit needs a call | The CSV ladder export uses Click-facing token names: `calc` emits as `math(...)`, `return_early` as `return()`, and `forloop` as `for(...)`. See the [laddercodec CSV format guide](https://ssweber.github.io/laddercodec/guides/csv-format/) for the full token grammar. ## Writing a Click program ``` from pyrung import Bool, Real, PLC, Program, Rung, copy, latch, reset, rise from pyrung.click import x, y, c, ds, df, TagMap # Define semantic tags (hardware-agnostic) StartButton = Bool("StartButton") StopButton = Bool("StopButton") MotorRunning = Bool("MotorRunning") RawSpeed = Real("RawSpeed") Speed = Real("Speed") # Write logic using semantic names with Program() as logic: with Rung(rise(StartButton)): latch(MotorRunning) with Rung(rise(StopButton)): reset(MotorRunning) with Rung(MotorRunning): copy(RawSpeed, Speed) # Simulate — no mapping needed with PLC(logic, dt=0.1) as plc: StartButton.value = True plc.step() ``` ## TagMap — mapping to hardware `TagMap` links semantic tags and blocks to Click hardware addresses. Mapping is separate from logic — write and simulate first, map when ready. ### Dict constructor ``` mapping = TagMap({ StartButton: x[1], # BOOL → X001 StopButton: x[2], # BOOL → X002 MotorRunning: y[1], # BOOL → Y001 RawSpeed: df[1], # REAL → DF1 (analog input) Speed: df[11], # REAL → DF11 }) ``` ### Method-call syntax ``` StartButton.map_to(x[1]) Speed.map_to(ds[1]) ``` ### Mapping a block to a hardware range ``` Alarms = Block("Alarms", TagType.BOOL, 1, 100) mapping = TagMap({ Alarms: c.select(101, 200), # Alarms[1..100] → C101..C200 }) ``` ### Timer/Counter mapping `Timer` and `Counter` are built-in UDTs. Map them to Click hardware banks explicitly, just like any other block: ``` OvenTimer = Timer.clone("OvenTimer") mapping = TagMap([ OvenTimer.Done.map_to(t[1]), OvenTimer.Acc.map_to(td[1]), ]) ``` #### Codegen from nicknames When importing ladder CSV with nicknames, a nickname on a T or CT address produces a named clone: ``` # Without nickname → clone named after the operand T1 = Timer.clone("T1") on_delay(T1, preset=100) # With nickname {"T1": "OvenTimer"} → clone named after the nickname OvenTimer = Timer.clone("OvenTimer") on_delay(OvenTimer, preset=100) ``` The T (done-bit) nickname drives the name — any nickname on the matching TD/CTD address is silently overridden. This keeps `.Done` and `.Acc` fields under a single consistent prefix. If the nickname already ends with `_Done` or `_Acc`, the suffix is stripped automatically — `"OvenTimer_Done"` becomes `Timer.clone("OvenTimer")`. Condition references resolve through the clone. A rung conditioned on T1 renders as `OvenTimer.Done`: ``` with Rung(OvenTimer.Done): out(AlarmLight) ``` ### Input inference When a tag is mapped to an input bank (`x` or `xd`), `TagMap` automatically marks it `external=True`. This tells the verifier that the tag's value comes from outside the ladder — `prove()` and `pyrung lock` will treat it as a nondeterministic input without requiring you to declare `external=True` yourself. ``` StartButton = Bool("StartButton") # external=False initially mapping = TagMap({StartButton: x[1]}) assert StartButton.external # now True — stamped by TagMap ``` Tags mapped to output or memory banks (`y`, `c`, `ds`, etc.) are not stamped `external`. Tags that are `readonly` are skipped — `readonly` and `external` are mutually exclusive. ### Output inference When a tag is mapped to an output bank (`y` or `yd`), `TagMap` automatically marks it `lock=True`. This includes the tag in the default `pyrung lock` projection, so physical outputs are tracked in the lock file without requiring you to declare `lock=True` yourself. ``` MotorOut = Bool("MotorOut") # lock=False initially mapping = TagMap({MotorOut: y[1]}) assert MotorOut.lock # now True — stamped by TagMap ``` Tags mapped to input or memory banks (`x`, `c`, `ds`, etc.) are not affected. ### Type validation at map time `TagMap` validates that logical and hardware data types match: ``` TagMap({Speed: c[1]}) # raises: INT cannot map to C (BIT) ``` ### From nickname file Load an existing Click nickname CSV: ``` mapping = TagMap.from_nickname_file("project.csv") ``` The importer reconstructs blocks, structures, and standalone tags from CSV comment markers and nickname patterns. Standalone nicknames become individual `Tag` objects. Non-marker address-comment text is preserved on standalone tags and block slots for CSV round-trip export. For strict grouping validation, pass `mode="strict"` — this fails fast on structure grouping mismatches instead of falling back to plain blocks with a warning. ``` mapping = TagMap.from_nickname_file("project.csv", mode="strict") ``` Imported structure metadata is available via `mapping.structures` and `mapping.structure_by_name("Base")`. #### CSV marker format The comment field on CSV rows carries block and structure boundaries. Three marker types: | Marker | Example | Meaning | | ------------ | ------------------------ | ------------------------------- | | Opening | `` | Start of a semantic plain block | | Closing | `` | End of a semantic plain block | | Self-closing | `` | Single-row semantic marker | Bare tags are grouping-only comments: ``, ``, ``, and `` do not reconstruct pyrung semantics. They simply group rows visually, and any inner nicknames import as ordinary standalone tags. Bracket metadata can appear alongside markers or ordinary comments. It preserves tag flags, choices, range bounds, units, and physical annotations through nickname CSV import/export. For int-backed boolean values in register memory, use the built-in shorthand `[choices=Bool]` instead of spelling out `[choices=False:0|True:1]`. **Plain blocks** use explicit `:block` markers: `` / ``. If the logical start differs from the inferred default, export/import uses `` or ``. If a boundary row has a blank nickname, default retentive/default value, and its comment is only the block tag, pyrung treats that row as boundary metadata rather than a slot rename/config override. **Named arrays** use `:named_array` markers. Count and stride are optional — the importer infers them from the row span between open/close tags: ``` count=1, stride from row count count=2, stride = rows / count count=2, stride=3 (fully explicit) ``` When both count and stride are given, the row span must equal `count × stride`. When stride is omitted, the row count must be divisible by count. **Nickname patterns.** For `count > 1`, nicknames must follow `{Base}{instance}_{field}` with 1-based instance numbers. The instance is derived from position: `position // stride + 1`. Field names are the suffix after the prefix strip (`Channel1_Id` → field `Id`). For `count = 1`, nicknames default to the compact form `{Base}_{field}` (no instance number). If the CSV already uses numbered names like `Task1_Call`, the importer detects this and sets `always_number=True` automatically. To force numbered names explicitly, add `,always_number` to the marker: ``` ``` The `always_number` flag only matters for singletons — `count > 1` is always numbered regardless. **Instance rules.** Instance 1 defines the field template — all its fields must be explicitly named. Instance 2+ fields must match instance 1's pattern (correct field name and instance number). Unnamed slots in instance 2+ are fine (silently skipped). A field name in instance 2+ that wasn't defined in instance 1 is an error. Example — `Channel` with 2 instances, 3 fields, no gaps (`stride=3`): | Address | Nickname | Comment | | ------- | --------------- | ----------------------------- | | DS101 | `Channel1_Id` | `` | | DS102 | `Channel1_Val` | | | DS103 | `Channel1_Name` | | | DS104 | `Channel2_Id` | | | DS105 | `Channel2_Val` | | | DS106 | `Channel2_Name` | `` | Singleton with compact names (`count=1`): | Address | Nickname | Comment | | ------- | ----------- | -------------------------- | | DS501 | `Task_Call` | `` | | DS502 | `Task_Done` | `` | If stride exceeds the field count, the extra slots are gaps (empty nicknames): | Address | Nickname | Comment | | ------- | ---------------- | ---------------------------- | | DS101 | `Sensor1_Raw` | `` | | DS102 | `Sensor1_Scaled` | | | DS103 | | *(gap)* | | DS104 | `Sensor2_Raw` | | | DS105 | `Sensor2_Scaled` | | | DS106 | | `` | Click codegen can round-trip aligned whole-instance spans back into pyrung as `Name.instance(...)` or `Name.instance_select(...)` instead of raw bank ranges. This works for both dense and sparse layouts: ``` blockcopy(RecipeProfile.instance(2), WorkingRecipe.select(1, 3)) fill(0, RecipeProfile.instance_select(1, 2)) ``` **UDTs** use explicit `:udt` markers per field and memory bank. Each attribute range is a separate marker: ``` ``` The importer collects all `Base.Field:udt` ranges that share the same base name and assembles them into a single `@udt`. Field attribute ranges must have matching hardware span lengths across all attributes. Bare dotted tags such as `` are grouping-only and do not reconstruct a UDT. Nesting is not supported — a UDT field cannot itself be a named array (e.g. `Sts.Recipes:named_array(20,50)` won't parse). Flatten the name instead: `StsRecipes:named_array(20,50)`. **Conflict rules.** The same base name cannot be used across different marker kinds. These combinations are all errors: - Same name as both `:named_array` and `.attr:udt` - Same name as both `:block` and `:named_array` - Same name as both `:block` and `.attr:udt` - Duplicate `:named_array` or `:block` markers for the same name ### To nickname file Export to Click nickname CSV for import into CLICK Programming Software: ``` mapping.to_nickname_file("project.csv") ``` Mapped tags and blocks emit rows with canonical logical names, initial values, retentive flags, and preserved address comments. If a row needs both a block marker and user comment text, both are emitted in the same CSV comment field. Unmapped tags are omitted. ## Validation After mapping, validate your program against Click hardware restrictions: ``` report = mapping.validate(logic, mode="warn") print(report.summary()) for finding in report.findings: print(f" {finding.level}: {finding.message}") ``` Common findings: | Issue | pyrung allows | Click requires | | ------------------------------ | --------------------- | ----------------------- | | Pointer in `copy` source | Any block, arithmetic | DS only, no arithmetic | | Inline expression in condition | `(A + B) > 100` | Must use `calc()` first | ### Timer preset limits Click timer accumulators are 16-bit signed INT (max 32,767). A literal preset exceeding this range silently clamps at runtime. The validator reports `CLK_TIMER_PRESET_OVERFLOW` for out-of-range presets — use a larger time unit instead. | Unit | Max preset | Max duration | | ----- | ---------- | ------------ | | `Tms` | 32,767 | 32.7 seconds | | `Ts` | 32,767 | 9.1 hours | | `Tm` | 32,767 | 22.7 days | | `Th` | 32,767 | 3.7 years | | `Td` | 32,767 | 89 years | ``` # Wrong — clamps silently to 32.7 seconds on_delay(MyTimer, preset=60000) # Right — use seconds on_delay(MyTimer, preset=60, unit="sec") ``` Findings are hints by default (`mode="warn"`). Use `mode="strict"` to treat hints as errors. ## Ladder CSV export `pyrung_to_ladder(program, tag_map)` emits deterministic Click ladder CSV row matrices via `LadderBundle`. ``` from pyrung.click import pyrung_to_ladder bundle = pyrung_to_ladder(logic, mapping) bundle.main_rows # inspect rows in-memory bundle.write("./output") # write main.csv + subroutines/*.csv to disk ``` For the consumer-facing CSV decode contract (files, row semantics, token formats, branch wiring, and supported tokens), see the [laddercodec CSV format guide](https://ssweber.github.io/laddercodec/guides/csv-format/). To convert ladder CSV back into pyrung Python source, see [Click Python Codegen](https://ssweber.github.io/pyrung/dialects/click-codegen/index.md). ### Empty and comment-only rungs Empty rungs survive the round-trip. A `with Rung(): pass` in pyrung exports as `NOP` in the Click CSV AF column and imports back as `pass`. ``` comment("--- Motor Control Section ---") with Rung(): pass # becomes NOP in Click ladder CSV ``` For Click programs that want to be explicit, `pyrung.click` also provides `nop()`: ``` from pyrung.click import nop comment("Section header") with Rung(): nop() # one per rung, must be the sole instruction ``` Both forms produce identical CSV output. ## Loading PLC state Use Click Programming Software's **Data > Read Data from PLC** to dump the live state of a Click PLC to CSV, then load that snapshot into a pyrung runner so it starts right where the PLC was. ``` from pyclickplc import read_plc_data from pyrung.core import PLC, SystemState data = read_plc_data("data.csv", skip_default=True) tags = mapping.tags_from_plc_data(data) runner = PLC(logic, initial_state=SystemState().with_tags(tags)) ``` `read_plc_data` (from `pyclickplc`) parses the CSV and returns `{hardware_address: value}`. `tags_from_plc_data` translates the hardware keys to logical tag names using the TagMap, silently skipping any addresses that aren't mapped. The result is ready for `SystemState.with_tags()` or `runner.patch()`. `skip_default=True` omits zero/false/empty values — useful since PLC dumps are exhaustive and most addresses are at their default. You can also inject a PLC snapshot mid-run: ``` data = read_plc_data("data.csv", skip_default=True) runner.patch(mapping.tags_from_plc_data(data)) runner.step() # applied at the start of this scan ``` ## ClickDataProvider — soft PLC `ClickDataProvider` implements the `pyclickplc` `DataProvider` protocol, bridging pyrung's `SystemState` to a Modbus TCP server. This lets pyrung act as a soft PLC accessible from Click Programming Software or any Modbus client. ``` from pyrung.click import ClickDataProvider from pyclickplc.server import ClickServer provider = ClickDataProvider(runner, tag_map=mapping) server = ClickServer(provider, port=502) ``` Reads return the current committed state. Writes queue a `runner.patch()` for the next scan. If another device on the LAN can't reach the soft PLC, check the Windows Firewall: both the TCP port (default 502) and the Python interpreter need to be allowed through. ### Word-image (XD / YD) addressing - `XD*` reads compute bit-image words from current X bit state. - `YD*` reads compute bit-image words from current Y bit state. - `YD*` writes fan out to the corresponding Y bits. - `XD*` writes are rejected (read-only). ## Communication instructions `send` and `receive` implement Modbus communication with remote devices. Two addressing modes are supported: ### Click addresses (Click-to-Click) Use a Click address string for `remote_start` when talking to another Click PLC: ``` from pyrung.click import ModbusTcpTarget, send, receive plc = ModbusTcpTarget("plc1", "192.168.1.20") send( target=plc, remote_start="DS1", source=LocalSetpoint, sending=CommSending, success=CommSuccess, error=CommError, exception_response=CommEx, ) receive( target=plc, remote_start="DS1", dest=LocalWords.select(1, 4), receiving=CommReceiving, success=CommSuccess, error=CommError, exception_response=CommEx, ) ``` Click handles word swap and character order natively — no configuration needed on the pyrung side. ### Raw Modbus addresses (any device) Use `ModbusAddress` for `remote_start` when talking to non-Click Modbus devices (VFDs, meters, sensors, etc.): ``` from pyrung import ModbusAddress, ModbusTcpTarget, ModbusRtuTarget, RegisterType, send, receive vfd = ModbusTcpTarget("vfd", "192.168.1.50") send( target=vfd, remote_start=ModbusAddress(400001), source=SpeedSetpoint, sending=VfdSending, success=VfdSuccess, error=VfdError, exception_response=VfdEx, ) meter = ModbusRtuTarget("meter", "/dev/ttyUSB0", device_id=3, baudrate=19200) receive( target=meter, remote_start=ModbusAddress(300001), dest=MeterReading, receiving=MeterReceiving, success=MeterSuccess, error=MeterError, exception_response=MeterEx, word_swap=True, ) ``` `ModbusAddress` accepts MODBUS 984 addresses (e.g. `400001` for holding, `300001` for input, `100001` for discrete input) or hex strings with an `h` suffix (e.g. `"0h"`, `"FFFEh"`). For 984 addresses, the register type is inferred from the prefix. Hex addresses need an explicit `RegisterType` since the offset alone is ambiguous. `word_swap` controls how 32-bit values (DINT, REAL) are packed across register pairs. `False` (default) = high word first, `True` = low word first. Only relevant for 32-bit Click types (DD, DF, etc.). `RegisterType` selects the Modbus function code: `HOLDING` (FC 3/6/16, default), `INPUT` (FC 4, read-only), `COIL` (FC 1/5/15), `DISCRETE_INPUT` (FC 2, read-only). Sending to `INPUT` or `DISCRETE_INPUT` raises `ValueError`. ### Target types | Type | Transport | Live I/O | Codegen | | ----------------- | --------- | ------------------------------------------------------ | --------------------------------------- | | `ModbusTcpTarget` | Ethernet | Yes (pymodbus for raw, pyclickplc for Click addresses) | Yes | | `ModbusRtuTarget` | Serial | Yes (pymodbus) | Not yet | | `str` (name only) | — | No (inert) | Yes (resolved via `ModbusClientConfig`) | When `target` is a `ModbusTcpTarget` or `ModbusRtuTarget`, communication runs asynchronously in a background worker pool — the scan loop stays synchronous. When `target` is a plain string, the instruction is inert during simulation and exists only for CircuitPython code generation. # API Reference # API Reference This section is generated from explicit, versioned API manifests. ## Stable Core Pages - [Runtime API](https://ssweber.github.io/pyrung/reference/api/runtime/index.md) - [Data Model API](https://ssweber.github.io/pyrung/reference/api/data-model/index.md) - [Program Structure API](https://ssweber.github.io/pyrung/reference/api/program-structure/index.md) - [Instruction Set API](https://ssweber.github.io/pyrung/reference/api/instruction-set/index.md) ## Dialect Pages - [Click Dialect API](https://ssweber.github.io/pyrung/reference/api/click-dialect/index.md) - [CircuitPython Dialect API](https://ssweber.github.io/pyrung/reference/api/circuitpy-dialect/index.md) # Runtime API **Tier:** Stable Core Runner lifecycle, system points, timebase helpers, and feedback harness support. ## pyrung.PLC Generator-driven PLC execution engine. Executes PLC logic as pure functions: Logic(state) -> new_state. The consumer controls execution via step(), enabling: - Input injection via patch() - Inspection of retained historical state via runner.history - Pause/resume at any scan boundary Attributes: | Name | Type | Description | | ----------------- | ------------- | --------------------------------------------------- | | `current_state` | `SystemState` | The current SystemState snapshot. | | `history` | `History` | Query interface for retained SystemState snapshots. | | `simulation_time` | `float` | Current simulation clock (seconds). | | `time_mode` | `TimeMode` | Current time mode (FIXED_STEP or REALTIME). | ### program ``` program: Any ``` The Program object if the PLC was constructed from one, else None. ### current_state ``` current_state: SystemState ``` Current state snapshot. ### tags ``` tags: MappingProxyType[str, Tag] ``` Read-only mapping of tag name → Tag object. ### bounds_violations ``` bounds_violations: dict[str, BoundsViolation] ``` Constraint violations from the most recent scan, if any. ### history ``` history: History ``` Read-only history query surface. ### playhead ``` playhead: int ``` Current scan id used for inspection/time-travel queries. ### dataview ``` dataview: Any ``` Chainable query over this program's tag dependency graph. Convenience shorthand for `plc.program.dataview()` — builds (and caches) the static program graph lazily on first access. ### query ``` query: Any ``` Survey namespace for whole-program dynamic analysis. ### simulation_time ``` simulation_time: float ``` Current simulation clock in seconds. ### time_mode ``` time_mode: TimeMode ``` Current time mode. ### debug ``` debug: _DebugNamespace ``` Debugger-facing methods and internal runtime access. ### battery_present ``` battery_present: bool ``` Simulated backup battery presence. ### forces ``` forces: Mapping[str, bool | int | float | str] ``` Read-only view of active persistent overrides. ### seek ``` seek(scan_id: int) -> SystemState ``` Move playhead to a retained scan and return that snapshot. ### rewind ``` rewind(seconds: float) -> SystemState ``` Move playhead backward in time by `seconds` and return snapshot. ### rung_firings ``` rung_firings(scan_id: int | None = None) -> PMap ``` Return rung firings for the given scan (default: playhead). Returns `PMap[int, PMap[str, Any]]` mapping each rung index that fired (had any write, even if all were filtered by PDG) during the scan to the filtered `{tag_name: value_written}` map. Synthesized on demand from per-rung range-encoded timelines (:class:`RungFiringTimelines`); rungs with no timeline covering `scan_id` contribute nothing. Populated uniformly by both the non-debug (`step()` / `run()`) and debug (DAP `pyrungStepScan` / continue) scan paths via `ScanContext.capturing_rung`. .. todo:: ``` A rung whose condition is True but whose writes are identical to the already-pending values will not appear here. This is an acceptable approximation for causal-chain attribution; for accurate cold-rung detection a ``_last_condition_result`` field on ``Rung`` may be needed later. ``` ### diff ``` diff( scan_a: int, scan_b: int ) -> dict[str, tuple[Any, Any]] ``` Return changed tag values between two retained historical scans. ### cause ``` cause( tag: Tag | str, scan: int | None = None, *, to: Any = _SENTINEL, assume: dict[str, Any] | None = None, ) -> CausalChain | None ``` Explain what caused a tag to transition. **Recorded** (default, `to` omitted): walks recorded history backward from the transition. Returns `None` if no transition was found. **Projected** (`to=value`): projects forward from the current state, finding reachable paths that would drive the tag to *value*. Returns a `CausalChain` with `mode='projected'` (reachable) or `mode='unreachable'` (stranded, with `blockers`). Never returns `None` in projected mode. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tag` | \`Tag | str\` | Tag object or tag name string. | | `scan` | \`int | None\` | Specific scan to examine (recorded mode only). | | `to` | `Any` | Target value for projected mode. When provided, the method returns the path that would drive tag to this value from the current state. | `_SENTINEL` | | `assume` | \`dict[str, Any] | None\` | Tag-to-value overrides for projected mode. Pins the given tags to specified values during analysis. Raises ValueError if used without to= or if any key is a readonly tag. | Returns: | Name | Type | Description | | ---- | ------------- | ----------- | | `A` | \`CausalChain | None\` | | | \`CausalChain | None\` | ### effect ``` effect( tag: Tag | str, scan: int | None = None, *, from_: Any = _SENTINEL, assume: dict[str, Any] | None = None, steady_state_k: int = 3, max_scans: int = 1000, ) -> CausalChain | None ``` Trace the downstream effects of a tag transition. **Recorded** (default, `from_` omitted): walks recorded history forward from an actual transition. Returns `None` if no transition was found. **Projected** (`from_=value`): what-if analysis — if the tag transitioned from *value* right now, what downstream effects would follow? Returns `mode='projected'` (possibly empty steps for dead-end) or `mode='unreachable'` if the trigger can't fire. Never returns `None` in projected mode. Parameters: | Name | Type | Description | Default | | ---------------- | ---------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `tag` | \`Tag | str\` | Tag object or tag name string. | | `scan` | \`int | None\` | Specific scan of the transition (recorded mode only). | | `from_` | `Any` | Current value for projected what-if analysis. For Bool tags the TO value is inferred as not from\_. | `_SENTINEL` | | `assume` | \`dict[str, Any] | None\` | Tag-to-value overrides for projected mode. Pins the given tags to specified values during analysis. Raises ValueError if used without from\_= or if any key is a readonly tag. | | `steady_state_k` | `int` | Stop after this many consecutive scans with no new effects (recorded mode only, default 3). | `3` | | `max_scans` | `int` | Hard cap on forward scans (recorded mode only, default 1000). | `1000` | Returns: | Name | Type | Description | | ---- | ------------- | ----------- | | `A` | \`CausalChain | None\` | | | \`CausalChain | None\` | ### recovers ``` recovers( tag: Tag | str, *, assume: dict[str, Any] | None = None ) -> bool ``` True if *tag* has a reachable clear path from the current state. Convenience predicate: `cause(tag, to=resting).mode != 'unreachable'`. For the underlying chain (witness or blockers), call `cause()` directly. Tags marked `external=True` always return True — the recovery path exists outside the ladder by declaration. When *assume* is provided the external shortcut is skipped so the analysis runs with the given overrides. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | ----------- | ------------------------------------------------------------------------------------------ | | `tag` | \`Tag | str\` | Tag object or tag name string. | | `assume` | \`dict[str, Any] | None\` | Tag-to-value overrides. Pins the given tags to specified values during projected analysis. | ### fork ``` fork(scan_id: int | None = None) -> PLC ``` Create an independent runner from retained historical state. Parameters: | Name | Type | Description | Default | | --------- | ----- | ----------- | --------------------------------------------------------------- | | `scan_id` | \`int | None\` | Snapshot to fork from. Defaults to current committed tip state. | ### fork_from ``` fork_from(scan_id: int) -> PLC ``` Create an independent runner from a retained historical snapshot. ### replay_to ``` replay_to(target_scan_id: int) -> PLC ``` Reconstruct historical state, preferring compiled replay when supported. ### replay_trace_at ``` replay_trace_at( target_scan_id: int, ) -> dict[int, RungTrace] ``` Reconstruct the rung-trace dict for a historical scan. Runs the same replay walk as `replay_to` up to `target_scan_id - 1` on the plain scan path, then drives `_scan_steps_debug` for `target_scan_id` so the replay fork's `_current_rung_traces` gets populated. Returns a copy of that dict; the replay fork is discarded. The `_replay_mode` guards in `_commit_scan` (monitors, breakpoints) and `_set_rtc_and_record` cover the debug path too — both generators funnel through the same commit sink. A one-slot cache (`_cached_replay_trace`) hits when the same `target_scan_id` is requested back-to-back. It is cleared on any tip advance (`_run_single_scan`) and on reset paths that reset the log (reboot, stop→run, via `_clear_retained_debug_trace_caches`). Traces only exist for scans that actually executed — the fork anchor / initial scan was never stepped in debug mode — so `target_scan_id` must be strictly greater than `_initial_scan_id`. ### stop ``` stop() -> None ``` Transition PLC to STOP mode. ### reboot ``` reboot() -> SystemState ``` Power-cycle the runner and return the reset state. Reboot is destructive: tags reset to defaults (except battery-preserved retentive values), `state.scan_id` and `state.timestamp` return to 0. Because post-reboot scan_ids would alias pre-reboot entries in every sparse channel (patches, forces, rtc_base_changes, dts), the scan log and checkpoints are reset to a fresh recording session rooted at the post-reboot scan 0. Pre-reboot history is not replay-addressable — users who need that should `fork()` before rebooting. ### set_rtc ``` set_rtc(value: datetime) -> None ``` Set the current RTC value for the runner. ### patch ``` patch( tags: Mapping[str, bool | int | float | str] | Mapping[Tag, bool | int | float | str] | Mapping[str | Tag, bool | int | float | str], ) -> None ``` Queue tag values for next scan (one-shot). Values are applied at the start of the next step() call, then cleared. Use for momentary inputs like button presses. Parameters: | Name | Type | Description | Default | | ------ | -------------------- | ----------- | ------- | | `tags` | \`Mapping\[str, bool | int | float | ### force ``` force( tag: str | Tag, value: bool | int | float | str ) -> None ``` Persistently override a tag value until explicitly removed. The forced value is applied at the pre-logic force pass (phase 3) and re-applied at the post-logic force pass (phase 5) every scan. Logic may temporarily diverge the value mid-scan, but the post-logic pass restores it before outputs are written. Forces persist across scans until `unforce()` or `clear_forces()` is called. Multiple forces may be active simultaneously. If a tag is both patched and forced in the same scan, the force overwrites the patch during the pre-logic pass. Parameters: | Name | Type | Description | Default | | ------- | ------ | ----------- | ----------------------------------- | | `tag` | \`str | Tag\` | Tag name or Tag object to override. | | `value` | \`bool | int | float | Raises: | Type | Description | | ------------ | --------------------------------------- | | `ValueError` | If the tag is a read-only system point. | ### unforce ``` unforce(tag: str | Tag) -> None ``` Remove a single persistent force override. After removal the tag resumes its logic-computed value starting from the next scan. Parameters: | Name | Type | Description | Default | | ----- | ----- | ----------- | --------------------------------------------- | | `tag` | \`str | Tag\` | Tag name or Tag object whose force to remove. | ### clear_forces ``` clear_forces() -> None ``` Remove all active persistent force overrides. All forced tags resume their logic-computed values starting from the next scan. ### forced ``` forced( overrides: Mapping[str, bool | int | float | str] | Mapping[Tag, bool | int | float | str] | Mapping[str | Tag, bool | int | float | str], ) -> Iterator[PLC] ``` Temporarily apply forces for the duration of the context. On entry, saves the current force map and adds the given overrides. On exit (normally or on exception), the exact previous force map is restored — forces that existed before the context are reinstated, and forces added inside the context are removed. Safe for nesting: inner `forced()` contexts layer on top of outer ones without disrupting them. Parameters: | Name | Type | Description | Default | | ----------- | -------------------- | ----------- | ------- | | `overrides` | \`Mapping\[str, bool | int | float | Example ``` with plc.forced({"AutoMode": True, "Fault": False}): plc.run(5) # AutoMode and Fault forces released here ``` ### monitor ``` monitor( tag: str | Tag, callback: Callable[[Any, Any], None] ) -> _RunnerHandle ``` Call `callback(current, previous)` after commit when tag value changes. ### when ``` when( *conditions: Condition | Tag | Callable[[SystemState], bool] | tuple[Condition | Tag, ...] | list[Condition | Tag], ) -> _BreakpointBuilder ``` Create a breakpoint builder evaluated after each committed scan. Accepts `Tag`/`Condition` expressions (implicit AND) or a single callable predicate receiving `SystemState`. ### step ``` step() -> SystemState ``` Execute one full scan cycle and return the committed state. ### run ``` run(cycles: int) -> SystemState ``` Execute up to `cycles` scans, stopping early on pause breakpoints. Parameters: | Name | Type | Description | Default | | -------- | ----- | --------------------------- | ---------- | | `cycles` | `int` | Number of scans to execute. | *required* | Returns: | Type | Description | | ------------- | --------------------------------------- | | `SystemState` | The final SystemState after all cycles. | ### run_for ``` run_for(seconds: float) -> SystemState ``` Run until simulation time advances by N seconds or a pause breakpoint fires. Parameters: | Name | Type | Description | Default | | --------- | ------- | ----------------------------------- | ---------- | | `seconds` | `float` | Minimum simulation time to advance. | *required* | Returns: | Type | Description | | ------------- | ----------------------------------------------------- | | `SystemState` | The final SystemState after reaching the target time. | ### run_until ``` run_until( *conditions: Condition | Tag | Callable[[SystemState], bool] | tuple[Condition | Tag, ...] | list[Condition | Tag], max_cycles: int = 10000, ) -> SystemState ``` Run until condition is true, pause breakpoint fires, or max_cycles reached. Accepts `Tag`/`Condition` expressions (implicit AND) or a single callable predicate receiving `SystemState`. Parameters: | Name | Type | Description | Default | | ------------ | ----------- | ----------------------------------------------- | ------------------------------- | | `conditions` | \`Condition | Tag | Callable\[[SystemState], bool\] | | `max_cycles` | `int` | Maximum scans before giving up (default 10000). | `10000` | Returns: | Type | Description | | ------------- | -------------------------------------------------------------------- | | `SystemState` | The state that matched the condition, or final state if max reached. | ## pyrung.Physical Declares physical feedback characteristics for a tag or field. Bool feedback (has timing):: ``` motor_fb = Physical("MotorFb", on_delay="2s", off_delay="500ms") ``` Analog feedback (has profile):: ``` temp = Physical("TempSensor", profile="first_order") ``` The `system` field groups related feedback for reporting. ## pyrung.Harness Automatic feedback harness driven by Physical + link= declarations. Walks all known tags to find link= couplings, installs edge monitors on En tags, and schedules Fb patches using declared timing (bool) or profile functions (analog). Usage:: ``` plc = PLC(logic, dt=0.010) harness = Harness(plc) harness.install() plc.run_for(0.5) # Fb patches synthesized automatically ``` ### couplings ``` couplings() -> Iterator[Coupling] ``` Iterate over all discovered couplings (bool and profile). ## pyrung.Coupling Public view of one enable→feedback coupling discovered by the harness. ## pyrung.profile ``` profile(name: str) -> Callable[..., Any] ``` Register an analog feedback profile function. The decorated function is called once per scan tick for each active analog coupling:: ``` @profile("generic_thermal") def generic_thermal(cur, en, dt): if en: return cur + 0.5 * dt return cur ``` ## pyrung.system ``` system = SystemNamespaces( sys=SysNamespace( always_on=Bool("sys.always_on"), first_scan=Bool("sys.first_scan"), scan_clock_toggle=Bool("sys.scan_clock_toggle"), clock_10ms=Bool("sys.clock_10ms"), clock_100ms=Bool("sys.clock_100ms"), clock_500ms=Bool("sys.clock_500ms"), clock_1s=Bool("sys.clock_1s"), clock_1m=Bool("sys.clock_1m"), clock_1h=Bool("sys.clock_1h"), mode_switch_run=Bool("sys.mode_switch_run"), mode_run=Bool("sys.mode_run"), cmd_mode_stop=Bool("sys.cmd_mode_stop"), cmd_watchdog_reset=Bool("sys.cmd_watchdog_reset"), fixed_scan_mode=Bool("sys.fixed_scan_mode"), battery_present=Bool("sys.battery_present"), scan_counter=Int( "sys.scan_counter", retentive=False ), scan_time_current_ms=Int( "sys.scan_time_current_ms", retentive=False ), scan_time_min_ms=Int( "sys.scan_time_min_ms", retentive=False ), scan_time_max_ms=Int( "sys.scan_time_max_ms", retentive=False ), scan_time_fixed_setup_ms=Int( "sys.scan_time_fixed_setup_ms", retentive=False ), interrupt_scan_time_ms=Int( "sys.interrupt_scan_time_ms", retentive=False ), ), rtc=RtcNamespace( year4=Int("rtc.year4", retentive=False), year2=Int("rtc.year2", retentive=False), month=Int("rtc.month", retentive=False), day=Int("rtc.day", retentive=False), weekday=Int("rtc.weekday", retentive=False), hour=Int("rtc.hour", retentive=False), minute=Int("rtc.minute", retentive=False), second=Int("rtc.second", retentive=False), new_year4=Int("rtc.new_year4", retentive=False), new_month=Int("rtc.new_month", retentive=False), new_day=Int("rtc.new_day", retentive=False), new_hour=Int("rtc.new_hour", retentive=False), new_minute=Int("rtc.new_minute", retentive=False), new_second=Int("rtc.new_second", retentive=False), apply_date=Bool("rtc.apply_date"), apply_date_error=Bool("rtc.apply_date_error"), apply_time=Bool("rtc.apply_time"), apply_time_error=Bool("rtc.apply_time_error"), ), fault=FaultNamespace( plc_error=Bool("fault.plc_error"), division_error=Bool("fault.division_error"), out_of_range=Bool("fault.out_of_range"), address_error=Bool("fault.address_error"), math_operation_error=Bool( "fault.math_operation_error" ), code=Int("fault.code", retentive=False), ), firmware=FirmwareNamespace( main_ver_low=Int( "firmware.main_ver_low", retentive=False ), main_ver_high=Int( "firmware.main_ver_high", retentive=False ), sub_ver_low=Int( "firmware.sub_ver_low", retentive=False ), sub_ver_high=Int( "firmware.sub_ver_high", retentive=False ), ), storage=StorageNamespace( sd=StorageSdNamespace( eject_cmd=Bool("storage.sd.eject_cmd"), delete_all_cmd=Bool( "storage.sd.delete_all_cmd" ), ready=Bool("storage.sd.ready"), write_status=Bool("storage.sd.write_status"), error=Bool("storage.sd.error"), error_code=Int( "storage.sd.error_code", retentive=False ), ) ), ) ``` # Data Model API **Tier:** Stable Core Structured tags, IEC data types, and memory block primitives. ## pyrung.Field ``` Field( type: TagType | None = None, default: Any = ..., retentive: bool | None = None, choices: type[IntEnum] | ChoiceMap | None = None, readonly: bool | None = None, external: bool | None = None, final: bool | None = None, public: bool | None = None, lock: bool | None = None, physical: Physical | None = None, link: str | None = None, min: int | float | None = None, max: int | float | None = None, uom: str | None = None, ) -> Any ``` ## pyrung.auto ``` auto(*, start: int = 1, step: int = 1) -> Any ``` Create a per-instance numeric default sequence descriptor. ## pyrung.udt ``` udt( *, count: int = 1, always_number: bool = False, readonly: bool = False, external: bool = False, final: bool = False, public: bool = False, lock: bool = False, ) -> Callable[[type[Any]], _StructRuntime] ``` Decorator that builds a mixed-type structured runtime from annotations. ## pyrung.named_array ``` named_array( base_type: object, *, count: int = 1, stride: int = 1, always_number: bool = False, readonly: bool = False, external: bool = False, final: bool = False, public: bool = False, lock: bool = False, ) -> Callable[[type[Any]], _NamedArrayRuntime] ``` Decorator that builds a single-type, instance-interleaved structured runtime. ## pyrung.Timer ``` Timer = _DoneAccRuntime( name="Timer", count=1, field_specs=( _FieldSpec("Done", BOOL, UNSET, retentive=False), _FieldSpec("Acc", INT, UNSET, retentive=True), ), ) ``` ## pyrung.Counter ``` Counter = _DoneAccRuntime( name="Counter", count=1, field_specs=( _FieldSpec("Done", BOOL, UNSET, retentive=False), _FieldSpec("Acc", DINT, UNSET, retentive=True), ), ) ``` ## pyrung.TagType Bases: `Enum` Data types for tags (IEC 61131-3 naming). ## pyrung.Bool Bases: `_TagTypeBase` Create a BOOL (1-bit boolean) tag. Not retentive by default — resets to `False` on power cycle. Example ``` Button = Bool("Button") Light = Bool("Light", retentive=True) ``` ## pyrung.Int Bases: `_TagTypeBase` Create an INT (16-bit signed integer, −32768 to 32767) tag. Retentive by default — survives power cycles. Example ``` Step = Int("Step") preset = Int("preset", retentive=False) ``` ## pyrung.Dint Bases: `_TagTypeBase` Create a DINT (32-bit signed integer, ±2 147 483 647) tag. Retentive by default. Use for counters or values that exceed INT range. Example ``` TotalCount = Dint("TotalCount") ``` ## pyrung.Real Bases: `_TagTypeBase` Create a REAL (32-bit IEEE 754 float) tag. Retentive by default. Use for analog presets and process values. Example ``` Temperature = Real("Temperature") FlowRate = Real("FlowRate", retentive=False) ``` ## pyrung.Char Bases: `_TagTypeBase` Create a CHAR (8-bit ASCII character) tag. Retentive by default. Use for single-character text values. For multi-character strings, use a `Block` of `CHAR` tags. In the Click dialect, `Txt` is an alias for `Char`. Example ``` ModeChar = Char("ModeChar") ``` ## pyrung.Word Bases: `_TagTypeBase` Create a WORD (16-bit unsigned integer, 0x0000–0xFFFF) tag. Retentive by default. Use for bit-packed status registers or hex values. In the Click dialect, `Hex` is an alias for `Word`. Example ``` StatusWord = Word("StatusWord") ``` ## pyrung.Block Factory for creating Tags from a typed memory region. `Block` defines a named, 1-indexed memory region where every address shares the same `TagType`. Indexing a `Block` returns a cached `LiveTag`. The block holds no runtime values — all values live in `SystemState.tags`. Address bounds are **inclusive** on both ends: `Block("DS", INT, 1, 100)` defines addresses 1–100 (100 tags). Indexing outside this range raises `IndexError`. Slice syntax (`block[1:10]`) is rejected — use `.select(start, end)` instead. For sparse blocks (e.g. Click X/Y banks with non-contiguous valid addresses), pass `valid_ranges` to restrict which addresses within `[start, end]` are legal. Parameters: | Name | Type | Description | Default | | ------------------- | ------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | Block prefix used to generate tag names (e.g. "DS" → "DS1", "DS2" …). | *required* | | `type` | `TagType` | TagType shared by all tags in this block. | *required* | | `start` | `int` | Inclusive lower bound address (must be ≥ 0). | *required* | | `end` | `int` | Inclusive upper bound address (must be ≥ start). | *required* | | `retentive` | `bool` | Whether tags in this block survive power cycles. Default False. | `False` | | `valid_ranges` | \`tuple\[tuple[int, int], ...\] | None\` | Optional tuple of (lo, hi) inclusive segments that constrain which addresses within [start, end] are accessible. Addresses outside all segments raise IndexError. | | `address_formatter` | \`Callable\[[str, int], str\] | None\` | Optional callable (block_name, addr) → str that overrides default tag name generation. Used by dialects for canonical display names like "X001". | Example ``` DS = Block("DS", TagType.INT, 1, 100) DS[1] # → LiveTag("DS1", TagType.INT) DS[101] # → IndexError # Range for block operations: DS.select(1, 10) # → BlockRange, tags DS1..DS10 # Indirect (pointer) addressing: idx = Int("Idx") DS[idx] # → IndirectRef, resolved at scan time DS[idx + 1] # → IndirectExprRef ``` ### slot ``` slot(addr: int) -> SlotView ``` ``` slot( addr: int, *, name: str = ..., retentive: bool = ..., default: Any = ..., comment: str = ..., choices: ChoiceMap | None = ..., readonly: bool = ..., external: bool = ..., final: bool = ..., public: bool = ..., physical: Physical | None = ..., link: str | None = ..., min: int | float | None = ..., max: int | float | None = ..., uom: str | None = ..., ) -> SlotView ``` ``` slot(addr: int, end: int) -> RangeSlotView ``` ``` slot( addr: int, end: int, *, retentive: bool = ..., default: Any = ..., ) -> RangeSlotView ``` ``` slot( addr: int, end: int | None = None, *, name: object = UNSET, retentive: bool | None = None, default: object = UNSET, comment: object = UNSET, choices: object = UNSET, readonly: object = UNSET, external: object = UNSET, final: object = UNSET, public: object = UNSET, lock: object = UNSET, physical: object = UNSET, link: object = UNSET, min: object = UNSET, max: object = UNSET, uom: object = UNSET, ) -> SlotView | RangeSlotView ``` Inspect, configure, or reset one or more block slots. Single slot:: ``` ds.slot(10) # inspect ds.slot(10, name="Speed", retentive=True) # configure ds.slot(10).reset() # clear overrides ``` Range:: ``` ds.slot(20, 30, retentive=True) # configure range ds.slot(20, 30).reset() # clear range ``` Parameters: | Name | Type | Description | Default | | ----------- | -------- | --------------------------------------- | ------------------------------------------------- | | `addr` | `int` | Slot address (always required). | *required* | | `end` | \`int | None\` | If given, defines an inclusive range [addr, end]. | | `name` | `object` | Custom tag name (single-slot only). | `UNSET` | | `retentive` | \`bool | None\` | Retentive policy override. | | `default` | `object` | Default value override. | `UNSET` | | `comment` | `object` | Comment override (empty string clears). | `UNSET` | Returns: | Type | Description | | ---------- | --------------- | | \`SlotView | RangeSlotView\` | ### select ``` select(start: int, end: int) -> BlockRange ``` ``` select( start: Tag | Expression, end: int | Tag | Expression ) -> IndirectBlockRange ``` ``` select( start: int, end: Tag | Expression ) -> IndirectBlockRange ``` ``` select( start: int | Tag | Any, end: int | Tag | Any ) -> BlockRange | IndirectBlockRange ``` Select an inclusive range of addresses for block operations. Both `start` and `end` are **inclusive**: `DS.select(1, 10)` yields 10 tags (1, 2, … 10). This mirrors the block constructor convention and avoids the off-by-one confusion of Python's half-open slices. For sparse blocks (`valid_ranges` set), returns only the valid addresses within the window — gaps are silently skipped. Parameters: | Name | Type | Description | Default | | ------- | ----- | ----------- | ------- | | `start` | \`int | Tag | Any\` | | `end` | \`int | Tag | Any\` | Returns: | Type | Description | | ------------ | -------------------- | | \`BlockRange | IndirectBlockRange\` | | \`BlockRange | IndirectBlockRange\` | | \`BlockRange | IndirectBlockRange\` | Raises: | Type | Description | | ------------ | ---------------------------------------------------- | | `ValueError` | If start > end. | | `IndexError` | If either bound is outside the block's [start, end]. | Example ``` # Static range DS.select(1, 100) # BlockRange, DS1..DS100 # Sparse window (Click X bank) x.select(1, 21) # valid tags only: X001..X016, X021 # Dynamic range (resolved each scan) DS.select(start_tag, end_tag) # IndirectBlockRange # Use with bulk instructions: fill(0, DS.select(1, 10)) blockcopy(DS.select(1, 10), DD.select(1, 10)) search(">=", 100, DS.select(1, 100), result=Found, found=FoundFlag) ``` ### map_to ``` map_to(target: BlockRange) -> MappingEntry ``` Create a logical-to-hardware mapping entry. ## pyrung.InputBlock Bases: `Block` Factory for creating `InputTag` instances from a physical input memory region. `InputBlock` is identical to `Block` except: - Indexing returns `LiveInputTag` (not `LiveTag`), so elements have `.immediate`. - Always non-retentive — physical inputs do not survive power cycles. Use `InputBlock` when the tags represent real hardware inputs (sensors, switches, etc.). In simulation, values are supplied via `runner.patch()` or `runner.force()` during the *Read Inputs* scan phase. Example ``` X = InputBlock("X", TagType.BOOL, 1, 16) X[1] # → LiveInputTag("X1", BOOL) X[1].immediate # → ImmediateRef — bypass image table X.select(1, 8) # → BlockRange for bulk operations ``` ## pyrung.OutputBlock Bases: `Block` Factory for creating `OutputTag` instances from a physical output memory region. `OutputBlock` is identical to `Block` except: - Indexing returns `LiveOutputTag` (not `LiveTag`), so elements have `.immediate`. - Always non-retentive — physical outputs do not survive power cycles. Writes to `OutputTag` elements are immediately visible to subsequent rungs within the same scan (standard PLC behavior). The actual hardware write happens at the *Write Outputs* scan phase (phase 6). Example ``` Y = OutputBlock("Y", TagType.BOOL, 1, 16) Y[1] # → LiveOutputTag("Y1", BOOL) Y[1].immediate # → ImmediateRef — bypass image table Y.select(1, 8) # → BlockRange for bulk operations ``` ## pyrung.RangeComparison Comparison expression over a block range, used by `search()`. Created by applying a comparison operator to a `.select()` result:: ``` DS.select(1, 100) >= 100 # RangeComparison(range, ">=", 100) Txt.select(1, 50) == "A" # RangeComparison(range, "==", "A") ``` ## pyrung.RangeSlotView Live view into a range of block slots. Returned by `block.slot(start, end)`. Call `.reset()` to clear all per-slot overrides for every address in the range. ### reset ``` reset() -> None ``` Clear all overrides for every address in this range. ## pyrung.SlotView Live view into a single block slot. Returned by `block.slot(addr)`. Properties reflect the *current* effective policy (inherited defaults + overrides). Call `.reset()` to clear all overrides and restore inherited defaults. ### reset ``` reset() -> None ``` Clear all overrides, restoring inherited defaults. # Program Structure API **Tier:** Stable Core Program/rung builders and control-flow composition primitives. ## pyrung.Program Container for PLC logic (rungs and subroutines). Used as a context manager to capture rungs with Program() as logic: with Rung(Button): out(Light) Also works with PLC runner = PLC(logic) ### register_dialect ``` register_dialect( name: str, validator: DialectValidator ) -> None ``` Register a portability validator callback for a dialect name. ### registered_dialects ``` registered_dialects() -> tuple[str, ...] ``` Return registered dialect names in deterministic order. ### validate ``` validate( dialect: str | None = None, *, mode: str = "warn", select: set[str] | None = None, ignore: set[str] | None = None, dt: float = 0.01, **kwargs: Any, ) -> Any ``` Run validation on this Program. Without arguments, runs all core validators and returns a `ValidationReport`. Use `select` / `ignore` to filter by rule code, and `dt` to configure the physical-realism validator. With a `dialect` string, runs dialect-specific portability validation (e.g. `logic.validate("click", mode="strict")`). ### dataview ``` dataview() -> DataView ``` Return a chainable query over this program's tag dependency graph. The graph is built lazily on first call and cached. ### simplified ``` simplified() -> dict[str, TerminalForm] ``` Compute the simplified Boolean form for every terminal tag. ## pyrung.Rung Context manager for defining a rung. Example with Rung(Button): out(Light) with Rung(Step == 0): out(Light1) copy(1, Step, oneshot=True) ### continued ``` continued() -> Rung ``` Mark this rung as a continuation of the previous rung's condition snapshot. All conditions in this rung will evaluate against the same frozen state as the prior rung, rather than taking a fresh snapshot. This models multiple independent wires on the same visual rung in Click's ladder editor. The reused snapshot must come from the prior rung in the same execution scope (main or the current subroutine). Returns: | Type | Description | | ------ | ------------------------------------------------- | | `Rung` | self, for chaining: with Rung(B).continued(): ... | ## pyrung.program Program and Rung context managers for the immutable PLC engine. Provides DSL syntax for building PLC programs: ``` with Program() as logic: with Rung(Button): out(Light) runner = PLC(logic) ``` ### CountDownBuilder Bases: `_BuilderBase` Builder for count_down instruction with chaining API. Supports required .reset() chaining: count_down(Counter[1], preset=25).reset(reset_tag) #### reset ``` reset( *conditions: Condition | Tag | tuple[Condition | Tag, ...] | list[Condition | Tag], ) -> Tag ``` Add reset condition (required). When reset condition is true, loads preset into accumulator and clears done bit. Parameters: | Name | Type | Description | Default | | ------------- | ----------- | ----------- | ---------------- | | `*conditions` | \`Condition | Tag | tuple\[Condition | Returns: | Type | Description | | ----- | ----------------- | | `Tag` | The done bit tag. | ### CountUpBuilder Bases: `_BuilderBase` Builder for count_up instruction with chaining API. Supports optional .down() and required .reset() chaining: count_up(Counter[1], preset=100).reset(reset_tag) count_up(Counter[1], preset=50).down(down_cond).reset(reset_tag) #### down ``` down( *conditions: Condition | Tag | tuple[Condition | Tag, ...] | list[Condition | Tag], ) -> CountUpBuilder ``` Add down trigger (optional). Creates a bidirectional counter that increments on rung true and decrements on down condition true. Parameters: | Name | Type | Description | Default | | ------------- | ----------- | ----------- | ---------------- | | `*conditions` | \`Condition | Tag | tuple\[Condition | Returns: | Type | Description | | ---------------- | ------------------ | | `CountUpBuilder` | Self for chaining. | #### reset ``` reset( *conditions: Condition | Tag | tuple[Condition | Tag, ...] | list[Condition | Tag], ) -> Tag ``` Add reset condition (required). When reset condition is true, clears both done bit and accumulator. Parameters: | Name | Type | Description | Default | | ------------- | ----------- | ----------- | ---------------- | | `*conditions` | \`Condition | Tag | tuple\[Condition | Returns: | Type | Description | | ----- | ----------------- | | `Tag` | The done bit tag. | ### OffDelayBuilder Bases: `_AutoFinalizeBuilderBase` Builder for off_delay instruction (TOF behavior, Click-style). Auto-resets when re-enabled. ### OnDelayBuilder Bases: `_AutoFinalizeBuilderBase` Builder for on_delay instruction with optional .reset() chaining (Click-style). Without .reset(): TON behavior (auto-reset on rung false, non-terminal) With .reset(): RTON behavior (manual reset required, terminal) #### reset ``` reset( *conditions: Condition | Tag | tuple[Condition | Tag, ...] | list[Condition | Tag], ) -> Tag ``` Add reset condition (makes timer retentive - RTON). When reset condition is true, clears both done bit and accumulator. Parameters: | Name | Type | Description | Default | | ------------- | ----------- | ----------- | ---------------- | | `*conditions` | \`Condition | Tag | tuple\[Condition | Returns: | Type | Description | | ----- | ----------------- | | `Tag` | The done bit tag. | ### ShiftBuilder Bases: `_BuilderBase` Builder for shift instruction with required .clock().reset() chaining. #### clock ``` clock( *conditions: Condition | Tag | tuple[Condition | Tag, ...] | list[Condition | Tag], ) -> ShiftBuilder ``` Set the shift clock trigger condition. #### reset ``` reset( *conditions: Condition | Tag | tuple[Condition | Tag, ...] | list[Condition | Tag], ) -> BlockRange | IndirectBlockRange ``` Finalize the shift instruction with required reset condition. ### Branch Context manager for a parallel branch within a rung. A branch executes when both the parent rung conditions AND the branch's own conditions are true. Example with Rung(Step == 0): out(Light1) with branch(AutoMode): # Only executes if Step==0 AND AutoMode out(Light2) copy(1, Step, oneshot=True) ### ForLoop Context manager for a repeated instruction block within a rung. ### Program Container for PLC logic (rungs and subroutines). Used as a context manager to capture rungs with Program() as logic: with Rung(Button): out(Light) Also works with PLC runner = PLC(logic) #### register_dialect ``` register_dialect( name: str, validator: DialectValidator ) -> None ``` Register a portability validator callback for a dialect name. #### registered_dialects ``` registered_dialects() -> tuple[str, ...] ``` Return registered dialect names in deterministic order. #### validate ``` validate( dialect: str | None = None, *, mode: str = "warn", select: set[str] | None = None, ignore: set[str] | None = None, dt: float = 0.01, **kwargs: Any, ) -> Any ``` Run validation on this Program. Without arguments, runs all core validators and returns a `ValidationReport`. Use `select` / `ignore` to filter by rule code, and `dt` to configure the physical-realism validator. With a `dialect` string, runs dialect-specific portability validation (e.g. `logic.validate("click", mode="strict")`). #### dataview ``` dataview() -> DataView ``` Return a chainable query over this program's tag dependency graph. The graph is built lazily on first call and cached. #### simplified ``` simplified() -> dict[str, TerminalForm] ``` Compute the simplified Boolean form for every terminal tag. ### Rung Context manager for defining a rung. Example with Rung(Button): out(Light) with Rung(Step == 0): out(Light1) copy(1, Step, oneshot=True) #### continued ``` continued() -> Rung ``` Mark this rung as a continuation of the previous rung's condition snapshot. All conditions in this rung will evaluate against the same frozen state as the prior rung, rather than taking a fresh snapshot. This models multiple independent wires on the same visual rung in Click's ladder editor. The reused snapshot must come from the prior rung in the same execution scope (main or the current subroutine). Returns: | Type | Description | | ------ | ------------------------------------------------- | | `Rung` | self, for chaining: with Rung(B).continued(): ... | ### Subroutine Context manager for defining a subroutine. Subroutines are named blocks of rungs that are only executed when called. Example with subroutine("my_sub"): with Rung(): out(Light) ### SubroutineFunc A decorated function that represents a subroutine. Created by using @subroutine("name") as a decorator. When passed to call(), auto-registers with the current Program on first use. Example @subroutine("init") def init_sequence(): with Rung(): out(Light) with Program() as logic: with Rung(Button): call(init_sequence) #### name ``` name: str ``` The subroutine name. ### ForbiddenControlFlowError Bases: `RuntimeError` Raised when Python control flow is used inside strict DSL scope. ### count_down ``` count_down( counter: DoneAccUDT, preset: Tag | int ) -> CountDownBuilder ``` Count Down instruction (CTD). Creates a counter that decrements every scan while the rung condition is True. Use `rise()` on the condition for edge-triggered counting. Example with Rung(rise(Dispense)): count_down(Counter[1], preset=25).reset(Reload) This is a terminal instruction. Requires .reset() chaining. Parameters: | Name | Type | Description | Default | | --------- | ------------ | ------------------------------------------------- | -------------------------- | | `counter` | `DoneAccUDT` | Counter instance (must have done and acc fields). | *required* | | `preset` | \`Tag | int\` | Target value (Tag or int). | Returns: | Type | Description | | ------------------ | ------------------------------ | | `CountDownBuilder` | Builder for chaining .reset(). | ### count_up ``` count_up( counter: DoneAccUDT, preset: Tag | int ) -> CountUpBuilder ``` Count Up instruction (CTU). Creates a counter that increments every scan while the rung condition is True. Use `rise()` on the condition for edge-triggered counting. Example with Rung(rise(PartSensor)): count_up(Counter[1], preset=100).reset(ResetBtn) This is a terminal instruction. Requires .reset() chaining. Parameters: | Name | Type | Description | Default | | --------- | ------------ | ------------------------------------------------- | -------------------------- | | `counter` | `DoneAccUDT` | Counter instance (must have done and acc fields). | *required* | | `preset` | \`Tag | int\` | Target value (Tag or int). | Returns: | Type | Description | | ---------------- | ------------------------------------------ | | `CountUpBuilder` | Builder for chaining .down() and .reset(). | ### off_delay ``` off_delay( timer: DoneAccUDT, preset: Tag | int, unit: TimeUnitStr = "ms", ) -> OffDelayBuilder ``` Off-Delay Timer instruction (TOF). Done bit is True while enabled. After disable, counts until preset, then done bit goes False. Auto-resets when re-enabled. Example with Rung(MotorCommand): off_delay(Timer[2], preset=10000) off_delay(Timer[2], 10, "s") # 10 seconds Off-delay timers are composable in-rung (not terminal). Parameters: | Name | Type | Description | Default | | -------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | | `timer` | `DoneAccUDT` | Timer instance (must have done and acc fields). | *required* | | `preset` | \`Tag | int\` | Delay time in time units (Tag or int). | | `unit` | `TimeUnitStr` | Time unit for accumulator (default: "ms"). Accepts "ms", "sec", "min", "hour", "day" and their variants (see :func:normalize_unit). | `'ms'` | Returns: | Type | Description | | ----------------- | -------------------------------------- | | `OffDelayBuilder` | Builder for the off_delay instruction. | ### on_delay ``` on_delay( timer: DoneAccUDT, preset: Tag | int, unit: TimeUnitStr = "ms", ) -> OnDelayBuilder ``` On-Delay Timer instruction (TON/RTON). Accumulates time while rung is true. Example with Rung(MotorRunning): on_delay(Timer[1], preset=5000) # TON keyword on_delay(Timer[1], 5000) # TON positional on_delay(Timer[1], 5, "s") # TON 5 seconds on_delay(Timer[1], preset=5000).reset(ResetBtn) # RTON Without .reset(), this is TON and remains composable in-rung. With .reset(), this is RTON and becomes terminal in the current flow. Parameters: | Name | Type | Description | Default | | -------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | `timer` | `DoneAccUDT` | Timer instance (must have done and acc fields). | *required* | | `preset` | \`Tag | int\` | Target value in time units (Tag or int). | | `unit` | `TimeUnitStr` | Time unit for accumulator (default: "ms"). Accepts "ms", "sec", "min", "hour", "day" and their variants (see :func:normalize_unit). | `'ms'` | Returns: | Type | Description | | ---------------- | --------------------------------------- | | `OnDelayBuilder` | Builder for optional .reset() chaining. | ### shift ``` shift( bit_range: BlockRange | IndirectBlockRange, ) -> ShiftBuilder ``` Shift register instruction builder. Data input comes from current rung power. Use .clock(...) then .reset(...) to finalize and add the instruction. Example with Rung(DataBit): shift(C.select(2, 7)).clock(ClockBit).reset(ResetBit) ### And ``` And( *conditions: Condition | Tag | ImmediateRef | tuple[Condition | Tag | ImmediateRef, ...] | list[Condition | Tag | ImmediateRef], ) -> AllCondition ``` AND condition - true when all sub-conditions are true. This is equivalent to comma-separated rung conditions, but useful when building grouped condition trees with Or(). Example with Rung(And(Ready, AutoMode)): out(StartPermissive) with Rung(Or(And(Ready, AutoMode), RemoteStart)): out(StartPermissive) ### Or ``` Or( *conditions: Condition | Tag | ImmediateRef, ) -> AnyCondition ``` OR condition - true when any sub-condition is true. Use this to combine multiple conditions with OR logic within a rung. Multiple conditions passed directly to Rung() are ANDed together. Example with Rung(Step == 1, Or(Start, CmdStart)): out(Light) # True if Step==1 AND (Start OR CmdStart) #### Grouped AND inside OR (explicit): with Rung(Or(Start, And(AutoMode, Ready), RemoteStart)): out(Light) Parameters: | Name | Type | Description | Default | | ------------ | ----------- | ----------- | -------------- | | `conditions` | \`Condition | Tag | ImmediateRef\` | Returns: | Type | Description | | -------------- | -------------------------------------------------------------- | | `AnyCondition` | AnyCondition that evaluates True if any sub-condition is True. | ### fall ``` fall(tag: Tag | ImmediateRef) -> FallingEdgeCondition ``` Falling edge contact (FE). True only on 1->0 transition. Requires PLC to track previous values. Example with Rung(fall(Button)): reset(MotorRunning) # Resets when button is released ### rise ``` rise(tag: Tag | ImmediateRef) -> RisingEdgeCondition ``` Rising edge contact (RE). True only on 0->1 transition. Requires PLC to track previous values. Example with Rung(rise(Button)): latch(MotorRunning) # Latches on button press, not while held ### branch ``` branch(*conditions: ConditionTerm) -> Branch ``` Create a parallel branch within a rung. A branch executes when both the parent rung conditions AND the branch's own conditions are true. Example with Rung(Step == 0): out(Light1) with branch(AutoMode): # Only executes if Step==0 AND AutoMode out(Light2) copy(1, Step, oneshot=True) Parameters: | Name | Type | Description | Default | | ------------ | --------------- | ---------------------------------------------------------------------------------------------------- | ------- | | `conditions` | `ConditionTerm` | Conditions that must be true (in addition to parent rung) for this branch's instructions to execute. | `()` | Returns: | Type | Description | | -------- | ----------------------- | | `Branch` | Branch context manager. | ### comment ``` comment(text: str) -> None ``` Attach a comment to the next rung. The comment is consumed by the immediately following `Rung()` constructor. Must be called inside a `Program` context. Example:: ``` comment("UnitMode Change") with Rung(C_UnitModeChgRequest): copy(1, C_UnitModeChgRequestBool, oneshot=True) ``` ### forloop ``` forloop(count: Tag | int, oneshot: bool = False) -> ForLoop ``` Create a repeated instruction block context. Example with Rung(Enable): with forloop(10) as loop: copy(Source[loop.idx + 1], Dest[loop.idx + 1]) ### subroutine ``` subroutine(name: str, *, strict: bool = True) -> Subroutine ``` Define a named subroutine. Subroutines are only executed when called via call(). They are NOT executed during normal program scan. Example with Program() as logic: with Rung(Button): call("my_sub") ``` with subroutine("my_sub"): with Rung(): out(Light) ``` ### program ``` program(fn: Callable[[], None]) -> Program ``` ``` program( fn: None = None, /, *, strict: bool = True ) -> Callable[[Callable[[], None]], Program] ``` ``` program( fn: Callable[[], None] | None = None, /, *, strict: bool = True, ) -> Program | Callable[[Callable[[], None]], Program] ``` Decorator to create a Program from a function. Example @program def my_logic(): with Rung(Button): out(Light) @program(strict=False) def permissive_logic(): with Rung(Button): out(Light) runner = PLC(my_logic) ### blockcopy ``` blockcopy( source: Any, dest: Any, *, convert: CopyConverter | None = None, oneshot: bool = False, ) -> None ``` Block copy instruction. Copies values from source BlockRange to dest BlockRange. Both ranges must have the same length. Pass `convert=to_value` or `convert=to_ascii` for text→numeric block conversion. Only `value` and `ascii` modes are supported. Example with Rung(CopyEnable): blockcopy(DS.select(1, 10), DD.select(1, 10)) Parameters: | Name | Type | Description | Default | | --------- | --------------- | ------------------------------------------------------- | ----------------------------------------------- | | `source` | `Any` | Source BlockRange or IndirectBlockRange from .select(). | *required* | | `dest` | `Any` | Dest BlockRange or IndirectBlockRange from .select(). | *required* | | `convert` | \`CopyConverter | None\` | Optional converter (to_value or to_ascii only). | | `oneshot` | `bool` | If True, execute only once per rung activation. | `False` | ### calc ``` calc( expression: Any, dest: Tag, *, oneshot: bool = False ) -> Tag ``` Calc instruction. Evaluates an expression and stores the result in dest, with truncation to the destination tag's bit width (modular wrapping). Key differences from copy(): - Truncates result to destination tag's type width - Division by zero produces 0 (not infinity) - Infers decimal/hex behavior from referenced tag types Example with Rung(Enable): calc(DS1 * DS2 + DS3, Result) calc(MaskA & MaskB, MaskResult) Parameters: | Name | Type | Description | Default | | ------------ | ------ | --------------------------------------------------- | ---------- | | `expression` | `Any` | Expression, Tag, or literal to evaluate. | *required* | | `dest` | `Tag` | Destination tag (type determines truncation width). | *required* | | `oneshot` | `bool` | If True, execute only once per rung activation. | `False` | Returns: | Type | Description | | ----- | ------------- | | `Tag` | The dest tag. | ### call ``` call(target: str | SubroutineFunc) -> None ``` Call a subroutine instruction. Executes the named subroutine when the rung is true. Accepts either a string name or a @subroutine-decorated function. Example with Rung(Button): call("init_sequence") with subroutine("init_sequence"): with Rung(): out(Light) #### Or with decorator: @subroutine("init") def init_sequence(): with Rung(): out(Light) with Program() as logic: with Rung(Button): call(init_sequence) ### copy ``` copy( source: Any, target: Tag | IndirectRef | IndirectExprRef, *, convert: CopyConverter | None = None, oneshot: bool = False, ) -> Tag | IndirectRef | IndirectExprRef ``` Copy instruction (CPY/MOV). Copies source value to target. Pass `convert=` for text/numeric conversion (see :mod:`pyrung.core.copy_converters`). Example with Rung(Button): copy(5, StepNumber) ### fill ``` fill( value: Any, dest: Any, *, oneshot: bool = False ) -> None ``` Fill instruction. Writes a constant value to every element in a BlockRange. Example with Rung(ClearEnable): fill(0, DS.select(1, 100)) Parameters: | Name | Type | Description | Default | | --------- | ------ | ----------------------------------------------------- | ---------- | | `value` | `Any` | Value to write (literal, Tag, or Expression). | *required* | | `dest` | `Any` | Dest BlockRange or IndirectBlockRange from .select(). | *required* | | `oneshot` | `bool` | If True, execute only once per rung activation. | `False` | ### latch ``` latch( target: Tag | BlockRange | ImmediateRef, ) -> Tag | BlockRange | ImmediateRef ``` Latch/Set instruction (SET). Sets target to True. Unlike OUT, does NOT reset when rung goes false. Use reset() to turn off. Example with Rung(StartButton): latch(MotorRunning) latch(C.select(1, 8)) ### out ``` out( target: Tag | BlockRange | ImmediateRef, *, oneshot: bool = False, ) -> Tag | BlockRange | ImmediateRef ``` Output coil instruction (OUT). Sets target to True when rung is true. Resets to False when rung goes false. Example with Rung(Button): out(Light) out(Y.select(1, 4)) ### pack_bits ``` pack_bits( bit_block: Any, dest: Any, *, oneshot: bool = False ) -> None ``` Pack BOOL tags from a BlockRange into a register destination. ### pack_text ``` pack_text( source_range: Any, dest: Any, *, allow_whitespace: bool = False, oneshot: bool = False, ) -> None ``` Pack Copy text mode: parse a TXT/CHAR range into a numeric destination. ### pack_words ``` pack_words( word_block: Any, dest: Any, *, oneshot: bool = False ) -> None ``` Pack two 16-bit tags from a BlockRange into a 32-bit destination. ### reset ``` reset( target: Tag | BlockRange | ImmediateRef, ) -> Tag | BlockRange | ImmediateRef ``` Reset/Unlatch instruction (RST). Sets target to its default value (False for bits, 0 for ints). Example with Rung(StopButton): reset(MotorRunning) reset(C.select(1, 8)) ### return_early ``` return_early() -> None ``` Return from the current subroutine. Example with subroutine("my_sub"): with Rung(Abort): return_early() ### run_enabled_function ``` run_enabled_function( fn: Callable[..., dict[str, Any]], *, ins: dict[ str, Tag | IndirectRef | IndirectExprRef | Any ] | None = None, outs: dict[str, Tag | IndirectRef | IndirectExprRef] | None = None, ) -> None ``` Execute a synchronous function every scan with rung enabled state. ### run_function ``` run_function( fn: Callable[..., dict[str, Any]], *, ins: dict[ str, Tag | IndirectRef | IndirectExprRef | Any ] | None = None, outs: dict[str, Tag | IndirectRef | IndirectExprRef] | None = None, oneshot: bool = False, ) -> None ``` Execute a synchronous function when rung power is true. ### search ``` search( comparison: RangeComparison, *, result: Tag, found: Tag, continuous: bool = False, oneshot: bool = False, ) -> Tag ``` Search instruction. Scans a selected range and writes the first matching address into `result`. Writes `found` True on hit; on miss writes `result=-1` and `found=False`. The first argument is a comparison expression built from a `.select()` range:: ``` search(DS.select(1, 100) >= 100, result=FoundAddr, found=FoundFlag) search(Txt.select(1, 50) == "A", result=FoundAddr, found=FoundFlag) ``` ### unpack_to_bits ``` unpack_to_bits( source: Any, bit_block: Any, *, oneshot: bool = False ) -> None ``` Unpack a register source into BOOL tags in a BlockRange. ### unpack_to_words ``` unpack_to_words( source: Any, word_block: Any, *, oneshot: bool = False ) -> None ``` Unpack a 32-bit register source into two 16-bit tags in a BlockRange. ## pyrung.branch ``` branch(*conditions: ConditionTerm) -> Branch ``` Create a parallel branch within a rung. A branch executes when both the parent rung conditions AND the branch's own conditions are true. Example with Rung(Step == 0): out(Light1) with branch(AutoMode): # Only executes if Step==0 AND AutoMode out(Light2) copy(1, Step, oneshot=True) Parameters: | Name | Type | Description | Default | | ------------ | --------------- | ---------------------------------------------------------------------------------------------------- | ------- | | `conditions` | `ConditionTerm` | Conditions that must be true (in addition to parent rung) for this branch's instructions to execute. | `()` | Returns: | Type | Description | | -------- | ----------------------- | | `Branch` | Branch context manager. | ## pyrung.comment ``` comment(text: str) -> None ``` Attach a comment to the next rung. The comment is consumed by the immediately following `Rung()` constructor. Must be called inside a `Program` context. Example:: ``` comment("UnitMode Change") with Rung(C_UnitModeChgRequest): copy(1, C_UnitModeChgRequestBool, oneshot=True) ``` ## pyrung.forloop ``` forloop(count: Tag | int, oneshot: bool = False) -> ForLoop ``` Create a repeated instruction block context. Example with Rung(Enable): with forloop(10) as loop: copy(Source[loop.idx + 1], Dest[loop.idx + 1]) ## pyrung.subroutine ``` subroutine(name: str, *, strict: bool = True) -> Subroutine ``` Define a named subroutine. Subroutines are only executed when called via call(). They are NOT executed during normal program scan. Example with Program() as logic: with Rung(Button): call("my_sub") ``` with subroutine("my_sub"): with Rung(): out(Light) ``` # Instruction Set API **Tier:** Stable Core Instruction blocks, conditions, and copy/casting modifiers. ## pyrung.out ``` out( target: Tag | BlockRange | ImmediateRef, *, oneshot: bool = False, ) -> Tag | BlockRange | ImmediateRef ``` Output coil instruction (OUT). Sets target to True when rung is true. Resets to False when rung goes false. Example with Rung(Button): out(Light) out(Y.select(1, 4)) ## pyrung.latch ``` latch( target: Tag | BlockRange | ImmediateRef, ) -> Tag | BlockRange | ImmediateRef ``` Latch/Set instruction (SET). Sets target to True. Unlike OUT, does NOT reset when rung goes false. Use reset() to turn off. Example with Rung(StartButton): latch(MotorRunning) latch(C.select(1, 8)) ## pyrung.reset ``` reset( target: Tag | BlockRange | ImmediateRef, ) -> Tag | BlockRange | ImmediateRef ``` Reset/Unlatch instruction (RST). Sets target to its default value (False for bits, 0 for ints). Example with Rung(StopButton): reset(MotorRunning) reset(C.select(1, 8)) ## pyrung.copy ``` copy( source: Any, target: Tag | IndirectRef | IndirectExprRef, *, convert: CopyConverter | None = None, oneshot: bool = False, ) -> Tag | IndirectRef | IndirectExprRef ``` Copy instruction (CPY/MOV). Copies source value to target. Pass `convert=` for text/numeric conversion (see :mod:`pyrung.core.copy_converters`). Example with Rung(Button): copy(5, StepNumber) ## pyrung.run_function ``` run_function( fn: Callable[..., dict[str, Any]], *, ins: dict[ str, Tag | IndirectRef | IndirectExprRef | Any ] | None = None, outs: dict[str, Tag | IndirectRef | IndirectExprRef] | None = None, oneshot: bool = False, ) -> None ``` Execute a synchronous function when rung power is true. ## pyrung.run_enabled_function ``` run_enabled_function( fn: Callable[..., dict[str, Any]], *, ins: dict[ str, Tag | IndirectRef | IndirectExprRef | Any ] | None = None, outs: dict[str, Tag | IndirectRef | IndirectExprRef] | None = None, ) -> None ``` Execute a synchronous function every scan with rung enabled state. ## pyrung.blockcopy ``` blockcopy( source: Any, dest: Any, *, convert: CopyConverter | None = None, oneshot: bool = False, ) -> None ``` Block copy instruction. Copies values from source BlockRange to dest BlockRange. Both ranges must have the same length. Pass `convert=to_value` or `convert=to_ascii` for text→numeric block conversion. Only `value` and `ascii` modes are supported. Example with Rung(CopyEnable): blockcopy(DS.select(1, 10), DD.select(1, 10)) Parameters: | Name | Type | Description | Default | | --------- | --------------- | ------------------------------------------------------- | ----------------------------------------------- | | `source` | `Any` | Source BlockRange or IndirectBlockRange from .select(). | *required* | | `dest` | `Any` | Dest BlockRange or IndirectBlockRange from .select(). | *required* | | `convert` | \`CopyConverter | None\` | Optional converter (to_value or to_ascii only). | | `oneshot` | `bool` | If True, execute only once per rung activation. | `False` | ## pyrung.fill ``` fill( value: Any, dest: Any, *, oneshot: bool = False ) -> None ``` Fill instruction. Writes a constant value to every element in a BlockRange. Example with Rung(ClearEnable): fill(0, DS.select(1, 100)) Parameters: | Name | Type | Description | Default | | --------- | ------ | ----------------------------------------------------- | ---------- | | `value` | `Any` | Value to write (literal, Tag, or Expression). | *required* | | `dest` | `Any` | Dest BlockRange or IndirectBlockRange from .select(). | *required* | | `oneshot` | `bool` | If True, execute only once per rung activation. | `False` | ## pyrung.pack_bits ``` pack_bits( bit_block: Any, dest: Any, *, oneshot: bool = False ) -> None ``` Pack BOOL tags from a BlockRange into a register destination. ## pyrung.pack_text ``` pack_text( source_range: Any, dest: Any, *, allow_whitespace: bool = False, oneshot: bool = False, ) -> None ``` Pack Copy text mode: parse a TXT/CHAR range into a numeric destination. ## pyrung.pack_words ``` pack_words( word_block: Any, dest: Any, *, oneshot: bool = False ) -> None ``` Pack two 16-bit tags from a BlockRange into a 32-bit destination. ## pyrung.unpack_to_bits ``` unpack_to_bits( source: Any, bit_block: Any, *, oneshot: bool = False ) -> None ``` Unpack a register source into BOOL tags in a BlockRange. ## pyrung.unpack_to_words ``` unpack_to_words( source: Any, word_block: Any, *, oneshot: bool = False ) -> None ``` Unpack a 32-bit register source into two 16-bit tags in a BlockRange. ## pyrung.calc ``` calc( expression: Any, dest: Tag, *, oneshot: bool = False ) -> Tag ``` Calc instruction. Evaluates an expression and stores the result in dest, with truncation to the destination tag's bit width (modular wrapping). Key differences from copy(): - Truncates result to destination tag's type width - Division by zero produces 0 (not infinity) - Infers decimal/hex behavior from referenced tag types Example with Rung(Enable): calc(DS1 * DS2 + DS3, Result) calc(MaskA & MaskB, MaskResult) Parameters: | Name | Type | Description | Default | | ------------ | ------ | --------------------------------------------------- | ---------- | | `expression` | `Any` | Expression, Tag, or literal to evaluate. | *required* | | `dest` | `Tag` | Destination tag (type determines truncation width). | *required* | | `oneshot` | `bool` | If True, execute only once per rung activation. | `False` | Returns: | Type | Description | | ----- | ------------- | | `Tag` | The dest tag. | ## pyrung.call ``` call(target: str | SubroutineFunc) -> None ``` Call a subroutine instruction. Executes the named subroutine when the rung is true. Accepts either a string name or a @subroutine-decorated function. Example with Rung(Button): call("init_sequence") with subroutine("init_sequence"): with Rung(): out(Light) ### Or with decorator: @subroutine("init") def init_sequence(): with Rung(): out(Light) with Program() as logic: with Rung(Button): call(init_sequence) ## pyrung.return_early ``` return_early() -> None ``` Return from the current subroutine. Example with subroutine("my_sub"): with Rung(Abort): return_early() ## pyrung.count_up ``` count_up( counter: DoneAccUDT, preset: Tag | int ) -> CountUpBuilder ``` Count Up instruction (CTU). Creates a counter that increments every scan while the rung condition is True. Use `rise()` on the condition for edge-triggered counting. Example with Rung(rise(PartSensor)): count_up(Counter[1], preset=100).reset(ResetBtn) This is a terminal instruction. Requires .reset() chaining. Parameters: | Name | Type | Description | Default | | --------- | ------------ | ------------------------------------------------- | -------------------------- | | `counter` | `DoneAccUDT` | Counter instance (must have done and acc fields). | *required* | | `preset` | \`Tag | int\` | Target value (Tag or int). | Returns: | Type | Description | | ---------------- | ------------------------------------------ | | `CountUpBuilder` | Builder for chaining .down() and .reset(). | ## pyrung.count_down ``` count_down( counter: DoneAccUDT, preset: Tag | int ) -> CountDownBuilder ``` Count Down instruction (CTD). Creates a counter that decrements every scan while the rung condition is True. Use `rise()` on the condition for edge-triggered counting. Example with Rung(rise(Dispense)): count_down(Counter[1], preset=25).reset(Reload) This is a terminal instruction. Requires .reset() chaining. Parameters: | Name | Type | Description | Default | | --------- | ------------ | ------------------------------------------------- | -------------------------- | | `counter` | `DoneAccUDT` | Counter instance (must have done and acc fields). | *required* | | `preset` | \`Tag | int\` | Target value (Tag or int). | Returns: | Type | Description | | ------------------ | ------------------------------ | | `CountDownBuilder` | Builder for chaining .reset(). | ## pyrung.event_drum ``` event_drum( *, outputs: Sequence[Tag], events: Sequence[Condition | Tag], pattern: Sequence[Sequence[bool | int]], current_step: Tag, completion_flag: Tag, ) -> EventDrumBuilder ``` ## pyrung.search ``` search( comparison: RangeComparison, *, result: Tag, found: Tag, continuous: bool = False, oneshot: bool = False, ) -> Tag ``` Search instruction. Scans a selected range and writes the first matching address into `result`. Writes `found` True on hit; on miss writes `result=-1` and `found=False`. The first argument is a comparison expression built from a `.select()` range:: ``` search(DS.select(1, 100) >= 100, result=FoundAddr, found=FoundFlag) search(Txt.select(1, 50) == "A", result=FoundAddr, found=FoundFlag) ``` ## pyrung.shift ``` shift( bit_range: BlockRange | IndirectBlockRange, ) -> ShiftBuilder ``` Shift register instruction builder. Data input comes from current rung power. Use .clock(...) then .reset(...) to finalize and add the instruction. Example with Rung(DataBit): shift(C.select(2, 7)).clock(ClockBit).reset(ResetBit) ## pyrung.on_delay ``` on_delay( timer: DoneAccUDT, preset: Tag | int, unit: TimeUnitStr = "ms", ) -> OnDelayBuilder ``` On-Delay Timer instruction (TON/RTON). Accumulates time while rung is true. Example with Rung(MotorRunning): on_delay(Timer[1], preset=5000) # TON keyword on_delay(Timer[1], 5000) # TON positional on_delay(Timer[1], 5, "s") # TON 5 seconds on_delay(Timer[1], preset=5000).reset(ResetBtn) # RTON Without .reset(), this is TON and remains composable in-rung. With .reset(), this is RTON and becomes terminal in the current flow. Parameters: | Name | Type | Description | Default | | -------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | `timer` | `DoneAccUDT` | Timer instance (must have done and acc fields). | *required* | | `preset` | \`Tag | int\` | Target value in time units (Tag or int). | | `unit` | `TimeUnitStr` | Time unit for accumulator (default: "ms"). Accepts "ms", "sec", "min", "hour", "day" and their variants (see :func:normalize_unit). | `'ms'` | Returns: | Type | Description | | ---------------- | --------------------------------------- | | `OnDelayBuilder` | Builder for optional .reset() chaining. | ## pyrung.off_delay ``` off_delay( timer: DoneAccUDT, preset: Tag | int, unit: TimeUnitStr = "ms", ) -> OffDelayBuilder ``` Off-Delay Timer instruction (TOF). Done bit is True while enabled. After disable, counts until preset, then done bit goes False. Auto-resets when re-enabled. Example with Rung(MotorCommand): off_delay(Timer[2], preset=10000) off_delay(Timer[2], 10, "s") # 10 seconds Off-delay timers are composable in-rung (not terminal). Parameters: | Name | Type | Description | Default | | -------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | | `timer` | `DoneAccUDT` | Timer instance (must have done and acc fields). | *required* | | `preset` | \`Tag | int\` | Delay time in time units (Tag or int). | | `unit` | `TimeUnitStr` | Time unit for accumulator (default: "ms"). Accepts "ms", "sec", "min", "hour", "day" and their variants (see :func:normalize_unit). | `'ms'` | Returns: | Type | Description | | ----------------- | -------------------------------------- | | `OffDelayBuilder` | Builder for the off_delay instruction. | ## pyrung.time_drum ``` time_drum( *, outputs: Sequence[Tag], presets: Sequence[Tag | int], unit: TimeUnitStr = "ms", pattern: Sequence[Sequence[bool | int]], current_step: Tag, accumulator: Tag, completion_flag: Tag, ) -> TimeDrumBuilder ``` ## pyrung.send ``` send( *, target: str | ModbusTcpTarget | ModbusRtuTarget, remote_start: str | ModbusAddress, source: Tag | BlockRange, sending: Tag, success: Tag, error: Tag, exception_response: Tag, count: int | None = None, word_swap: bool = False, ) -> None ``` Modbus send instruction (write local values to a remote device). `target` may be a :class:`ModbusTcpTarget` or :class:`ModbusRtuTarget` (live simulation) or a plain string name (codegen placeholder). `remote_start` may be a Click address string (e.g. `"DS1"`) for Click PLCs, or a :class:`ModbusAddress` for raw Modbus devices. ## pyrung.receive ``` receive( *, target: str | ModbusTcpTarget | ModbusRtuTarget, remote_start: str | ModbusAddress, dest: Tag | BlockRange, receiving: Tag, success: Tag, error: Tag, exception_response: Tag, count: int | None = None, word_swap: bool = False, ) -> None ``` Modbus receive instruction (read remote device values into local tags). `target` may be a :class:`ModbusTcpTarget` or :class:`ModbusRtuTarget` (live simulation) or a plain string name (codegen placeholder). `remote_start` may be a Click address string (e.g. `"DS1"`) for Click PLCs, or a :class:`ModbusAddress` for raw Modbus devices. ## pyrung.ModbusAddress Modbus register address. `address` accepts: - **MODBUS 984** `int` — e.g. `400001` (prefix encodes register type) - **MODBUS Hex** `str` with `h` suffix — e.g. `"0h"`, `"FFFEh"` - **Raw** `int` 0–0xFFFE — low-level register offset ## pyrung.ModbusRtuTarget Connection details for a remote Modbus RTU (serial) device. ## pyrung.ModbusTcpTarget Connection details for a remote Modbus TCP device. ## pyrung.RegisterType Bases: `Enum` Modbus register / coil type. ## pyrung.WordOrder Bases: `Enum` Word ordering for 32-bit values across register pairs. ## pyrung.rise ``` rise(tag: Tag | ImmediateRef) -> RisingEdgeCondition ``` Rising edge contact (RE). True only on 0->1 transition. Requires PLC to track previous values. Example with Rung(rise(Button)): latch(MotorRunning) # Latches on button press, not while held ## pyrung.fall ``` fall(tag: Tag | ImmediateRef) -> FallingEdgeCondition ``` Falling edge contact (FE). True only on 1->0 transition. Requires PLC to track previous values. Example with Rung(fall(Button)): reset(MotorRunning) # Resets when button is released ## pyrung.And ``` And( *conditions: Condition | Tag | ImmediateRef | tuple[Condition | Tag | ImmediateRef, ...] | list[Condition | Tag | ImmediateRef], ) -> AllCondition ``` AND condition - true when all sub-conditions are true. This is equivalent to comma-separated rung conditions, but useful when building grouped condition trees with Or(). Example with Rung(And(Ready, AutoMode)): out(StartPermissive) with Rung(Or(And(Ready, AutoMode), RemoteStart)): out(StartPermissive) ## pyrung.Or ``` Or( *conditions: Condition | Tag | ImmediateRef, ) -> AnyCondition ``` OR condition - true when any sub-condition is true. Use this to combine multiple conditions with OR logic within a rung. Multiple conditions passed directly to Rung() are ANDed together. Example with Rung(Step == 1, Or(Start, CmdStart)): out(Light) # True if Step==1 AND (Start OR CmdStart) ### Grouped AND inside OR (explicit): with Rung(Or(Start, And(AutoMode, Ready), RemoteStart)): out(Light) Parameters: | Name | Type | Description | Default | | ------------ | ----------- | ----------- | -------------- | | `conditions` | \`Condition | Tag | ImmediateRef\` | Returns: | Type | Description | | -------------- | -------------------------------------------------------------- | | `AnyCondition` | AnyCondition that evaluates True if any sub-condition is True. | ## pyrung.immediate ``` immediate( value: Tag | BlockRange | ImmediateRef, ) -> ImmediateRef ``` Wrap a tag or block range as an immediate operand. ## pyrung.to_value ``` to_value: CopyConverter = CopyConverter(mode='value') ``` Text → Numeric conversion using the character's face value. Corresponds to Click PLC "Copy Character Value" (Option 4b). Example:: ``` # CHAR '5' → numeric 5 copy(ModeChar, DS[1], convert=to_value) ``` ## pyrung.to_ascii ``` to_ascii: CopyConverter = CopyConverter(mode='ascii') ``` Text → Numeric conversion using the ASCII code. Corresponds to Click PLC "Copy ASCII Code Value" (Option 4b). Example:: ``` # CHAR '5' → ASCII 53 copy(ModeChar, DS[1], convert=to_ascii) ``` ## pyrung.to_text ``` to_text( *, suppress_zero: bool = True, exponential: bool = False, termination_code: int | str | None = None, ) -> CopyConverter ``` Numeric → Text conversion. Corresponds to the Click PLC Copy Option 4a (Numeric→Text) and Option 4c (Float→Text). Parameters: | Name | Type | Description | Default | | ------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `suppress_zero` | `bool` | When True (default), leading zeros are omitted (Click PLC "Suppress zero"). When False, leading zeros fill the full digit width of the source data type. | `True` | | `exponential` | `bool` | When True, use scientific notation (Click PLC "Exponential Numbering", Option 4c). Only applicable to Float sources. | `False` | | `termination_code` | \`int | str | None\` | Examples:: ``` copy(DS[1], Txt[1], convert=to_text()) copy(DS[1], Txt[1], convert=to_text(suppress_zero=False)) copy(DF[1], Txt[1], convert=to_text(exponential=True)) copy(DS[1], Txt[1], convert=to_text(termination_code=0)) copy(DS[1], Txt[1], convert=to_text(termination_code="$0D")) ``` ## pyrung.to_binary ``` to_binary: CopyConverter = CopyConverter(mode='binary') ``` Numeric → Text conversion as raw binary. Corresponds to Click PLC "Copy Binary" (Option 4a). The numeric value is stored directly as an ASCII character. Example:: ``` # DS1=123 → '{' (ASCII 123) copy(DS[1], Txt[1], convert=to_binary) ``` # Click Dialect API **Tier:** Dialect Surface Click prebuilt blocks, aliases, and validation/communication helpers. ## Mapping and Runtime Helpers #### TagMap Parameters: | Name | Type | Description | Default | | ---------------- | ----------- | ----------------------------------------------------------------------------- | ------------ | | `mappings` | \`dict\[Tag | Block, Tag | BlockRange\] | | `include_system` | `bool` | Whether to include built-in system tag mappings (SC/SD points). Default True. | `True` | ##### from_nickname_file ``` from_nickname_file( path: str | Path, *, mode: Literal["warn", "strict"] = "warn", ) -> TagMap ``` ##### to_nickname_file ``` to_nickname_file(path: str | Path) -> int ``` ##### resolve ``` resolve( source: Tag | Block | str, index: int | None = None ) -> str ``` ##### validate ``` validate( program: Program, mode: ValidationMode = "warn", profile: HardwareProfile | None = None, ) -> ClickValidationReport ``` Parameters: | Name | Type | Description | Default | | --------- | ----------------- | ------------------------------------------------------------ | ---------------------------------------------- | | `program` | `Program` | The Program to validate. | *required* | | `mode` | `ValidationMode` | "warn" (findings as hints) or "strict" (findings as errors). | `'warn'` | | `profile` | \`HardwareProfile | None\` | Optional hardware capability profile override. | Returns: | Type | Description | | ----------------------- | ------------------------------------------------ | | `ClickValidationReport` | ClickValidationReport with categorized findings. | ##### mapped_slots ``` mapped_slots() -> tuple[MappedSlot, ...] ``` ##### tags_from_plc_data ``` tags_from_plc_data( data: Mapping[str, bool | int | float | str], ) -> dict[str, bool | int | float | str] ``` Parameters: | Name | Type | Description | Default | | ------ | -------------------- | ----------- | ------- | | `data` | \`Mapping\[str, bool | int | float | Returns: | Type | Description | | ----------------- | ----------- | | \`dict\[str, bool | int | | \`dict\[str, bool | int | #### LadderBundle #### LadderExportError Bases: `RuntimeError` #### ClickDataProvider Parameters: | Name | Type | Description | Default | | ---------- | -------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- | | `runner` | `PLC` | The active PLC whose state is served. | *required* | | `tag_map` | `TagMap` | Mapping from logical tag names to Click hardware addresses. | *required* | | `fallback` | \`DataProvider | None\` | Optional provider for unmapped addresses. Defaults to an in-memory provider. | #### ModbusAddress #### ModbusReceiveInstruction Bases: `Instruction` #### ModbusRtuTarget #### ModbusSendInstruction Bases: `Instruction` #### ModbusTcpTarget #### RegisterType Bases: `Enum` #### WordOrder Bases: `Enum` #### NopInstruction Bases: `Instruction` ##### execute ``` execute(ctx: ScanContext, enabled: bool) -> None ``` ##### is_terminal ``` is_terminal() -> bool ``` #### RawInstruction Bases: `Instruction` ##### execute ``` execute(ctx: ScanContext, enabled: bool) -> None ``` #### ladder_to_pyrung ``` ladder_to_pyrung( source: str | Path | LadderBundle, *, nickname_csv: str | Path | None = None, nicknames: dict[str, str] | None = None, output_path: str | Path | None = None, ) -> str ``` Parameters: | Name | Type | Description | Default | | -------------- | ---------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------- | | `source` | \`str | Path | LadderBundle\` | | `nickname_csv` | \`str | Path | None\` | | `nicknames` | \`dict[str, str] | None\` | Optional pre-parsed {operand: nickname} dict. Alternative to nickname_csv; useful when the caller already has the map. | | `output_path` | \`str | Path | None\` | Returns: | Type | Description | | ----- | --------------------------------------------- | | `str` | The generated Python source code as a string. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If both nickname_csv and nicknames are provided, if required subroutine CSV files are missing, or if the CSV format is invalid. | | `TypeError` | If source is not a supported type. | #### ladder_to_pyrung_project ``` ladder_to_pyrung_project( source: str | Path | LadderBundle, *, nickname_csv: str | Path | None = None, nicknames: dict[str, str] | None = None, output_dir: str | Path | None = None, ) -> dict[str, str] ``` Parameters: | Name | Type | Description | Default | | -------------- | ---------------- | ----------- | --------------------------------------------- | | `source` | \`str | Path | LadderBundle\` | | `nickname_csv` | \`str | Path | None\` | | `nicknames` | \`dict[str, str] | None\` | Optional pre-parsed {operand: nickname} dict. | | `output_dir` | \`str | Path | None\` | Returns: | Type | Description | | ---------------- | ---------------------------------------------------------------------- | | `dict[str, str]` | A dict mapping relative file paths to their content, e.g. | | `dict[str, str]` | {"main.py": "...", "tags.py": "...", "subroutines/startup.py": "..."}. | #### pyrung_to_ladder ``` pyrung_to_ladder( program: Program, tag_map: TagMap ) -> LadderBundle ``` Parameters: | Name | Type | Description | Default | | --------- | --------- | -------------------------------------------------------- | ---------- | | `program` | `Program` | The Program to export. | *required* | | `tag_map` | `TagMap` | TagMap mapping logical tags to Click hardware addresses. | *required* | Returns: | Type | Description | | -------------- | ----------------------------------------------------------- | | `LadderBundle` | A LadderBundle containing main and subroutine row matrices. | #### send ``` send( *, target: str | ModbusTcpTarget | ModbusRtuTarget, remote_start: str | ModbusAddress, source: Tag | BlockRange, sending: Tag, success: Tag, error: Tag, exception_response: Tag, count: int | None = None, word_swap: bool = False, ) -> None ``` #### receive ``` receive( *, target: str | ModbusTcpTarget | ModbusRtuTarget, remote_start: str | ModbusAddress, dest: Tag | BlockRange, receiving: Tag, success: Tag, error: Tag, exception_response: Tag, count: int | None = None, word_swap: bool = False, ) -> None ``` ## Prebuilt Blocks and Aliases ##### Bit ``` Bit = Bool ``` ##### Int2 ``` Int2 = Dint ``` ##### Float ``` Float = Real ``` ##### Hex ``` Hex = Word ``` ##### Txt ``` Txt = Char ``` ##### x ``` x: InputBlock = cast( InputBlock, _block_from_bank_config(BANKS["X"]) ) ``` ##### y ``` y: OutputBlock = cast( OutputBlock, _block_from_bank_config(BANKS["Y"]) ) ``` ##### c ``` c: Block = _block_from_bank_config(BANKS['C']) ``` ##### t ``` t: Block = _block_from_bank_config(BANKS['T']) ``` ##### ct ``` ct: Block = _block_from_bank_config(BANKS['CT']) ``` ##### sc ``` sc: Block = _block_from_bank_config(BANKS['SC']) ``` ##### ds ``` ds: Block = _block_from_bank_config(BANKS['DS']) ``` ##### dd ``` dd: Block = _block_from_bank_config(BANKS['DD']) ``` ##### dh ``` dh: Block = _block_from_bank_config(BANKS['DH']) ``` ##### df ``` df: Block = _block_from_bank_config(BANKS['DF']) ``` ##### xd ``` xd: InputBlock = cast( InputBlock, _block_from_bank_config(BANKS["XD"]) ) ``` ##### yd ``` yd: OutputBlock = cast( OutputBlock, _block_from_bank_config(BANKS["YD"]) ) ``` ##### xd0u ``` xd0u = InputTag('XD0u', WORD, retentive=False) ``` ##### yd0u ``` yd0u = OutputTag('YD0u', WORD, retentive=False) ``` ##### td ``` td: Block = _block_from_bank_config(BANKS['TD']) ``` ##### ctd ``` ctd: Block = _block_from_bank_config(BANKS['CTD']) ``` ##### sd ``` sd: Block = _block_from_bank_config(BANKS['SD']) ``` ##### txt ``` txt: Block = _block_from_bank_config(BANKS['TXT']) ``` # CircuitPython Dialect API **Tier:** Dialect Surface P1AM hardware model, module catalog, validation, and code generation. ## pyrung.circuitpy.CircuitPyFinding ## pyrung.circuitpy.CircuitPyValidationReport ## pyrung.circuitpy.ChannelGroup One homogeneous group of channels within a module. Simple modules have a single group. Combo modules (e.g. P1-16CDR) have two groups — one input, one output. Attributes: | Name | Type | Description | | ----------- | ----------------- | -------------------------------------------- | | `direction` | `ModuleDirection` | INPUT or OUTPUT (never COMBO). | | `count` | `int` | Number of channels in this group (positive). | | `tag_type` | `TagType` | IEC data type for the channels. | ## pyrung.circuitpy.MAX_SLOTS ``` MAX_SLOTS: Final[int] = 15 ``` Maximum number of I/O module slots on the P1AM-200 base unit. ## pyrung.circuitpy.MODULE_CATALOG ``` MODULE_CATALOG: Final[dict[str, ModuleSpec]] = { "P1-08ND-TTL": ModuleSpec( "P1-08ND-TTL", "8-ch discrete input (TTL)", _di(8) ), "P1-08ND3": ModuleSpec( "P1-08ND3", "8-ch discrete input (24V sink)", _di(8) ), "P1-08NA": ModuleSpec( "P1-08NA", "8-ch discrete input (120V AC)", _di(8) ), "P1-08SIM": ModuleSpec( "P1-08SIM", "8-ch discrete input simulator", _di(8) ), "P1-08NE3": ModuleSpec( "P1-08NE3", "8-ch discrete input (24V source)", _di(8), ), "P1-16ND3": ModuleSpec( "P1-16ND3", "16-ch discrete input (24V sink)", _di(16), ), "P1-16NE3": ModuleSpec( "P1-16NE3", "16-ch discrete input (24V source)", _di(16), ), "P1-04TRS": ModuleSpec( "P1-04TRS", "4-ch relay output", _do(4) ), "P1-08TA": ModuleSpec( "P1-08TA", "8-ch AC output", _do(8) ), "P1-08TRS": ModuleSpec( "P1-08TRS", "8-ch relay output", _do(8) ), "P1-16TR": ModuleSpec( "P1-16TR", "16-ch relay output", _do(16) ), "P1-08TD-TTL": ModuleSpec( "P1-08TD-TTL", "8-ch discrete output (TTL)", _do(8) ), "P1-08TD1": ModuleSpec( "P1-08TD1", "8-ch discrete output (24V sink)", _do(8), ), "P1-08TD2": ModuleSpec( "P1-08TD2", "8-ch discrete output (24V source)", _do(8), ), "P1-15TD1": ModuleSpec( "P1-15TD1", "15-ch discrete output (24V sink)", _do(15), ), "P1-15TD2": ModuleSpec( "P1-15TD2", "15-ch discrete output (24V source)", _do(15), ), "P1-16CDR": ModuleSpec( "P1-16CDR", "8-ch DI + 8-ch relay DO", _combo_discrete(8, 8), ), "P1-15CDD1": ModuleSpec( "P1-15CDD1", "8-ch DI + 7-ch DO (24V sink)", _combo_discrete(8, 7), ), "P1-15CDD2": ModuleSpec( "P1-15CDD2", "8-ch DI + 7-ch DO (24V source)", _combo_discrete(8, 7), ), "P1-04AD": ModuleSpec( "P1-04AD", "4-ch analog input (voltage/current)", _ai(4), ), "P1-04AD-1": ModuleSpec( "P1-04AD-1", "4-ch analog input (voltage)", _ai(4) ), "P1-04AD-2": ModuleSpec( "P1-04AD-2", "4-ch analog input (current)", _ai(4) ), "P1-04RTD": ModuleSpec( "P1-04RTD", "4-ch RTD temperature input", _temp_ai(4), ), "P1-04THM": ModuleSpec( "P1-04THM", "4-ch thermocouple input", _temp_ai(4) ), "P1-04NTC": ModuleSpec( "P1-04NTC", "4-ch NTC temperature input", _temp_ai(4), ), "P1-04ADL-1": ModuleSpec( "P1-04ADL-1", "4-ch analog input (voltage, low-cost)", _ai(4), ), "P1-04ADL-2": ModuleSpec( "P1-04ADL-2", "4-ch analog input (current, low-cost)", _ai(4), ), "P1-08ADL-1": ModuleSpec( "P1-08ADL-1", "8-ch analog input (voltage, low-cost)", _ai(8), ), "P1-08ADL-2": ModuleSpec( "P1-08ADL-2", "8-ch analog input (current, low-cost)", _ai(8), ), "P1-04DAL-1": ModuleSpec( "P1-04DAL-1", "4-ch analog output (voltage, low-cost)", _ao(4), ), "P1-04DAL-2": ModuleSpec( "P1-04DAL-2", "4-ch analog output (current, low-cost)", _ao(4), ), "P1-08DAL-1": ModuleSpec( "P1-08DAL-1", "8-ch analog output (voltage, low-cost)", _ao(8), ), "P1-08DAL-2": ModuleSpec( "P1-08DAL-2", "8-ch analog output (current, low-cost)", _ao(8), ), "P1-4ADL2DAL-1": ModuleSpec( "P1-4ADL2DAL-1", "4-ch AI (voltage) + 2-ch AO (voltage)", _combo_analog(4, 2), ), "P1-4ADL2DAL-2": ModuleSpec( "P1-4ADL2DAL-2", "4-ch AI (current) + 2-ch AO (current)", _combo_analog(4, 2), ), } ``` ## pyrung.circuitpy.ModbusClientConfig ## pyrung.circuitpy.ModbusServerConfig ## pyrung.circuitpy.ModbusTcpTarget Connection details for a remote Modbus TCP device. ## pyrung.circuitpy.ModuleDirection Bases: `Enum` I/O direction of a P1AM module. ## pyrung.circuitpy.ModuleSpec Static specification for a single P1AM I/O module. Attributes: | Name | Type | Description | | ------------- | -------------------------- | ------------------------------------------------------------------------ | | `part_number` | `str` | Manufacturer part number (e.g. "P1-08SIM"). | | `description` | `str` | Human-readable summary. | | `groups` | `tuple[ChannelGroup, ...]` | One or two :class:ChannelGroup entries describing the module's channels. | ### direction ``` direction: ModuleDirection ``` Overall direction: INPUT, OUTPUT, or COMBO. ### is_combo ``` is_combo: bool ``` True if the module has both input and output channels. ### input_group ``` input_group: ChannelGroup | None ``` The input channel group, or `None` if the module has no inputs. ### output_group ``` output_group: ChannelGroup | None ``` The output channel group, or `None` if the module has no outputs. ## pyrung.circuitpy.P1AM P1AM-200 hardware configuration. Represents a P1AM-200 base unit with up to 15 I/O module slots. Each slot is configured with a module part number via :meth:`slot`, which constructs and returns the appropriate :class:`~pyrung.core.memory_block.InputBlock` / :class:`~pyrung.core.memory_block.OutputBlock`. This class holds no runtime state — it produces Block instances whose tags reference values in `SystemState.tags` via the core engine. Example:: ``` hw = P1AM() inputs = hw.slot(1, "P1-08SIM") # InputBlock outputs = hw.slot(2, "P1-08TRS") # OutputBlock inp, out = hw.slot(3, "P1-16CDR") # combo → tuple Button = inputs[1] # LiveInputTag("Slot1.1", BOOL) Light = outputs[1] # LiveOutputTag("Slot2.1", BOOL) ``` ### configured_slots ``` configured_slots: dict[int, ModuleSpec] ``` Mapping of slot number → :class:`ModuleSpec` for all configured slots. ### slot ``` slot( number: int, module: InputModuleName, *, name: str | None = None, ) -> InputBlock ``` ``` slot( number: int, module: OutputModuleName, *, name: str | None = None, ) -> OutputBlock ``` ``` slot( number: int, module: ComboModuleName, *, name: str | None = None, ) -> tuple[InputBlock, OutputBlock] ``` ``` slot( number: int, module: str, *, name: str | None = None ) -> SlotValue ``` ``` slot( number: int, module: str, *, name: str | None = None ) -> SlotValue ``` Configure a module in the given slot and return its block(s). Parameters: | Name | Type | Description | Default | | -------- | ----- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `number` | `int` | Slot number (1–15 inclusive). | *required* | | `module` | `str` | Module part number (e.g. "P1-08SIM"). Must exist in :data:~pyrung.circuitpy.catalog.MODULE_CATALOG. | *required* | | `name` | \`str | None\` | Optional custom name prefix for tags in this slot. Defaults to "Slot{number}". | Returns: | Type | Description | | ----------- | --------------------------------------------------------------------- | | `SlotValue` | :class:~pyrung.core.memory_block.InputBlock for input-only modules. | | `SlotValue` | :class:~pyrung.core.memory_block.OutputBlock for output-only modules. | | `SlotValue` | tuple[InputBlock, OutputBlock] for combo modules. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------- | | `ValueError` | If number is out of range, module is not in the catalog, or the slot is already configured. | ### get_slot ``` get_slot(number: int) -> SlotValue ``` Retrieve the block(s) for an already-configured slot. Raises: | Type | Description | | ------------ | ------------------------------------ | | `ValueError` | If the slot has not been configured. | ### validate ``` validate( program: Program, mode: ValidationMode = "warn" ) -> CircuitPyValidationReport ``` Validate a Program against CircuitPy portability rules. Parameters: | Name | Type | Description | Default | | --------- | ---------------- | ------------------------------------------------------------ | ---------- | | `program` | `Program` | The Program to validate. | *required* | | `mode` | `ValidationMode` | "warn" (findings as hints) or "strict" (findings as errors). | `'warn'` | Returns: | Type | Description | | --------------------------- | ---------------------------------------------------- | | `CircuitPyValidationReport` | CircuitPyValidationReport with categorized findings. | ## pyrung.circuitpy.RunStopConfig Optional RUN/STOP hardware-mode mapping for generated runtime. ## pyrung.circuitpy.ValidationMode ``` ValidationMode = Literal['warn', 'strict'] ``` ## pyrung.circuitpy.board P1AM-200 onboard peripheral tag model. ### P1AMNeoPixelNamespace Onboard single NeoPixel RGB channels. ### P1AMBoardNamespace Onboard P1AM peripheral tags. ### RunStopConfig Optional RUN/STOP hardware-mode mapping for generated runtime. ### is_board_tag ``` is_board_tag(tag: Tag) -> bool ``` Return True when the tag belongs to the onboard P1AM model. ## pyrung.circuitpy.CircuitPyOutput Result of :func:`generate_circuitpy`. *code* is the `code.py` content (program-specific, stays as `.py`). *runtime* is the `pyrung_rt.py` content (generic runtime library, intended to be compiled to `.mpy` via `mpy-cross`). An empty string means no runtime module is needed. ## pyrung.circuitpy.generate_circuitpy ``` generate_circuitpy( program: Program, hw: P1AM, *, target_scan_ms: float, watchdog_ms: int | None = None, runstop: RunStopConfig | None = _DEFAULT_RUNSTOP, modbus_server: ModbusServerConfig | None = None, modbus_client: ModbusClientConfig | None = None, tag_map: TagMap | None = None, mapped_tag_scope: MappedTagScope = "referenced_only", force_runtime: bool = False, ) -> CircuitPyOutput ``` ## pyrung.circuitpy.write_circuitpy ``` write_circuitpy( program: Program, hw: P1AM, *, output_dir: str | Path, target_scan_ms: float, watchdog_ms: int | None = None, runstop: RunStopConfig | None = _DEFAULT_RUNSTOP, modbus_server: ModbusServerConfig | None = None, modbus_client: ModbusClientConfig | None = None, tag_map: TagMap | None = None, mapped_tag_scope: MappedTagScope = "referenced_only", force_runtime: bool = False, ) -> Path ``` Generate and write `code.py` (and `pyrung_rt.py` when needed) to *output_dir*. Accepts the same parameters as :func:`generate_circuitpy` plus `output_dir`. Returns the path to the written `code.py`.