diff --git a/03-plc/ARCHITECTURE.md b/03-plc/ARCHITECTURE.md new file mode 100644 index 0000000..24dc925 --- /dev/null +++ b/03-plc/ARCHITECTURE.md @@ -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. diff --git a/03-plc/README.md b/03-plc/README.md index b45f357..5b99109 100644 --- a/03-plc/README.md +++ b/03-plc/README.md @@ -9,6 +9,7 @@ how it reaches the runtime. ``` 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 src/*.st the program - concatenated in lexical order 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 ``` -**New here?** Read `GETTING-STARTED.md` first — it answers "how do I change the -program", "how do I stand up a new PLC" and "how do I move this one" in a page. +**New here?** `GETTING-STARTED.md` answers "how do I change the program", "how do +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