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.
|
||
|---|---|---|
| .. | ||
| as-built | ||
| editor-devices | ||
| src | ||
| build.py | ||
| DEPLOY.md | ||
| gen_project.py | ||
| GETTING-STARTED.md | ||
| README.md | ||
| register-map.csv | ||
| VERSIONS.md | ||
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
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.