# Architecture of the PLC program

What the program is made of, what each part does, and how data moves through it.
Behaviour and the reasoning behind design choices are in `README.md`; addresses
are in `register-map.csv`.

## At a glance

| | |
|---|---|
| Language | IEC 61131-3 Structured Text |
| Source files | 10 (`src/*.st`), concatenated in lexical order |
| Function blocks | 4 |
| Programs | 3 |
| Tasks | **1** — `plc_task`, 100 ms cyclic |
| Program instances | 3, all on that one task |
| Located variables | 69 |
| Process-image globals | 65 |
| Modbus points | 69 — 10 input reg · 10 discrete in · 15 coil · 34 holding reg |
| Writable from SCADA | 15 holding registers (11 control + 4 simulation) |
| Comms interfaces | **1** — Modbus TCP server (slave), port 502, unit 1 |

## The one architectural rule

**Control logic never touches hardware.** Exactly two files may reference a
located variable (`%IW`, `%IX`, `%QW`, `%QX`, `%MW`):

- `10_globals.st` — declares them
- `50_prog_io_mux.st` — reads and writes them

Everything else reads and writes **process-image globals** (`g_*`) only.
`build.py` enforces this and **refuses to build** if any other file references a
located variable in code.

That rule is what lets the identical control logic run against a simulated plant
or a real one, with no change to a single control POU.

## Layers

```
   ┌──────────────────────────────────────────────────────────────┐
   │  MODBUS TCP SLAVE  :502   (runtime plugin, not program code) │
   └──────────────────────────────────────────────────────────────┘
        ▲ %QW %QX published        ▼ %MW commands      ▼ %IW %IX field
   ┌──────────────────────────────────────────────────────────────┐
   │  IO_MUX          the ONLY POU that touches located variables │
   │                  field build : %IW/%IX  →  g_*               │
   │                  sim build   : SIMULATION outputs  →  g_*    │
   │                  both        : g_o_*  →  %QW/%QX             │
   │                  first scan  : seeds %MW setpoint defaults   │
   └──────────────────────────────────────────────────────────────┘
                          ▲ g_o_*            ▼ g_*
   ┌──────────────────────────────────────────────────────────────┐
   │  CONTROL         the control logic. Reads globals only.      │
   │    ├── FB_PUMP        × 3   one per unit                     │
   │    ├── FB_DUTY_SELECT × 1   which units run                  │
   │    ├── FB_LEVEL_CTRL  × 1   what speed                       │
   │    └── FB_HEADROOM    × 1   time and volume to breach        │
   └──────────────────────────────────────────────────────────────┘
                          ▲ run cmds, speed
   ┌──────────────────────────────────────────────────────────────┐
   │  SIMULATION      simulation build only. Models the wet well, │
   │                  the pumps and the instruments.              │
   └──────────────────────────────────────────────────────────────┘
```

## The sections

### Function blocks

| File | POU | Lines | Owns |
|---|---|---|---|
| `20_fb_pump.st` | `FB_PUMP` | 141 | **One pump, completely.** Min-run and min-off timers, no-flow trip, thermal and vibration trips, availability, run-hour accumulation, service-due, pump state enum. Three instances. Trips latch and clear only on command. |
| `21_fb_duty_selector.st` | `FB_DUTY_SELECT` | 128 | **Which** units run — never how many. Ranks available units by service-due then run hours, ties by pump number. Two rules dominate the ranking: never stop a running unit to start a better-ranked one, and service-due is a preference, never a veto. |
| `22_fb_level_control.st` | `FB_LEVEL_CTRL` | 74 | Common drive speed. PI, no derivative, anti-windup, clamped 38–50 Hz. Below 38 Hz the 22 m static lift means no delivery, so the clamp is physical, not a preference. |
| `23_fb_headroom.st` | `FB_HEADROOM` | 97 | Pure calculation, no control action. Net inflow, volume and time to the spill weir and to LSHH. Inflow is filtered through a 30 s lag first — this is the number the demo narrative rests on. |

### Programs

| File | POU | Lines | Does |
|---|---|---|---|
| `30_prog_control.st` | `CONTROL` | 595 | The control sequence. Clamps setpoints, processes the command word and acknowledge, derives how many pumps are required from level with hysteresis, applies the LSHH / LSLL / station-off overrides, staggers starts, calls the four function blocks in order, and publishes every output global. **The largest file, and where the plant's behaviour lives.** |
| `50_prog_io_mux.st` | `IO_MUX` | 292 | The hardware boundary. Copies located inputs (or the simulation's outputs) into the process image, copies published globals out to `%QW`/`%QX`, and seeds the `%MW` setpoint defaults once on first scan. |
| `40_prog_simulation.st` | `SIMULATION` | 308 | **Simulation build only.** Integrates the wet-well volume, models per-pump flow against speed with parallel derating, generates discharge pressure, drives four inflow scenarios, applies time scaling and adds measurement noise. |

### Declarations and configuration

| File | Lines | Contents |
|---|---|---|
| `10_globals.st` | 252 | The only place located variables are declared: 69 of them, plus 65 process-image globals and the constants (plant geometry, speed limits, setpoint defaults). |
| `90_config_field.st` | 23 | `CONFIGURATION` for the **field** build — `plc_task` at 100 ms, instances `inst_mux` + `inst_ctl`. |
| `91_config_sim.st` | 45 | `CONFIGURATION` for the **simulation** build — the same, plus `inst_sim`. |

## Execution order — it is load-bearing

One task, `plc_task`, 100 ms cyclic. Declaration order in the CONFIGURATION sets
execution order within it.

**Field build** (`90_config_field.st`): `IO_MUX` → `CONTROL`

**Simulation build** (`91_config_sim.st`): `SIMULATION` → `IO_MUX` → `CONTROL`

> `SIMULATION` runs **first**, though CTL-003 §8.2 says it should run after
> `CONTROL`. With it last, the first scan after every start has no simulated image,
> so `IO_MUX` falls back to the all-zero field inputs — which means `ThermalOK`
> FALSE and `LSLL` dry — and all three pumps latch a trip while the station enters
> dry-run lockout. Every demo would begin by clearing trips that never happened.
> The cost of running it first is one scan of staleness, invisible beside a 3 s
> pump start delay.

**The 100 ms interval is not adjustable on its own.** `FB_PUMP`, `FB_LEVEL_CTRL`,
`FB_HEADROOM` and `SIMULATION` all integrate against a hardcoded `SCAN_S := 0.1`.
Change the task interval without changing those and you silently alter controller
tuning and run-hour accumulation.

## The two builds

Selected by `build.py --mode`; the difference is **two files**, and no control POU
changes between them.

| | Field | Simulation |
|---|---|---|
| `40_prog_simulation.st` | excluded | included |
| Configuration | `90_config_field.st` | `91_config_sim.st` |
| `IO_MUX` input source | `%IW` / `%IX` | `SIMULATION` outputs |
| `%MW20–23` | declared, unread | scenario control |
| Currently deployed | | **✅ this one** |

`%MW20–23` are declared in both builds even though CTL-003 §2 says
"simulation build only" — located variables may only be declared in
`10_globals.st`, and splitting that file would weaken the build's located-variable
check. In the field build they are simply never read.

## Comms interfaces

**One.** The runtime's `modbus_slave` plugin, Modbus TCP, port 502, unit id 1.

It is **not program code** — it is a runtime plugin, enabled by
`conf/modbus_slave.json`, which the Editor only ships if the project defines a
Modbus **Server** device. This is why port 502 can be published by Docker and
still refuse connections. See `DEPLOY.md` §A3.

| Not used | Why |
|---|---|
| `modbus_master` | Disabled. The PLC must never poll anyone — that would invert the architecture. |
| `opcua`, `s7comm` | Disabled. |
| `ethercat` | ⚠️ **Enabled**, from an empty `ethercat.json` the Editor emits by default. Nothing uses it. |

The REST API on 8443 is the runtime's control channel (upload, start/stop), not a
process interface. CI Server never touches it.

## The 69 Modbus points

| Object | FC | Count | Direction | What |
|---|---|---|---|---|
| Input register `%IW` | 04 | 10 | PLC reads | Field analogues: level, flows, pressures, vibration |
| Discrete input `%IX` | 02 | 10 | PLC reads | Field digitals: level switches, thermals, seal leaks, mains |
| Coil `%QX` | 01 | 15 | PLC writes | Run commands, running, available, tripped, in-auto, alarms |
| Holding register `%QW` | 03 | 19 | PLC writes | Every published measurement, station and pump state, run hours, headroom |
| Holding register `%MW` | 03/06 | 15 | **SCADA writes** | 11 setpoints and commands + 4 simulation controls |

> **`%QW` occupies holding registers 0–1023; `%MW` starts at HR 1024.** So `%MW3`,
> the level setpoint, is holding register **1027**. This is a v4 change and it
> produces plausible-but-wrong values rather than an error. Use the resolved
> addresses in `register-map.csv`.

**In the simulation build, do not poll `%IW` / `%IX`.** Nothing writes them; they
read zero forever. Every live value is published in the `%QW` block.

## Where the numbers come from

The whole chain is generated from one table, so the two sides cannot drift:

```
src/*.st  ──build.py──►  register-map.csv  ──gen_scada_points.py──►  scada-points.csv
   ▲                          ▲                                            │
   │                    REGISTERS table                       gen_ciserver_qli.py
   │                    in build.py                                        ▼
 hand-written                                                     CI Server .qli
```

`register-map.csv` is **generated** — never hand-edit it. Change the `REGISTERS`
table in `build.py` and rebuild, then regenerate the SCADA side.
