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
This commit is contained in:
Clio Liu 2026-09-02 16:51:45 +10:00
parent 55279ca78f
commit 0bbff348f8
2 changed files with 187 additions and 2 deletions

183
03-plc/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,183 @@
# 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.

View file

@ -9,6 +9,7 @@ how it reaches the runtime.
``` ```
GETTING-STARTED.md start here - modify the program / build a new PLC / move it GETTING-STARTED.md start here - modify the program / build a new PLC / move it
ARCHITECTURE.md what the program is made of: POUs, tasks, layers, points
VERSIONS.md every version, read from the running system VERSIONS.md every version, read from the running system
src/*.st the program - concatenated in lexical order src/*.st the program - concatenated in lexical order
build.py builds a flat .st AND generates register-map.csv build.py builds a flat .st AND generates register-map.csv
@ -19,8 +20,9 @@ as-built/ what is ACTUALLY running, copied out of the container
DEPLOY.md how a program reaches the runtime, and every gotcha hit DEPLOY.md how a program reaches the runtime, and every gotcha hit
``` ```
**New here?** Read `GETTING-STARTED.md` first — it answers "how do I change the **New here?** `GETTING-STARTED.md` answers "how do I change the program", "how do
program", "how do I stand up a new PLC" and "how do I move this one" in a page. I stand up a new PLC" and "how do I move this one" in a page.
`ARCHITECTURE.md` shows how the program is put together before you read any ST.
## Build ## Build