wrps-demo-kit/03-plc/README.md
Clio Liu dca7797d68 feat(plc): ST sources, generators and the Modbus contract
The PLC program from the old repo's 04-plc/, flattened into one folder and
checked against the running system.

Verified during the move:
  - build.py regenerates register-map.csv byte-identically (69 points)
  - polled the live PLC: the SIMULATION build is what is deployed and
    running, %MW21=2 wet weather, values moving, run hours accumulating
  - addresses, %MW HR1024 segmentation and %QW17/%QW7 signedness all
    match the map

Corrections against the old repo:
  - 10_globals.st header cited WRPS-CTL-002 (the FDS); it means CTL-003
  - build.py wrote the map to its parent directory; now beside itself
  - deploy/README.md was a single-file folder; now DEPLOY.md
  - dropped the empty editor-devices/remote/
  - README no longer claims the simulation build is uncompiled - it is
    the one running

Two open items are now stated plainly rather than buried:
  - none of the 20 acceptance tests in CTL-003 have ever been run
  - the OpenPLC Editor lived only on the retired dev-ubuntu host, so
    there is currently NO route to deploy a new program (DEPLOY.md 0)

Documents the setpoint distinction: IO_MUX seeds %MW defaults once at
first scan, operators retune them live, and that tuning exists only in
the container volume - a restart reverts it.
2026-09-02 15:48:49 +10:00

239 lines
11 KiB
Markdown
Raw 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.

# 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 01023 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 1120, default 1 |
Scenarios (§8.2): **1** is a 40110 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, 3060× 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
**`%MW2023` 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.