wrps-demo-kit/01-design/WRPS-CTL-003_OpenPLC_Implementation_Brief.md
xxlio 11fb30268f docs(design): design set, with its origin and the method that made it
The five design documents plus the implementation brief, brought across
as issued. Adds two things the old repo never recorded:

  00-origin/  the one-page idea the whole document set was generated from
  README.md   how the set was produced - idea -> basic design -> detailed
              design -> implementation brief - so the team can repeat the
              method for their own demos, including what to do differently

CTL-003 gains a dated amendment block. Its section 2 register map was
verified line by line against register-map.csv and is correct; its
sections 1 and 9 specify a MatIEC flat-file build that does not exist on
Runtime v4, and forbid the PLCopen route actually used. Nine corrections
listed, body unchanged.

Not brought across: superseded/WRPS-CTL-002_OpenPLC_Build_Brief.md - dead,
and its number collides with the live FDS.
2026-09-02 15:25:54 +10:00

24 KiB
Raw Blame History

WRPS-CTL-003 — Waterloo Road Pump Station, OpenPLC implementation brief

Target: OpenPLC Runtime v4 on Windows, feeding Yokogawa CI Server over Modbus TCP. Source documents: WRPS-CTL-001 (Control Philosophy), WRPS-CTL-002 (Functional Design Specification), WRPS-PRO-001 (Process Basis of Design), WRPS-DRG-001 (P&ID), WRPS-INS-001 (Instrument index). Status: subordinate to WRPS-CTL-002. Where this brief and the FDS disagree, the FDS governs. Deliverable: a single IEC 61131-3 Structured Text file, uploadable directly through the OpenPLC Runtime web interface.


Amendments — 2026-09-02

This document is retained as issued. The body below is unchanged. The corrections here were found during the repo audit, by checking the document against the system that was actually built and is running.

§2 (the Modbus address map) was verified line by line against the generated 03-plc/register-map.csv and is correct in full. So are §3§8. The errors are confined to the build system described in §1 and §9, which was superseded before any code was written.

# Where As issued Correction
A1 Header "OpenPLC Runtime v4 on Windows" Linux, in Docker, on yau-sls-poc-lin001 (10.0.0.17). See 02-environment/.
A2 §1, §9 "OpenPLC's MatIEC compiler takes one flat .st file" Runtime v4 removed MatIEC and rejects its output. It requires STruC++ codegen, which ships only inside the OpenPLC Editor GUI. The flat-file build system this section specifies does not exist on v4.
A3 Header, §1 Uploadable "through the OpenPLC Runtime web interface … Programs → Upload Program" Runtime v4 has no browser UI. A program reaches it either through the Editor (compile + upload) or through the REST API on port 8443. See 03-plc/DEPLOY.md.
A4 §1 "Do not attempt to generate OpenPLC Editor PLCopen XML" This is now the required route. gen_project.py generates the Editor project from src/, because it is the only path that reaches a v4 runtime.
A5 Header, §10 "Deliverable: a single … Structured Text file" The deliverable is a generated OpenPLC Editor project. build.py's flat build/wrps.st survives as a single-file review artefact only; nothing consumes it.
A6 §1, §7 A tests/ directory in the source tree Never created. The verification that exists is 05-tests/verify_modbus.py, which proves the register map against the running PLC — not the control behaviour.
A7 §9 "Port 502 may already be bound on that Windows server" Applies to the Linux host. 502 and 8443 are published on 10.0.0.17 only — see 02-environment/README.md.
A8 §9, §10 "docs/register-map.csv" The file is 03-plc/register-map.csv, generated by build.py.
A9 §10 Definition of done requires all 14 pass-1 and 6 pass-2 tests "documented with actual observed values" Not met. None of the 20 acceptance tests have been run. The program compiles, is loaded, and publishes its registers correctly, but its control behaviour is unverified. This is the largest open item in the project.

A2, A3 and A4 share one cause: this brief was written before the deploy spike established that Runtime v4 had dropped MatIEC. §1 therefore specifies a build system that never existed on the target, and §4 of §1 forbids the method actually used. Read §1 and §9 as historical intent; read 03-plc/README.md for how the program is really built.

A9 is not a documentation defect. It is real outstanding work, recorded here so it is not mistaken for done.


0. How to use this brief

Build in two passes, in this order, and do not start pass 2 until pass 1 passes its acceptance tests:

  • Pass 1 — control logic only. The PLC reads level, flow and status from located input variables. Nothing simulates them. Test by writing values into the input image manually.
  • Pass 2 — simulation. Add a separate program that models the wet well and pumps, and switch the control logic's inputs from the located variables to the simulated ones through a single mux layer.

The control logic must never contain simulation code, and must never be edited to accommodate the simulator. If pass 2 requires a change inside a control POU, that is a design error in the mux layer — fix the mux, not the control.


1. Build system

OpenPLC's MatIEC compiler takes one flat .st file. To keep control and simulation genuinely separate in the source tree while still producing one uploadable artefact, use a concatenating build.

wrps-plc/
├── src/
│   ├── 10_globals.st              # VAR_GLOBAL, located variables, constants
│   ├── 20_fb_pump.st              # FB_PUMP
│   ├── 21_fb_duty_selector.st     # FB_DUTY_SELECT
│   ├── 22_fb_level_control.st     # FB_LEVEL_CTRL
│   ├── 23_fb_headroom.st          # FB_HEADROOM (time-to-breach calculation)
│   ├── 30_prog_control.st         # PROGRAM CONTROL
│   ├── 40_prog_simulation.st      # PROGRAM SIMULATION   <-- pass 2 only
│   ├── 50_prog_io_mux.st          # PROGRAM IO_MUX
│   ├── 90_config_field.st         # CONFIGURATION, field build
│   └── 91_config_sim.st           # CONFIGURATION, simulation build
├── build.py                       # concatenates in order, emits build/wrps.st
├── build/
└── tests/

build.py requirements:

  • python build.py --mode field → concatenates everything except 40_prog_simulation.st and 91_config_sim.st.
  • python build.py --mode sim → concatenates everything except 90_config_field.st.
  • Concatenation order is lexical by filename. Emit a header comment with the mode, git short hash and UTC timestamp.
  • Fail loudly if any source file is missing, rather than silently emitting a short file.
  • Do not attempt to generate OpenPLC Editor PLCopen XML. The .st file is uploaded directly through the Runtime web UI (Programs → Upload Program).

2. Modbus address map

OpenPLC's located-variable to Modbus mapping (verify against your runtime version before wiring CI Server — the offsets have changed between v3 and v4):

IEC Modbus object Access from CI Server
%IX0.0+ Discrete input Read only
%QX0.0+ Coil Read/write (PLC overwrites each scan)
%IW0+ Input register Read only
%QW0+ Holding register Read/write (PLC overwrites each scan)
%MW0+ Holding register Read/write, PLC does not overwrite

Because the PLC rewrites %QX and %QW every scan, all commands from CI Server go into %MW only. Never expect a SCADA write to a coil to persist.

2.1 Field inputs — %IW / %IX

Used in field mode. In simulation mode these are left unwired and the mux takes the simulated values instead.

Address Tag Engineering value Raw scaling
%IW0 LIT-101 Wet well level mm, 07000
%IW1 FIT-201 Inlet flow L/s × 10
%IW2 FIT-301 Discharge flow L/s × 10
%IW3 PIT-302 Manifold pressure kPa
%IW4/5/6 PIT-311/321/331 Pump discharge pressure kPa
%IW7/8/9 VE-314/324/334 Bearing vibration mm/s × 10
%IX0.0 LSHH-102 High high level TRUE = wet
%IX0.1 LSLL-103 Low low level TRUE = wet (fail-safe: FALSE = dry)
%IX0.2 LSH-104 Spill detected TRUE = spilling
%IX0.3/4/5 TE-312/322/332 Motor thermal trip TRUE = healthy
%IX0.6/7, %IX1.0 MSE-313/323/333 Seal leak TRUE = leak
%IX1.1 XA-502 Mains healthy TRUE = healthy

2.2 PLC outputs — %QX / %QW (read by CI Server)

Address Description Units
%QX0.0/1/2 PU-301/302/303 run command
%QX0.3/4/5 PU-301/302/303 running
%QX0.6/7, %QX1.0 PU-301/302/303 available
%QX1.1 Station in auto
%QX1.2 High level alarm
%QX1.3 Spill active
%QX1.4/5/6 PU-301/302/303 tripped
%QW0 Wet well level mm
%QW1 Inflow L/s × 10
%QW2 Total discharge flow L/s × 10
%QW3 Pumps running count
%QW4 Common drive speed Hz × 10
%QW5 Time to spill weir seconds, 32767 = drawing down
%QW6 Time to LSHH seconds, 32767 = drawing down
%QW7 Net accumulation L/s × 10, signed
%QW8/9/10 PU-301/302/303 run hours hours
%QW11 Volume remaining to spill
%QW12 Station state enum, §3.1
%QW13/14/15 Pump state enum, §3.2
%QW16 Current duty pump 0 = none, 13
%QW17 Alarm bitmask §6
%QW20 Command acknowledge echoes %MW1 when executed

2.3 Commands and setpoints — %MW (written by CI Server)

Address Description Default
%MW0 Station mode: 1 = auto, 2 = off 1
%MW1 Command word, §3.3 0
%MW2 Command parameter (pump number, etc.) 0
%MW3 Level control setpoint 4200 mm
%MW4 Start duty level 4000 mm
%MW5 Start pump 2 level 4500 mm
%MW6 Start pump 3 level 5000 mm
%MW7 Stop all level 1000 mm
%MW8 High level alarm 5200 mm
%MW9 Minimum drive speed 380 (Hz × 10)
%MW10 Service interval hours, default 4000
%MW1119 Reserved
%MW2029 Simulation control, §8 — simulation build only

Clamp every setpoint on read. If CI Server writes nonsense, hold the previous good value and raise an alarm rather than acting on it. A start level above the spill weir must never be accepted.


3. Enumerations

3.1 Station state (%QW12)

0 Off · 1 Idle · 2 Pumping · 3 High level · 4 Emergency (LSHH) · 5 Dry run lockout · 6 Fault

3.2 Pump state (%QW13/14/15)

0 Unavailable · 1 Available, stopped · 2 Start delay · 3 Running · 4 Min-run inhibit · 5 Min-off inhibit · 6 Tripped · 7 Maintenance lockout

3.3 Command word (%MW1)

0 None · 1 Reset all trips · 2 Reset trip on pump in %MW2 · 3 Lock out pump in %MW2 · 4 Release lockout on pump in %MW2 · 5 Reset run hours on pump in %MW2 (service done) · 6 Acknowledge alarms

Handshake: PLC executes on rising edge of a non-zero %MW1, writes the same value to %QW20, and takes no further action until %MW1 returns to 0. CI Server writes 0 after it sees the echo.


4. Control logic specification

Scan task: 100 ms, cyclic. All timers in TIME literals, never scan counts.

4.1 FB_PUMP

One instance per pump. Owns everything about a single unit.

Inputs: RunRequest : BOOL, SpeedRef : REAL (Hz), ThermalOK : BOOL, SealLeak : BOOL, Vibration : REAL, DischPressure : REAL, ResetTrip : BOOL, Lockout : BOOL

Outputs: RunCmd : BOOL, Running : BOOL, Available : BOOL, Tripped : BOOL, State : INT, RunHours : REAL, ServiceDue : BOOL

Behaviour:

  • Available = ThermalOK AND NOT Tripped AND NOT Lockout. A seal leak does not remove availability — it raises an alarm only (per WRPS-PRO-001 §5.5).
  • Minimum run 5 min: once Running, ignore a falling RunRequest until the timer expires.
  • Minimum off 5 min: once stopped, block restart until expired. Overridden only by the LSHH emergency condition.
  • No-flow trip: 20 s after RunCmd goes true, if DischPressure < 150 kPa, set Tripped, drop RunCmd, latch alarm. This is the condition that must cause the duty selector to promote the next available unit.
  • Vibration: alarm above 7.1 mm/s, trip above 11.0 mm/s.
  • RunHours accumulates only while Running, in scan-time increments. ServiceDue when RunHours >= %MW10.
  • Trips latch. They clear only on the reset command, never automatically.

4.2 FB_DUTY_SELECT

Decides which units run, never how many.

Inputs: Available[1..3] : BOOL, RunHours[1..3] : REAL, ServiceDue[1..3] : BOOL, RunningNow[1..3] : BOOL, PumpsRequired : INT

Outputs: RunRequest[1..3] : BOOL, DutyPump : INT

Ranking rule, best first:

  1. Unavailable units are excluded entirely.
  2. Units not due for service rank above units due for service.
  3. Within each group, lower accumulated run hours ranks first.
  4. Ties break by ascending pump number, so the result is deterministic and repeatable in a demo.

Then: assign RunRequest to the top PumpsRequired ranked units.

Two rules that matter more than the ranking:

  • Never stop a running unit to start a better-ranked one. If a unit is already running and is still available, it keeps its slot. Re-ranking applies only when a slot opens. Without this the station will churn pumps every time run hours cross over.
  • Service-due is a preference, never a veto. If the only available unit is due for service, it runs. Rank 2 above must not be able to produce an empty selection while an available unit exists.

4.3 FB_LEVEL_CTRL

Inputs: Level : REAL (m), Setpoint : REAL, Enable : BOOL, MinSpeed, MaxSpeed : REAL

Output: Speed : REAL (Hz), common to all running units.

  • PI control, no derivative term. Suggested starting gains: Kp = 12 Hz/m, Ti = 120 s. Expect to retune.
  • Output clamped 38.050.0 Hz. Below 38 Hz the 22 m static lift means no delivery, so the clamp is a hard physical limit, not a preference.
  • Anti-windup: freeze the integrator whenever the output is clamped.
  • On Enable false, hold output at MinSpeed and reset the integrator, so a restart doesn't inherit stale integral action.

4.4 FB_HEADROOM

Pure calculation. No control action, no alarms. Publishes the quantity the demo narrative is built on.

NetInflow    = Inflow - TotalDischarge                    (L/s)
VolToSpill   = (6.000 - Level) * 120.0                    (m³)
VolToLSHH    = (5.500 - Level) * 120.0                    (m³)
TimeToSpill  = VolToSpill * 1000.0 / NetInflow            (s)
  • If NetInflow <= 0.5 L/s, output 32767 — drawing down or holding, the figure is meaningless.
  • Clamp outputs to 0 .. 32767. Never emit a negative time.
  • Filter Inflow through a 30 s first-order lag before use. Raw flow-meter noise on a near-zero denominator produces wild swings, which on a demo screen looks broken.

4.5 PROGRAM CONTROL

Execution order per scan, and it matters:

  1. Read and clamp setpoints from %MW.
  2. Process the command word and write the acknowledge.
  3. Determine PumpsRequired from level:
    • Level >= StartP3 → 3
    • Level >= StartP2 → 2
    • Level >= StartDuty → 1
    • Level <= StopAll → 0
    • otherwise hold the previous value (this hysteresis band is the whole point — do not recompute from scratch)
  4. Override: if LSHH then PumpsRequired := 3 and bypass min-off timers.
  5. Override: if LSLL then PumpsRequired := 0, latch dry-run lockout, require a manual reset.
  6. Override: if station mode is off, PumpsRequired := 0.
  7. Call FB_DUTY_SELECT.
  8. Call FB_LEVEL_CTRL. On LSHH force 50.0 Hz.
  9. Call each FB_PUMP.
  10. Call FB_HEADROOM.
  11. Stagger starts: when more than one unit is being started, hold the second and third by 30 s each, to limit the inrush and the hydraulic transient.
  12. Publish all %QW / %QX.

5. Interlocks that must be provable

These map to the cause-and-effect table in WRPS-PRO-001 §5.5. Each needs a test in §7.

Initiator Action Reset
LSLL-103 Stop all, inhibit start Manual, after level recovers above stop level
LSHH-102 Start all available at 50 Hz, bypass min-off Automatic on level falling below LSHH
TE-31x Trip that unit, promote next available Manual
VE-31x > 11.0 mm/s Trip that unit Manual
PIT-31x low, 20 s after start Trip that unit, promote next available Manual
LIT-101 out of range or frozen Alarm, fall back to LSHH/LSLL discrete control Automatic

Level signal failure detection: raise the fault if the raw value sits outside 07000 mm, or if it does not change by more than 1 mm for 10 minutes while a pump is running. A frozen transmitter reading a plausible value is the failure mode that actually causes spills, and it is invisible to a range check alone.


6. Alarm bitmask (%QW17)

bit0 High level · bit1 High high level · bit2 Low low level · bit3 Spill active · bit46 Pump 1/2/3 tripped · bit79 Pump 1/2/3 seal leak · bit1012 Pump 1/2/3 high vibration · bit13 Level signal fault · bit14 Mains failure · bit15 Setpoint rejected


7. Pass 1 acceptance tests

Run these with the simulation absent, by writing directly into the input image. Automate them in tests/ if practical; otherwise document the manual procedure with expected %QW values.

  1. Cold start. Level 2.0 m, no pumps. Expect: 0 pumps, state Idle.
  2. Duty start. Raise level to 4.1 m. Expect: exactly 1 pump requested, lowest-run-hours unit selected, speed ramping.
  3. Hysteresis. Fall to 3.9 m. Expect: still 1 pump. Fall to 0.9 m. Expect: 0 pumps.
  4. Assist staging. Raise to 4.6 m then 5.1 m. Expect 2 then 3 pumps, each staged 30 s apart.
  5. Rotation. Set PU-301 run hours to 500, others to 100. Trigger a duty start. Expect PU-302 (lowest hours, tie broken by number).
  6. Service due. Set PU-302 run hours above the service interval. Expect PU-303 selected ahead of it, but PU-302 still selected if it is the only available unit.
  7. No churn. With PU-302 running, set its run hours above PU-303's. Expect PU-302 keeps running.
  8. No-flow trip. Start a pump, hold PIT-31x at 100 kPa. Expect trip after 20 s, next available unit promoted, alarm bit set.
  9. Thermal trip. Drop TE-312. Expect PU-301 unavailable, promotion, trip latched through a level cycle, clears only on command 2.
  10. LSHH. Assert LSHH-102. Expect all available units at 50.0 Hz regardless of min-off timers.
  11. LSLL. Assert LSLL-103. Expect all stopped, lockout latched, and no restart until reset even after level recovers.
  12. Frozen transmitter. Hold %IW0 constant for 10 minutes with a pump running. Expect the level fault alarm.
  13. Setpoint rejection. Write 6500 mm to %MW4. Expect rejection, previous value retained, bit15 set.
  14. Time to breach. Level 4.00 m, inflow 165 L/s, one pump at 120 L/s. Expect %QW5 ≈ 5333 s and %QW7 = 450.

Test 14 is the one the demo narrative rests on. It must reproduce the figure in WRPS-PRO-001 §7.3 exactly, or the Basis of Design and the running system disagree.


8. Pass 2 — simulation

Only start this once every test above passes.

8.1 The mux layer

Add PROGRAM IO_MUX, which runs before CONTROL in the task list and is the only place that touches located input variables.

  • In the field build, IO_MUX copies %IW / %IX into the global process variables that CONTROL reads.
  • In the simulation build, IO_MUX copies the simulation program's outputs into those same globals instead.
  • CONTROL reads only the globals. It must contain no %IW, %IX reference at all. Grep for it as a build check.

8.2 PROGRAM SIMULATION

Runs on the same 100 ms task, after CONTROL, so it sees this scan's run commands.

Wet well integration:

dt          = 0.1 * TimeScale                         (simulated seconds this scan)
NetFlow     = Inflow - SumPumpFlow                    (L/s)
Volume      = Volume + NetFlow * dt / 1000.0          (m³)
Level       = Volume / 120.0                          (m)

Clamp Level to 0.0 .. 6.0. On reaching 6.00 m, spill: hold level at 6.00, set the spill flag, and discard the excess. The station must be seen to spill in the demo, not to have its level run off scale.

Per-pump flow model. For each unit with RunCmd true:

  • Start delay: 3 s from command to delivering flow, so the no-flow trip logic has something real to detect.
  • Speed to flow, linear between the two physical anchors: 38 Hz → 65 L/s, 50 Hz → 120 L/s. Below 38 Hz, flow is 0 — this is the static-lift cutoff and must be modelled, or the level controller's clamp looks arbitrary.
  • Parallel derating: with n units running, multiply each unit's flow by 1.00 / 0.94 / 0.88 for n = 1 / 2 / 3. Three units then give roughly 360 L/s at full speed, matching the design basis rather than a naive 3 × 120.
  • Discharge pressure: 220 + 1.4 * PumpFlow kPa while delivering, 80 kPa otherwise. This is what makes the no-flow trip testable.

Inflow generator, selected by %MW21:

Mode Behaviour
0 Manual — inflow held at %MW20 (L/s × 10)
1 Diurnal dry weather — sinusoid, 40110 L/s, 24 h simulated period
2 Wet weather event — ramp from current to 300 L/s over 20 simulated minutes, hold 40 min, decay over 90 min
3 Demo reference — start at exactly 165 L/s with the level at 4.00 m and one pump running, then ramp slowly

Mode 3 must reproduce the §7.3 reference condition on demand, so the demo can be reset to a known state between audiences. Command %MW22 = 1 resets the simulation to the initial conditions of the selected mode.

Time scaling. %MW23 holds the time scale, 1120, default 1. All simulation integration multiplies by it. The control logic must not know about it — its timers stay in real seconds. This means a 5-minute minimum-run timer at 60× covers 5 simulated hours, which is wrong but harmless for the demo. Note the discrepancy in the README rather than trying to scale the control timers; scaling both is what makes these demos impossible to debug.

Sensible defaults: 1× while testing logic, 3060× when presenting.

8.3 Noise

Add small noise to the published measurements — ±0.5% on level, ±2% on flow — using a simple LCG, not a library call. Perfectly smooth trends are the giveaway that a plant screen is fake, and the inflow filter in FB_HEADROOM needs something to filter or you cannot show that it works.

8.4 Pass 2 acceptance tests

  1. Dry weather mode for 24 simulated hours: expect ~7 start/stop cycles per unit and no high level alarm.
  2. Demo reference mode: expect %QW5 to count down from ≈5333 s, and the spill to occur at ≈89 simulated minutes if no second pump starts.
  3. Wet weather mode with all three available: expect the level to peak below 5.50 m and no spill.
  4. Wet weather mode with PU-301 locked out: expect the level to reach LSHH and, with two units, to spill.
  5. Reset command returns level, volume, run hours and trip states to initial conditions.
  6. Time scale 60× produces the same level trajectory as 1×, sampled at the same simulated times.

Test 3 versus test 4 is the demonstration that firm capacity of 240 L/s is below PWWF of 300 L/s. That contrast is worth building the demo around.


9. Constraints

  • OpenPLC's MatIEC is not a full IEC 61131-3 implementation. Stay on: BOOL, INT, DINT, REAL, TIME, one-dimensional ARRAY, TON, TOF, R_TRIG, F_TRIG, CTU. Avoid STRING manipulation, nested STRUCT, REF_TO, and VAR_IN_OUT on complex types.
  • Everything the demo needs must survive a runtime restart being the reset mechanism. Do not rely on retained variables.
  • Port 502 may already be bound on that Windows server. Check before assuming.
  • No floating-point over Modbus. Every published value is a scaled 16-bit integer, per §2. Document the scaling in a docs/register-map.csv generated from the same source of truth as the ST, so CI Server tag configuration and the PLC cannot drift apart.

10. Definition of done

  • python build.py --mode field and --mode sim both emit a file that OpenPLC Runtime compiles without warnings.
  • All 14 pass-1 tests and all 6 pass-2 tests documented with actual observed values.
  • docs/register-map.csv generated, matching §2.
  • README.md covering: how to build, how to upload, how to select a scenario, how to reset between demos, and the time-scaling caveat in §8.2.
  • Grep confirms no located variable reference outside 10_globals.st and 50_prog_io_mux.st.