Adds what the folder was missing for someone picking it up cold.
as-built/ the STruC++ output copied out of the live container - the C++
the PLC is actually executing, plus program.st and the Modbus
buffer config. The running image was made with docker commit
and exists in no registry, so this is the only other copy of
the compiled form.
Verified while copying: the deployed program.st matches
build/wrps.st generated from src/. Identical POU structure,
ZERO differences in non-declaration lines. src/ is genuinely
canonical and the running PLC agrees with it.
VERSIONS.md every version read from the running system, not from
documentation: runtime v4.1.10, STruC++ 0.6.2, Editor
4.2.11, Debian 12, g++ 12.2, pymodbus 3.11.2, CI
Server R1.03. Plus which plugins are enabled -
ethercat is on for no reason - and a v3-vs-v4 table,
since most OpenPLC guidance online is for v3 and the
%MW HR1024 change silently produces wrong data.
GETTING-STARTED.md three questions answered in a page: how to modify the
program, how to stand up a new PLC container from
scratch, how to move this one. Includes the compose
file, the Editor steps that are not generated and are
always missed, and a symptom-to-document table.
246 lines
12 KiB
Markdown
246 lines
12 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.
|
||
|
||
```
|
||
GETTING-STARTED.md start here - modify the program / build a new PLC / move it
|
||
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
|
||
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)
|
||
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.
|
||
|
||
## 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 |
|
||
| Source integrity | **`src/` matches what is deployed.** The container's `program.st` was compared against `build/wrps.st` on 2026-09-02: identical POU structure, **zero** differences in non-declaration lines. See `as-built/README.md`. |
|
||
|
||
### ⚠️ 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.
|