# 03-plc — the PLC program IEC 61131-3 Structured Text for the Waterloo Road Pump Station, implementing `01-design/WRPS-CTL-003`. Control logic and a wet-well simulation, in separate POUs, from one source tree. **`src/` is canonical.** Everything else here is generated from it, or describes how it reaches the runtime. ``` src/*.st the program - concatenated in lexical order build.py builds a flat .st AND generates register-map.csv gen_project.py generates the OpenPLC Editor v4 project (the deploy artefact) register-map.csv GENERATED - the PLC half of the Modbus contract editor-devices/ the GUI-configured half of the Editor project (reference copy) DEPLOY.md how a program actually reaches the runtime ``` ## Build ```bash python build.py --mode field # control only python build.py --mode sim # control + PROGRAM SIMULATION python gen_project.py --mode sim --out editor-project ``` `build.py` emits `build/wrps.st` (git-ignored) and regenerates `register-map.csv`. **Never hand-edit the register map** — change the `REGISTERS` table in `build.py` and rebuild. The build refuses to emit if any file other than `src/10_globals.st` or `src/50_prog_io_mux.st` references a located variable in code (CTL-003 §8.1, §10). Comments may mention them. `gen_project.py` emits the Editor project, which is what actually gets deployed — see `DEPLOY.md`. ## What is running right now **The simulation build**, verified live on 2026-09-02 by polling the PLC: ``` level 2696 mm · inflow 74.3 L/s · discharge 75.0 L/s · 1 pump running · 40.1 Hz run hours 113/107/100 h · vol to spill 396 m3 · state 2 (Pumping) · alarms 0x0000 %MW21 = 2 (wet weather scenario) · %MW23 = 1 (real time) ``` All `%IW` read zero — the mux is taking simulated values, as designed. Values move between polls; run hours accumulate. ## The Modbus contract **`%MW` is not holding register 0.** The v4 slave segments holding registers: `%QW` occupies HR 0–1023 and `%MW` starts at **HR 1024**. So `%MW3` (level setpoint) is **HR 1027**. `register-map.csv` carries the resolved addresses — build the SCADA-side point list in `04-scada/modbus/` from that file, never from §2 of the brief. **`%QW17` (alarm bitmask) must be read as unsigned 16-bit.** Bit 15 does not fit a signed INT, so values ≥ 32768 fold into the negative half of the word. **`%QW7` (net accumulation) is genuinely signed** — a live read returned `65532`, which is −4. Two conditions must both hold for port 502 to answer, and neither is a container setting: a program must be **running**, and the Editor project must define a Modbus **Server** so the runtime's `modbus_slave` plugin is enabled. See `DEPLOY.md` §A3. ## Setpoints: seeded defaults vs live tuning `IO_MUX` writes the `%MW` setpoint defaults **once, on first scan**, so CI Server sees real values rather than zeros after a restart. It is a one-shot write — **anything CI Server writes afterwards survives** (CTL-003 §2.3). These are operator-adjustable. They are meant to be changed from demo to demo, and they are: the live station currently runs lower start levels than the seeded defaults, so it cycles faster and reads better on screen. | `%MW` | Seeded default (`10_globals.st`) | Live 2026-09-02 | |---|---|---| | 3 Level control setpoint | 4200 mm | 2700 | | 4 Start duty level | 4000 mm | 1800 | | 5 Start pump 2 | 4500 mm | 3600 | | 6 Start pump 3 | 5000 mm | 4200 | | 7 Stop all | 1000 mm | 900 | | 8 High level alarm | 5200 mm | 4560 | | 9 Minimum drive speed | 380 | 380 | | 10 Service interval | 4000 h | 4000 | > [!IMPORTANT] > **Tuning lives only in the container's volume, not in this repo.** Restart the > runtime and every setpoint reverts to the seeded default, and the station will > behave differently on stage. If a particular set of values is the one you want > to demo, write them down — the repo cannot restore them for you. ## Simulation `--mode sim` adds `PROGRAM SIMULATION`, which models the wet well, the pumps and the field instruments. `IO_MUX` then feeds `CONTROL` from the model instead of `%IW`/`%IX`. **No control POU changes between the two builds** — that is the point of §8.1. | Register | HR | Meaning | |---|---|---| | `%MW20` | 1044 | Manual inflow, L/s × 10 (scenario 0 only) | | `%MW21` | 1045 | Scenario: 0 manual · 1 diurnal · 2 wet weather · 3 demo reference | | `%MW22` | 1046 | Write 1 to reset the scenario; self-clearing | | `%MW23` | 1047 | Time scale 1–120, default 1 | Scenarios (§8.2): **1** is a 40–110 L/s sinusoid over a 24 h simulated period; **2** ramps to 300 L/s over 20 simulated minutes, holds 40, decays over 90; **3** is the §7.3 demo reference — level starts at 4.00 m with 165 L/s inflow. > ⚠️ **Reset does not clear run hours or trip states.** Those live in `FB_PUMP`, > not in the simulation, and the simulation is forbidden from reaching into > control state. Use the control commands: command 1 resets trips, command 5 > resets run hours (`%MW1`/`%MW2`). Pass-2 test 5 asks for one reset that does all > of it; here it takes two writes. ### The time-scaling caveat `%MW23` scales **simulation** time only. `CONTROL`'s timers stay in real seconds and must — `FB_PUMP`'s no-flow trip is a `T#20s` TON, and scaling both time bases is what makes these demos impossible to debug. The consequence is real and intended: at 60×, a 5-minute minimum-run timer covers 5 simulated hours. Wrong, but harmless for a demo — and obvious, rather than a subtly mis-tuned controller. For the same reason the per-pump **start delay (3 s) runs on real time**: scaled, at 60× it would elapse in 50 ms and the no-flow trip could never be demonstrated, which is the one thing §8.2 says the delay is for. Use 1× while testing logic, 30–60× when presenting. ## Reset between demos Restart the runtime. Nothing is retained (§9), and `IO_MUX` re-seeds the `%MW` defaults on first scan — see the warning about tuning above. --- ## Decisions and deviations Everything below departs from a literal reading of the brief. Each is a judgement call, not an oversight. **FB_PUMP has three inputs the brief does not list.** `MinOffBypass` (LSHH must override the min-off timer, §4.1), `ServiceInterval` (`ServiceDue` is defined against `%MW10`) and `ResetHours` (command 5). Each specified output is otherwise unreachable. **LSHH bypasses the start stagger as well as the min-off timers.** §4.1 and §5 mention only min-off, but test 10 expects *all available units at 50.0 Hz* on LSHH, and a 30 s-per-unit stagger would take 60 s to get there. Treated as an emergency response. **The stagger is applied before duty selection, not after.** §4.5 lists it as step 11. It limits how many units *may start*, which is an input to selection rather than a correction applied to its result. **No-flow is monitored continuously after the 20 s window**, not sampled once at t = 20 s. Either reading satisfies test 8; this one also catches a loss of flow while running. **The dry-run lockout is reset by command 1** (reset all trips), and only once level has recovered above stop level, per §5. §3.3 defines no dedicated command. ### Simulation deviations **`%MW20–23` are declared in both builds**, not "simulation build only" as §2 says. Located variables may only be declared in `10_globals.st`, and splitting that file would need a second file with located-variable permission, weakening the §10 grep check. In the field build the registers are simply unused. `register-map.csv` marks them `SIM ONLY`. **Scenario 3 holds inflow at exactly 165 L/s** rather than "then ramp slowly" (§8.2). The ramp rate is unspecified, and pass-2 test 2 pins the outcome precisely: spill at ~89 simulated minutes from 4.00 m. That figure only holds at a constant 165 L/s against one pump at 120 L/s — 240 m3 / 45 L/s = 5333 s = 88.9 min, which is also the `%QW5` countdown the test expects. Any ramp makes both numbers wrong. **Plant health is constant** (`ThermalOK` TRUE, `SealLeak` FALSE, `MainsOK` TRUE). Fault injection is not modelled because §8 does not ask for it. Test 4 needs PU-301 *locked out*, which is a control command rather than a simulated fault, so it is reachable. A thermal or seal-leak trip is not demonstrable without adding injection registers. **`SIMULATION` runs FIRST in the sim build, not after `CONTROL` as §8.2 says.** Found by running it: with `SIMULATION` last, the first scan after every start has no simulated image, so `IO_MUX` falls back to the all-zero field inputs — `ThermalOK` FALSE and `LSLL` dry — and `FB_PUMP` **latches a trip on all three units** while the station enters dry-run lockout. Both latch until a reset command, so every demo would begin by clearing trips that never happened. Observed live 2026-08-14: `%QW12 = 5`, `%QW17 = 116`, all three trip coils true, until command 1 was issued by hand. Running it first costs one scan of staleness — `SIMULATION` acts on the run commands and speed `CONTROL` published 100 ms earlier. That is the same lag `IO_MUX` already has on published outputs, and it is invisible beside a 3 s pump start delay. **`FB_HEADROOM`'s inflow filter is primed with its first sample** rather than ramping from zero. Without this, test 14's reference figure takes ~2 minutes to settle and cannot be reproduced on demand, which §7 requires. **Outputs are published one scan late.** §8.1 requires `IO_MUX` to be the only POU touching located variables *and* to run before `CONTROL`, so the `%QW`/`%QX` values it writes are the previous scan's. 100 ms lag on published values only; no control decision is affected. **Three explicit `FB_PUMP` instances**, not `ARRAY[1..3] OF FB_PUMP` — arrays of function blocks are the kind of construct §9 warns about. **`SCAN_S := 0.1` is a constant inside each FB** that uses scan time for integration (run hours, PI integral, inflow filter). It must match the `TASK INTERVAL` in the configuration file. Changing one without the other silently changes controller tuning and run-hour accumulation. --- ## Status — 2026-09-02 | | | |---|---| | Simulation build | **Compiled, deployed and running** on `yau-sls-poc-lin001`, polled live by CI Server on `yau-poc-cicore1` | | Register map | 69 points, **reproduces byte-identically** from `build.py` | | Modbus contract | Verified against the live PLC — addresses, segmentation and signedness all as documented | | Field build | Compiled and verified 2026-08-14 (65/65 points, 11/11 RW). Superseded by the sim build now running | ### ⚠️ Two things that are not done **1. None of the 20 acceptance tests have been run.** CTL-003 §7 lists 14 pass-1 tests and §8.4 lists 6 pass-2 tests. The program compiles, runs, and publishes its registers correctly — which says nothing about whether the **control logic is right**. This is the single largest open item in the project, and CTL-003's own definition of done is unmet because of it. **2. There is currently no way to deploy a new program.** The OpenPLC Editor lived only on the retired `dev-ubuntu` host and does not exist on `yau-sls-poc-lin001`. The running PLC is a **committed image** carrying an already-compiled program. Changing `src/` today produces a project that cannot reach the runtime. See `DEPLOY.md` §0.