The folder described behaviour and listed points, but nothing showed the
shape of the program. ARCHITECTURE.md covers what a reader needs before
opening any ST file:
- counts up front: 4 function blocks, 3 programs, 1 task, 69 located
variables, 65 process globals, 69 Modbus points, 1 comms interface
- the one architectural rule - only 10_globals.st and 50_prog_io_mux.st
may touch located variables, enforced by build.py - and why it is what
lets identical control logic run against a simulation or a real plant
- a layer diagram: Modbus slave / IO_MUX / CONTROL + its four FBs /
SIMULATION, with the direction of every flow
- what each of the 10 source files owns, with line counts
- execution order per build, and why SIMULATION runs first
- the 100 ms task interval is not independently adjustable: four POUs
integrate against a hardcoded SCAN_S := 0.1
- one comms interface, which is a runtime plugin rather than program
code - the reason 502 can be published and still refuse connections
- the 69 points by function code, with the %MW HR1024 warning
11 KiB
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 them50_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
SIMULATIONruns first, though CTL-003 §8.2 says it should run afterCONTROL. With it last, the first scan after every start has no simulated image, soIO_MUXfalls back to the all-zero field inputs — which meansThermalOKFALSE andLSLLdry — 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 |
%QWoccupies holding registers 0–1023;%MWstarts 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 inregister-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.