# Architecture of the PLC program What the program is made of, what each part does, and how data moves through it. Behaviour and the reasoning behind design choices are in `README.md`; addresses are in `register-map.csv`. ## At a glance | | | |---|---| | Language | IEC 61131-3 Structured Text | | Source files | 10 (`src/*.st`), concatenated in lexical order | | Function blocks | 4 | | Programs | 3 | | Tasks | **1** — `plc_task`, 100 ms cyclic | | Program instances | 3, all on that one task | | Located variables | 69 | | Process-image globals | 65 | | Modbus points | 69 — 10 input reg · 10 discrete in · 15 coil · 34 holding reg | | Writable from SCADA | 15 holding registers (11 control + 4 simulation) | | Comms interfaces | **1** — Modbus TCP server (slave), port 502, unit 1 | ## The one architectural rule **Control logic never touches hardware.** Exactly two files may reference a located variable (`%IW`, `%IX`, `%QW`, `%QX`, `%MW`): - `10_globals.st` — declares them - `50_prog_io_mux.st` — reads and writes them Everything else reads and writes **process-image globals** (`g_*`) only. `build.py` enforces this and **refuses to build** if any other file references a located variable in code. That rule is what lets the identical control logic run against a simulated plant or a real one, with no change to a single control POU. ## Layers ``` ┌──────────────────────────────────────────────────────────────┐ │ MODBUS TCP SLAVE :502 (runtime plugin, not program code) │ └──────────────────────────────────────────────────────────────┘ ▲ %QW %QX published ▼ %MW commands ▼ %IW %IX field ┌──────────────────────────────────────────────────────────────┐ │ IO_MUX the ONLY POU that touches located variables │ │ field build : %IW/%IX → g_* │ │ sim build : SIMULATION outputs → g_* │ │ both : g_o_* → %QW/%QX │ │ first scan : seeds %MW setpoint defaults │ └──────────────────────────────────────────────────────────────┘ ▲ g_o_* ▼ g_* ┌──────────────────────────────────────────────────────────────┐ │ CONTROL the control logic. Reads globals only. │ │ ├── FB_PUMP × 3 one per unit │ │ ├── FB_DUTY_SELECT × 1 which units run │ │ ├── FB_LEVEL_CTRL × 1 what speed │ │ └── FB_HEADROOM × 1 time and volume to breach │ └──────────────────────────────────────────────────────────────┘ ▲ run cmds, speed ┌──────────────────────────────────────────────────────────────┐ │ SIMULATION simulation build only. Models the wet well, │ │ the pumps and the instruments. │ └──────────────────────────────────────────────────────────────┘ ``` ## The sections ### Function blocks | File | POU | Lines | Owns | |---|---|---|---| | `20_fb_pump.st` | `FB_PUMP` | 141 | **One pump, completely.** Min-run and min-off timers, no-flow trip, thermal and vibration trips, availability, run-hour accumulation, service-due, pump state enum. Three instances. Trips latch and clear only on command. | | `21_fb_duty_selector.st` | `FB_DUTY_SELECT` | 128 | **Which** units run — never how many. Ranks available units by service-due then run hours, ties by pump number. Two rules dominate the ranking: never stop a running unit to start a better-ranked one, and service-due is a preference, never a veto. | | `22_fb_level_control.st` | `FB_LEVEL_CTRL` | 74 | Common drive speed. PI, no derivative, anti-windup, clamped 38–50 Hz. Below 38 Hz the 22 m static lift means no delivery, so the clamp is physical, not a preference. | | `23_fb_headroom.st` | `FB_HEADROOM` | 97 | Pure calculation, no control action. Net inflow, volume and time to the spill weir and to LSHH. Inflow is filtered through a 30 s lag first — this is the number the demo narrative rests on. | ### Programs | File | POU | Lines | Does | |---|---|---|---| | `30_prog_control.st` | `CONTROL` | 595 | The control sequence. Clamps setpoints, processes the command word and acknowledge, derives how many pumps are required from level with hysteresis, applies the LSHH / LSLL / station-off overrides, staggers starts, calls the four function blocks in order, and publishes every output global. **The largest file, and where the plant's behaviour lives.** | | `50_prog_io_mux.st` | `IO_MUX` | 292 | The hardware boundary. Copies located inputs (or the simulation's outputs) into the process image, copies published globals out to `%QW`/`%QX`, and seeds the `%MW` setpoint defaults once on first scan. | | `40_prog_simulation.st` | `SIMULATION` | 308 | **Simulation build only.** Integrates the wet-well volume, models per-pump flow against speed with parallel derating, generates discharge pressure, drives four inflow scenarios, applies time scaling and adds measurement noise. | ### Declarations and configuration | File | Lines | Contents | |---|---|---| | `10_globals.st` | 252 | The only place located variables are declared: 69 of them, plus 65 process-image globals and the constants (plant geometry, speed limits, setpoint defaults). | | `90_config_field.st` | 23 | `CONFIGURATION` for the **field** build — `plc_task` at 100 ms, instances `inst_mux` + `inst_ctl`. | | `91_config_sim.st` | 45 | `CONFIGURATION` for the **simulation** build — the same, plus `inst_sim`. | ## Execution order — it is load-bearing One task, `plc_task`, 100 ms cyclic. Declaration order in the CONFIGURATION sets execution order within it. **Field build** (`90_config_field.st`): `IO_MUX` → `CONTROL` **Simulation build** (`91_config_sim.st`): `SIMULATION` → `IO_MUX` → `CONTROL` > `SIMULATION` runs **first**, though CTL-003 §8.2 says it should run after > `CONTROL`. With it last, the first scan after every start has no simulated image, > so `IO_MUX` falls back to the all-zero field inputs — which means `ThermalOK` > FALSE and `LSLL` dry — and all three pumps latch a trip while the station enters > dry-run lockout. Every demo would begin by clearing trips that never happened. > The cost of running it first is one scan of staleness, invisible beside a 3 s > pump start delay. **The 100 ms interval is not adjustable on its own.** `FB_PUMP`, `FB_LEVEL_CTRL`, `FB_HEADROOM` and `SIMULATION` all integrate against a hardcoded `SCAN_S := 0.1`. Change the task interval without changing those and you silently alter controller tuning and run-hour accumulation. ## The two builds Selected by `build.py --mode`; the difference is **two files**, and no control POU changes between them. | | Field | Simulation | |---|---|---| | `40_prog_simulation.st` | excluded | included | | Configuration | `90_config_field.st` | `91_config_sim.st` | | `IO_MUX` input source | `%IW` / `%IX` | `SIMULATION` outputs | | `%MW20–23` | declared, unread | scenario control | | Currently deployed | | **✅ this one** | `%MW20–23` are declared in both builds even though CTL-003 §2 says "simulation build only" — located variables may only be declared in `10_globals.st`, and splitting that file would weaken the build's located-variable check. In the field build they are simply never read. ## Comms interfaces **One.** The runtime's `modbus_slave` plugin, Modbus TCP, port 502, unit id 1. It is **not program code** — it is a runtime plugin, enabled by `conf/modbus_slave.json`, which the Editor only ships if the project defines a Modbus **Server** device. This is why port 502 can be published by Docker and still refuse connections. See `DEPLOY.md` §A3. | Not used | Why | |---|---| | `modbus_master` | Disabled. The PLC must never poll anyone — that would invert the architecture. | | `opcua`, `s7comm` | Disabled. | | `ethercat` | ⚠️ **Enabled**, from an empty `ethercat.json` the Editor emits by default. Nothing uses it. | The REST API on 8443 is the runtime's control channel (upload, start/stop), not a process interface. CI Server never touches it. ## The 69 Modbus points | Object | FC | Count | Direction | What | |---|---|---|---|---| | Input register `%IW` | 04 | 10 | PLC reads | Field analogues: level, flows, pressures, vibration | | Discrete input `%IX` | 02 | 10 | PLC reads | Field digitals: level switches, thermals, seal leaks, mains | | Coil `%QX` | 01 | 15 | PLC writes | Run commands, running, available, tripped, in-auto, alarms | | Holding register `%QW` | 03 | 19 | PLC writes | Every published measurement, station and pump state, run hours, headroom | | Holding register `%MW` | 03/06 | 15 | **SCADA writes** | 11 setpoints and commands + 4 simulation controls | > **`%QW` occupies holding registers 0–1023; `%MW` starts at HR 1024.** So `%MW3`, > the level setpoint, is holding register **1027**. This is a v4 change and it > produces plausible-but-wrong values rather than an error. Use the resolved > addresses in `register-map.csv`. **In the simulation build, do not poll `%IW` / `%IX`.** Nothing writes them; they read zero forever. Every live value is published in the `%QW` block. ## Where the numbers come from The whole chain is generated from one table, so the two sides cannot drift: ``` src/*.st ──build.py──► register-map.csv ──gen_scada_points.py──► scada-points.csv ▲ ▲ │ │ REGISTERS table gen_ciserver_qli.py │ in build.py ▼ hand-written CI Server .qli ``` `register-map.csv` is **generated** — never hand-edit it. Change the `REGISTERS` table in `build.py` and rebuild, then regenerate the SCADA side.