wrps-demo-kit/03-plc/ARCHITECTURE.md
Clio Liu 0bbff348f8 docs(plc): architecture overview
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
2026-09-02 16:51:45 +10:00

11 KiB
Raw Permalink Blame History

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 1plc_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 3850 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_MUXCONTROL

Simulation build (91_config_sim.st): SIMULATIONIO_MUXCONTROL

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
%MW2023 declared, unread scenario control
Currently deployed this one

%MW2023 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 01023; %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.