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:
parent
55279ca78f
commit
0bbff348f8
2 changed files with 187 additions and 2 deletions
183
03-plc/ARCHITECTURE.md
Normal file
183
03-plc/ARCHITECTURE.md
Normal 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 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.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue