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

183 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 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_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 |
| `%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.