# 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
ARCHITECTURE.md     what the program is made of: POUs, tasks, layers, points
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?** `GETTING-STARTED.md` 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.
`ARCHITECTURE.md` shows how the program is put together before you read any ST.

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