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