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.
404 lines
24 KiB
Markdown
404 lines
24 KiB
Markdown
# 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, 0–7000 |
|
||
| `%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 | m³ |
|
||
| `%QW12` | Station state | enum, §3.1 |
|
||
| `%QW13/14/15` | Pump state | enum, §3.2 |
|
||
| `%QW16` | Current duty pump | 0 = none, 1–3 |
|
||
| `%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 |
|
||
| `%MW11–19` | Reserved | |
|
||
| `%MW20–29` | 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.0–50.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 0–7000 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 · `bit4–6` Pump 1/2/3 tripped · `bit7–9` Pump 1/2/3 seal leak · `bit10–12` 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, 40–110 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, 1–120, 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, 30–60× 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`.
|