wrps-demo-kit/03-plc/README.md
Clio Liu 55279ca78f docs(plc): as-built copy, versions, and a getting-started guide
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.
2026-09-02 16:48:30 +10:00

12 KiB
Raw Blame History

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 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
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.