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