feat(plc): ST sources, generators and the Modbus contract

The PLC program from the old repo's 04-plc/, flattened into one folder and
checked against the running system.

Verified during the move:
  - build.py regenerates register-map.csv byte-identically (69 points)
  - polled the live PLC: the SIMULATION build is what is deployed and
    running, %MW21=2 wet weather, values moving, run hours accumulating
  - addresses, %MW HR1024 segmentation and %QW17/%QW7 signedness all
    match the map

Corrections against the old repo:
  - 10_globals.st header cited WRPS-CTL-002 (the FDS); it means CTL-003
  - build.py wrote the map to its parent directory; now beside itself
  - deploy/README.md was a single-file folder; now DEPLOY.md
  - dropped the empty editor-devices/remote/
  - README no longer claims the simulation build is uncompiled - it is
    the one running

Two open items are now stated plainly rather than buried:
  - none of the 20 acceptance tests in CTL-003 have ever been run
  - the OpenPLC Editor lived only on the retired dev-ubuntu host, so
    there is currently NO route to deploy a new program (DEPLOY.md 0)

Documents the setpoint distinction: IO_MUX seeds %MW defaults once at
first scan, operators retune them live, and that tuning exists only in
the container volume - a restart reverts it.
This commit is contained in:
Clio Liu 2026-09-02 15:48:49 +10:00
parent f6b32b42a1
commit 13a05d0135
20 changed files with 3248 additions and 1 deletions

10
.gitignore vendored
View file

@ -28,5 +28,13 @@ venv/
.pytest_cache/ .pytest_cache/
# --- Build output ------------------------------------------------------------ # --- Build output ------------------------------------------------------------
# Rules are added as each generator is brought across during the audit.
*.log *.log
# Generated by 03-plc/build.py - a single-file review artefact. Rebuild it.
# NOTE: 03-plc/register-map.csv IS committed even though it is generated: it is
# the PLC-side half of the Modbus contract, so changes must show up in diffs.
03-plc/build/
# Generated by 03-plc/gen_project.py - the OpenPLC Editor v4 project, rebuilt
# from src/. Regenerate it; never hand-edit it, never commit it.
03-plc/editor-project/

235
03-plc/DEPLOY.md Normal file
View file

@ -0,0 +1,235 @@
# Deploying a program to the OpenPLC runtime
Headless upload is **not available**: Runtime v4 accepts only STruC++ codegen
output, and that compiler ships solely inside the OpenPLC Editor's GUI bundle.
The repo prepares the project; a human drives the Editor.
The chain — repo `src/` → generated project → STruC++ → runtime compile → running
PLC → Modbus TCP read — **was proven end to end on 2026-08-14**, on the
now-retired `dev-ubuntu` host.
---
## 0. ⚠️ Read this first — the toolchain is not currently available
> [!WARNING]
> **You cannot deploy a new program today.** The OpenPLC Editor v4 AppImage lived
> only on `dev-ubuntu`, which has been retired. It is **not installed on
> `yau-sls-poc-lin001`**, and there is no `wrps` Editor project there — the home
> directory holds only `openplc-compose.yml` and the migration tarballs.
>
> The PLC that is running is a **committed Docker image** (`docker commit`) with an
> already-compiled program baked into its writable layer. It runs fine and will
> keep running. But editing `src/` and regenerating the project produces something
> with nowhere to go.
**To restore the ability to deploy, someone must:**
1. Install OpenPLC Editor v4.2.11 somewhere with a graphical desktop that can
reach `10.0.0.17:8443`. Options: a desktop VM on the `10.0.0.0/24` LAN, or a
workstation on the WireGuard VPN.
2. Recreate the Editor project — §A below is the complete list of what is *not*
generated and must be configured by hand.
3. Connect it to the runtime at `https://10.0.0.17:8443` (not `localhost` — the
Editor is no longer on the same host as the runtime).
4. Prove the chain with a trivial change before relying on it.
Until that is done, treat the PLC program as **frozen**. Everything below §A is
the runbook for once it is restored, and is accurate apart from the host and
address changes noted.
---
## A. One-time setup (redo on a fresh project)
These steps are **not** generated by `gen_project.py` and are easy to miss. A
reference copy of the resulting files is in `editor-devices/`.
### A1. Create the project
**WHERE:** OpenPLC Editor, on the machine you install it on.
**WHAT:** New Project, name `wrps`.
**WHAT FOR:** creates the folder skeleton the generator writes into. The base
program language does not matter — the generator replaces `pous/` entirely,
including deleting the placeholder `main`.
### A2. Point it at the runtime
**WHERE:** Editor → **Device → Configuration**.
**WHAT:** Device `OpenPLC Runtime V4`, IP **`10.0.0.17`**, port `8443`, runtime
user `admin` (password in `02-environment/secrets.local.md`, git-ignored).
**WHAT FOR:** identifies the target board and the REST endpoint.
> The reference copy in `editor-devices/configuration.json` still says
> `localhost`, which was correct when the Editor ran on the same host as the
> runtime. It is not correct now — use the LAN address.
### A3. Add the Modbus **server** — the step easiest to get wrong
**WHERE:** Editor → **Device → Servers** → add a Modbus TCP server (`Modbus01`).
**WHAT:** **Enable Server** on; leave `networkInterface` `0.0.0.0` and port `502`.
**WHAT FOR:** this makes the Editor ship `conf/modbus_slave.json` in the upload
bundle, which flips the runtime's `modbus_slave` plugin to enabled. Without it the
plugin stays off and **nothing ever binds port 502**, no matter how the container
is configured.
> `0.0.0.0` here is correct and is **not** in conflict with the container binding
> 502 to `10.0.0.17`. This setting is *inside* the container — the slave listens on
> all of the container's own interfaces. Docker's port publishing is what restricts
> exposure to the host's LAN address. Do not "fix" one to match the other.
> ⚠️ **Do not use Device → Remote Devices for this.** That is the Modbus
> *master*: it makes the PLC poll someone else, inverting the architecture
> invariant, and its IO groups claim `%IW` addresses that collide with the field
> inputs in `src/10_globals.st`. CI Server needs no entry in the PLC project at
> all — it is a client and simply connects.
---
## B. Every deployment
### B0. Generate and install the project
**WHERE:** wherever this repo is checked out.
```bash
python gen_project.py --mode sim --out editor-project
```
then copy `editor-project/project.json` and `editor-project/pous/` over the
Editor project folder.
**WHAT FOR:** the Editor has **no flat-`.st` import** — a v4 project is a folder
(`project.json` + one file per POU). This also writes the located addresses
(`%IW0`, `%QX0.0`, …) straight into `project.json`, which the Global Variables grid
will not let you type by hand.
`devices/` and `build/` are never touched — only `project.json` and `pous/`.
**This repo is canonical. Never edit the Editor project in place** — changes there
are wiped on the next generate. Fixes go into `src/`.
### B1. Launch the Editor
**WHERE:** a graphical desktop session — not over SSH.
```bash
~/Applications/OpenPLC.Editor-4.2.11.AppImage --no-sandbox
```
**WHAT FOR:** `--no-sandbox` is required (Electron) and `libfuse2t64` must be
installed, or the AppImage exits immediately.
### B2. Open the project
> ⚠️ **The Editor must be CLOSED while files are copied in** — not merely showing
> a different tab. It holds `project.json` in memory and writes that copy back on
> its next save, silently discarding whatever was installed.
>
> Correct order: **quit the Editor → copy and verify the md5 → reopen → build.**
>
> This has actually happened (2026-08-14). The symptom is confusing because it is
> *partial*: new POU files survive — the Editor discovers those by scanning
> `pous/` — while `project.json` reverts, so the build fails with
>
> ```
> error: VAR_EXTERNAL 'G_SIM_LEVEL_MM' in program 'SIMULATION'
> has no matching VAR_GLOBAL declaration
> ```
>
> for every *new* global, while pre-existing globals resolve normally. New program
> instances vanish from the task list the same way. If you see that pattern, the ST
> is probably fine — check `project.json` before touching the code.
### B3. Connect to the runtime — required before any upload
**WHERE:** Editor → **Device → Configuration****Connect to OpenPLC Runtime**.
**WHAT:** authenticate as `admin`.
**WHAT FOR:** Upload and Start PLC are unavailable until the session is connected.
The console confirms with `[connection] status -> connected`.
### B4. Build and upload
**WHERE:** the left icon rail: Search · Open/Close Toolbox · **Build Options** ·
**Start PLC** · Debugger. There is no separate upload button.
> ⚠️ **Turn *compile-only* mode off.** If the console ends with *"Compile only
> mode — skipping upload to runtime"*, nothing was uploaded. In that mode the
> composed bundle is **discarded** rather than written to `build/`, so there is no
> zip left behind for any automated upload path either.
**WHAT FOR:** the Editor runs STruC++ (v0.6.2), composes the bundle, POSTs it to
`/api/upload-file`, and the runtime rebuilds `new_libplc.so` with Make while the
Editor polls `/api/compilation-status`.
### B5. Start the PLC
The runtime opens its **Modbus slave only while a program is running**.
### B6. Verify
```bash
python ../05-tests/verify_modbus.py --host 10.0.0.17 --port 502 --unit 1
```
`10.0.0.17` is only routable from inside the VNet or over the WireGuard VPN. From
outside, poll from a throwaway container on the host instead:
```bash
ssh lin001 "docker run --rm --network openplc-net python:3.12-alpine sh -c 'pip install -q pymodbus && python -c \"...\"'"
```
**What "working" looks like in the Editor log:**
```
Final state - modbus_slave: enabled=True
PLC started.
Upload complete.
```
---
## Gotchas, each one actually hit
**The Editor's declaration parser** is separate from the body compiler and rejects
three things inside a `VAR` block:
- comment-only lines, e.g. `(* process image in *)`
- a `(* … *)` comment that wraps onto the next line
- more than one declaration on a line, e.g. `A : INT; B : INT;`
A trailing single-line comment is fine. `gen_project.py` handles all three, so
`src/` keeps its comments and formatting. Symptom of a regression: *"could not be
fully parsed … preserved as raw text"*, then *"No variable defined in POU"*.
**The Editor class enum has no `constant`** (`input|output|inOut|external|local|
temp|global`), so `VAR_GLOBAL CONSTANT` entries are emitted as ordinary globals
with their initial values. Behaviour is unchanged — nothing writes them — but they
are no longer compiler-enforced read-only. The generator warns each run.
**Task interval must be `T#100ms`.** The generator takes it from the configuration
file in `src/`. Run hours, the PI integral and the inflow filter all hardcode
`SCAN_S := 0.1`; a mismatch silently skews tuning and accumulation.
**EtherCAT gets enabled** on the runtime from an `ethercat.json` the Editor project
emits by default. Harmless so far, but it is a plugin running for no reason.
---
## Notes that will bite otherwise
- **`%MW` is not holding register 0.** `%QW` occupies HR 01023 and `%MW` starts at
HR 1024, so `%MW3` (level setpoint) is **HR 1027**. Confirmed against the
plugin's own defaults (`qw_count: 1024`, `mw_count: 1024`). Use
`register-map.csv`, which carries the resolved addresses.
- **`%QW17` (alarm bitmask) must be read as unsigned 16-bit.** Bit 15 does not fit
a signed INT. `%QW7` is genuinely signed.
- **Nothing is retained.** Restarting the runtime resets everything; `IO_MUX`
re-seeds the `%MW` setpoint defaults on first scan — **which discards any live
demo tuning.** See `README.md`.
- **All `%IW` read 0** in the simulation build. That is correct — the mux takes
simulated values instead. In a *field* build with nothing driving the inputs, the
program correctly trips the pumps and raises alarms; that is not a fault either.
- **The runtime image is local to the host and cannot be re-pulled.** It was
produced by `docker commit` during the migration and carries the compiled
program. Deleting it loses the program. See `02-environment/`.

239
03-plc/README.md Normal file
View file

@ -0,0 +1,239 @@
# 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.
```
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)
DEPLOY.md how a program actually reaches the runtime
```
## 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 01023 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 1120, default 1 |
Scenarios (§8.2): **1** is a 40110 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, 3060× 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
**`%MW2023` 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 |
### ⚠️ 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.

316
03-plc/build.py Normal file
View file

@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""WRPS-CTL-003 build.
Concatenates src/*.st in lexical order into a single flat .st file for
import into OpenPLC Editor v4.
python build.py --mode field
python build.py --mode sim
Also emits register-map.csv (the PLC-side point list) from the
same source of truth as the ST, so CI Server tag configuration and the
PLC cannot drift apart (brief section 9).
"""
import argparse
import csv
import datetime
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
BUILD = ROOT / "build"
# The register map is the PLC-side point list and the upstream half of
# the Modbus contract. The SCADA-side point list is a separate,
# differently shaped file kept under 04-scada/modbus/ and derived from
# this one - never the other way round.
PLC_DIR = ROOT # register-map.csv sits beside this script
# Files excluded per mode. Everything else in src/ is concatenated.
EXCLUDE = {
"field": {"40_prog_simulation.st", "91_config_sim.st"},
"sim": {"90_config_field.st"},
}
# Files that must exist for a mode to build. Listed explicitly rather
# than globbed so that a missing file fails loudly instead of silently
# emitting a short file (brief section 1).
REQUIRED = {
"field": [
"10_globals.st",
"20_fb_pump.st",
"21_fb_duty_selector.st",
"22_fb_level_control.st",
"23_fb_headroom.st",
"30_prog_control.st",
"50_prog_io_mux.st",
"90_config_field.st",
],
"sim": [
"10_globals.st",
"20_fb_pump.st",
"21_fb_duty_selector.st",
"22_fb_level_control.st",
"23_fb_headroom.st",
"30_prog_control.st",
"40_prog_simulation.st",
"50_prog_io_mux.st",
"91_config_sim.st",
],
}
# Only these files may reference located variables (brief sections 8.1, 10).
LOCATED_ALLOWED = {"10_globals.st", "50_prog_io_mux.st"}
LOCATED_TOKENS = ("%I", "%Q", "%M")
# ---------------------------------------------------------------------
# Register map - the single source of truth for section 2.
#
# IMPORTANT: on OpenPLC Runtime v4 the Modbus slave segments holding
# registers. %QW occupies holding registers 0-1023 and %MW starts at
# 1024, so %MW0 is holding register 1024. This offset is applied in
# modbus_address() below and is the reason this file is generated
# rather than hand-maintained.
# ---------------------------------------------------------------------
MW_HR_OFFSET = 1024
# (iec, tag, description, units, scaling, access)
REGISTERS = [
# --- 2.1 field inputs -------------------------------------------
("%IW0", "LIT-101", "Wet well level", "mm", "1", "R"),
("%IW1", "FIT-201", "Inlet flow", "L/s", "x10", "R"),
("%IW2", "FIT-301", "Discharge flow", "L/s", "x10", "R"),
("%IW3", "PIT-302", "Manifold pressure", "kPa", "1", "R"),
("%IW4", "PIT-311", "PU-301 discharge pressure", "kPa", "1", "R"),
("%IW5", "PIT-321", "PU-302 discharge pressure", "kPa", "1", "R"),
("%IW6", "PIT-331", "PU-303 discharge pressure", "kPa", "1", "R"),
("%IW7", "VE-314", "PU-301 bearing vibration", "mm/s", "x10", "R"),
("%IW8", "VE-324", "PU-302 bearing vibration", "mm/s", "x10", "R"),
("%IW9", "VE-334", "PU-303 bearing vibration", "mm/s", "x10", "R"),
("%IX0.0", "LSHH-102", "High high level (TRUE = wet)", "", "", "R"),
("%IX0.1", "LSLL-103", "Low low level (TRUE = wet, FALSE = dry)", "", "", "R"),
("%IX0.2", "LSH-104", "Spill detected (TRUE = spilling)", "", "", "R"),
("%IX0.3", "TE-312", "PU-301 motor thermal (TRUE = healthy)", "", "", "R"),
("%IX0.4", "TE-322", "PU-302 motor thermal (TRUE = healthy)", "", "", "R"),
("%IX0.5", "TE-332", "PU-303 motor thermal (TRUE = healthy)", "", "", "R"),
("%IX0.6", "MSE-313", "PU-301 seal leak (TRUE = leak)", "", "", "R"),
("%IX0.7", "MSE-323", "PU-302 seal leak (TRUE = leak)", "", "", "R"),
("%IX1.0", "MSE-333", "PU-303 seal leak (TRUE = leak)", "", "", "R"),
("%IX1.1", "XA-502", "Mains healthy (TRUE = healthy)", "", "", "R"),
# --- 2.2 PLC outputs --------------------------------------------
("%QX0.0", "PU-301", "Run command", "", "", "R"),
("%QX0.1", "PU-302", "Run command", "", "", "R"),
("%QX0.2", "PU-303", "Run command", "", "", "R"),
("%QX0.3", "PU-301", "Running", "", "", "R"),
("%QX0.4", "PU-302", "Running", "", "", "R"),
("%QX0.5", "PU-303", "Running", "", "", "R"),
("%QX0.6", "PU-301", "Available", "", "", "R"),
("%QX0.7", "PU-302", "Available", "", "", "R"),
("%QX1.0", "PU-303", "Available", "", "", "R"),
("%QX1.1", "STN", "Station in auto", "", "", "R"),
("%QX1.2", "STN", "High level alarm", "", "", "R"),
("%QX1.3", "STN", "Spill active", "", "", "R"),
("%QX1.4", "PU-301", "Tripped", "", "", "R"),
("%QX1.5", "PU-302", "Tripped", "", "", "R"),
("%QX1.6", "PU-303", "Tripped", "", "", "R"),
("%QW0", "STN", "Wet well level", "mm", "1", "R"),
("%QW1", "STN", "Inflow", "L/s", "x10", "R"),
("%QW2", "STN", "Total discharge flow", "L/s", "x10", "R"),
("%QW3", "STN", "Pumps running", "count", "1", "R"),
("%QW4", "STN", "Common drive speed", "Hz", "x10", "R"),
("%QW5", "STN", "Time to spill weir (32767 = drawing down)", "s", "1", "R"),
("%QW6", "STN", "Time to LSHH (32767 = drawing down)", "s", "1", "R"),
("%QW7", "STN", "Net accumulation (signed)", "L/s", "x10", "R"),
("%QW8", "PU-301", "Run hours", "h", "1", "R"),
("%QW9", "PU-302", "Run hours", "h", "1", "R"),
("%QW10", "PU-303", "Run hours", "h", "1", "R"),
("%QW11", "STN", "Volume remaining to spill", "m3", "1", "R"),
("%QW12", "STN", "Station state (enum 3.1)", "", "1", "R"),
("%QW13", "PU-301", "Pump state (enum 3.2)", "", "1", "R"),
("%QW14", "PU-302", "Pump state (enum 3.2)", "", "1", "R"),
("%QW15", "PU-303", "Pump state (enum 3.2)", "", "1", "R"),
("%QW16", "STN", "Current duty pump (0 = none, 1-3)", "", "1", "R"),
("%QW17", "STN", "Alarm bitmask (section 6) - READ AS UNSIGNED", "", "1", "R"),
("%QW20", "STN", "Command acknowledge (echoes %MW1)", "", "1", "R"),
# --- 2.3 commands and setpoints ---------------------------------
("%MW0", "STN", "Station mode: 1 = auto, 2 = off", "", "1", "RW"),
("%MW1", "STN", "Command word (section 3.3)", "", "1", "RW"),
("%MW2", "STN", "Command parameter (pump number)", "", "1", "RW"),
("%MW3", "STN", "Level control setpoint", "mm", "1", "RW"),
("%MW4", "STN", "Start duty level", "mm", "1", "RW"),
("%MW5", "STN", "Start pump 2 level", "mm", "1", "RW"),
("%MW6", "STN", "Start pump 3 level", "mm", "1", "RW"),
("%MW7", "STN", "Stop all level", "mm", "1", "RW"),
("%MW8", "STN", "High level alarm", "mm", "1", "RW"),
("%MW9", "STN", "Minimum drive speed", "Hz", "x10", "RW"),
("%MW10", "STN", "Service interval", "h", "1", "RW"),
# --- 8.2 simulation control (simulation build only) --------------
("%MW20", "SIM", "SIM ONLY: manual inflow (mode 0)", "L/s", "x10", "RW"),
("%MW21", "SIM", "SIM ONLY: scenario 0=man 1=diurnal 2=wet 3=ref", "", "1", "RW"),
("%MW22", "SIM", "SIM ONLY: write 1 to reset scenario, self-clearing", "", "1", "RW"),
("%MW23", "SIM", "SIM ONLY: time scale 1-120", "x", "1", "RW"),
]
def modbus_address(iec):
"""Map an IEC located address to (object type, Modbus address).
Reflects the v4 simple_modbus segmented data blocks, not v3.
"""
body = iec[2:]
kind = iec[:2]
if kind == "%I":
if body[0] == "W":
return "Input register", int(body[1:])
byte, bit = body[1:].split(".")
return "Discrete input", int(byte) * 8 + int(bit)
if kind == "%Q":
if body[0] == "W":
return "Holding register", int(body[1:])
byte, bit = body[1:].split(".")
return "Coil", int(byte) * 8 + int(bit)
if kind == "%M":
return "Holding register", MW_HR_OFFSET + int(body[1:])
raise ValueError(f"unrecognised located address: {iec}")
def git_hash():
try:
out = subprocess.run(
["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=True,
)
return out.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return "nogit"
def strip_comments(text):
"""Blank out (* ... *) comment spans, preserving line structure.
Line structure is preserved so that reported line numbers still
match the source file. ST block comments do not nest.
"""
out = []
depth = 0
i = 0
while i < len(text):
if text.startswith("(*", i):
depth += 1
out.append(" ")
i += 2
elif text.startswith("*)", i) and depth:
depth -= 1
out.append(" ")
i += 2
else:
ch = text[i]
out.append(ch if (depth == 0 or ch == "\n") else " ")
i += 1
return "".join(out)
def check_located(files):
"""Enforce the section 10 grep check as a build step.
Comments are stripped first: the brief bans located variables in
the code, not in the commentary explaining the register map.
"""
offenders = []
for path in files:
if path.name in LOCATED_ALLOWED:
continue
code = strip_comments(path.read_text(encoding="utf-8"))
for n, line in enumerate(code.splitlines(), 1):
if any(tok in line for tok in LOCATED_TOKENS):
offenders.append(f" {path.name}:{n}: {line.strip()}")
if offenders:
sys.exit(
"BUILD FAILED: located variable referenced outside "
+ " / ".join(sorted(LOCATED_ALLOWED))
+ "\n"
+ "\n".join(offenders)
)
def write_register_map():
PLC_DIR.mkdir(exist_ok=True)
out = PLC_DIR / "register-map.csv"
with out.open("w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(
[
"iec_address",
"modbus_object",
"modbus_address",
"tag",
"description",
"units",
"scaling",
"access",
]
)
for iec, tag, desc, units, scaling, access in REGISTERS:
obj, addr = modbus_address(iec)
w.writerow([iec, obj, addr, tag, desc, units, scaling, access])
return out
def main():
ap = argparse.ArgumentParser(description="Build WRPS-CTL-002 flat ST.")
ap.add_argument("--mode", choices=("field", "sim"), required=True)
args = ap.parse_args()
missing = [n for n in REQUIRED[args.mode] if not (SRC / n).is_file()]
if missing:
sys.exit(
f"BUILD FAILED: missing source file(s) for mode '{args.mode}':\n"
+ "\n".join(f" src/{n}" for n in missing)
)
# Lexical order by filename, per brief section 1.
files = sorted(
(p for p in SRC.glob("*.st") if p.name not in EXCLUDE[args.mode]),
key=lambda p: p.name,
)
if not files:
sys.exit("BUILD FAILED: no source files found in src/")
check_located(files)
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
header = [
"(* =====================================================================",
" WRPS-CTL-002 Waterloo Road Pump Station",
" GENERATED FILE - do not edit. Edit src/ and rebuild.",
"",
f" mode : {args.mode}",
f" git : {git_hash()}",
f" generated : {stamp}",
f" sources : {', '.join(p.name for p in files)}",
" ===================================================================== *)",
"",
]
parts = ["\n".join(header)]
for p in files:
parts.append(f"\n(* ---- {p.name} ---- *)\n")
parts.append(p.read_text(encoding="utf-8"))
BUILD.mkdir(exist_ok=True)
out = BUILD / "wrps.st"
out.write_text("\n".join(parts), encoding="utf-8")
regmap = write_register_map()
print(f"OK {out} ({out.stat().st_size} bytes, {len(files)} sources)")
print(f"OK {regmap} ({len(REGISTERS)} registers)")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,52 @@
# Editor `devices/` — reference copy, NOT generated
These files are the **hand-made** half of the OpenPLC Editor project. Everything
else in the project (`project.json`, `pous/`) is generated from `src/` by
`../gen_project.py`; `devices/` is not, because it is configured through GUI
dialogs and holds machine-specific settings.
They live here so the setup is not lost when the Editor project is recreated —
which it will have to be, because the machine that held it has been retired. See
`../DEPLOY.md` §0.
**This is a reference copy, not the live one.** `DEPLOY.md` §A is the procedure
for reproducing it through the GUI; these files show what the result should look
like.
Captured 2026-08-14, from the working setup that first served Modbus data.
| File | What it is |
|---|---|
| `configuration.json` | Device board (`OpenPLC Runtime v4`) and runtime address |
| `pin-mapping.json` | Board pin map — Editor default, unused by this program |
| `servers/Modbus01.json` | The Modbus TCP **server** definition — the file that enables the runtime's `modbus_slave` plugin |
## Two things to know before reusing these
**`configuration.json` says `localhost`, and that is now wrong.** It was correct
when the Editor and the runtime were on the same host. The runtime is now on
`yau-sls-poc-lin001`, so a new Editor installation must point at **`10.0.0.17`**,
port `8443`.
**`servers/Modbus01.json` says `networkInterface: 0.0.0.0`, and that is correct —
leave it alone.** It looks like it contradicts the container publishing port 502
on `10.0.0.17` only, but the two settings operate at different layers:
| Layer | Setting | Effect |
|---|---|---|
| PLC runtime, **inside** the container | `0.0.0.0:502` | the Modbus slave listens on all of the container's own interfaces |
| Docker, **outside** the container | `10.0.0.17:502:502` | only the host's LAN address forwards to it |
Narrowing the runtime's own bind address gains nothing and risks the slave not
listening at all. The **Docker publish** is the security control. See
`../../02-environment/`.
## What is deliberately absent
There is **no `remote/` directory**. Remote Devices is the Modbus *master*
configuration — it would make the PLC poll someone else, inverting the
architecture invariant, and its IO groups claim `%IW` addresses that collide with
the field inputs in `src/10_globals.st`.
CI Server needs no entry in the PLC project at all. It is the client; it simply
connects.

View file

@ -0,0 +1,11 @@
{
"deviceBoard": "OpenPLC Runtime v4",
"communicationPort": "",
"runtimeIpAddress": "localhost",
"vendorScreenData": {},
"vendorScreenDataByBoard": {
"OpenPLC Simulator": {},
"OpenPLC Runtime v4": {}
},
"selectedPlatformOptions": {}
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,9 @@
{
"name": "Modbus01",
"protocol": "modbus-tcp",
"modbusSlaveConfig": {
"enabled": true,
"networkInterface": "0.0.0.0",
"port": 502
}
}

351
03-plc/gen_project.py Normal file
View file

@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""Generate an OpenPLC Editor v4 project from src/*.st.
python gen_project.py --out editor-project
The Editor v4 project is a *folder*, not a file:
project.json meta + configuration (tasks, instances, globals)
pous/programs/*.st one file per PROGRAM
pous/function-blocks/*.st
pous/functions/*.st
There is no "import a flat .st" path in the Editor, so this script turns
the repo's ST sources into that layout. The repo stays canonical; the
generated project is a build artefact and is git-ignored.
What it does NOT touch, because the user configures them in the GUI:
devices/configuration.json (runtime address, credentials) and build/.
Copy only project.json and pous/ over an existing project.
Schemas below were recovered from the Editor bundle
(resources/app.asar, v4.2.11), not guessed:
scalar : {"definition": "base-type", "value": "INT"}
array : {"definition": "array",
"value": "ARRAY[1..3] OF REAL",
"data": {"baseType": {"definition": "base-type", "value": "REAL"},
"dimensions": [{"dimension": "1..3"}]}}
class : one of input|output|inOut|external|local|temp|global
"""
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
GLOBALS_FILE = "10_globals.st"
CONFIG_FILE = {"field": "90_config_field.st", "sim": "91_config_sim.st"}
# src file -> POU folder. Anything not listed is not a POU.
POU_FOLDER = {
"20_fb_pump.st": "function-blocks",
"21_fb_duty_selector.st": "function-blocks",
"22_fb_level_control.st": "function-blocks",
"23_fb_headroom.st": "function-blocks",
"30_prog_control.st": "programs",
"50_prog_io_mux.st": "programs",
}
# POUs added only in a given mode. Mirrors build.py's EXCLUDE.
MODE_ONLY = {
"field": {},
"sim": {"40_prog_simulation.st": "programs"},
}
POU_HEADER = re.compile(
r"^\s*(FUNCTION_BLOCK|PROGRAM|FUNCTION)\s+([A-Za-z_][A-Za-z0-9_]*)",
re.MULTILINE,
)
# NAME [AT %LOC] : TYPE [:= INIT] ;
VAR_DECL = re.compile(
r"""^\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*
(?:AT\s+(?P<loc>%[A-Za-z]+[0-9.]+)\s*)?
:\s*(?P<type>.+?)
(?:\s*:=\s*(?P<init>.+?))?
\s*;""",
re.VERBOSE,
)
ARRAY_TYPE = re.compile(
r"^ARRAY\s*\[\s*(?P<dims>[^\]]+?)\s*\]\s*OF\s+(?P<base>[A-Za-z_][A-Za-z0-9_]*)$",
re.IGNORECASE,
)
COMMENT = re.compile(r"\(\*.*?\*\)", re.DOTALL)
# Declaration sections the Editor parses separately from the body.
VAR_BLOCK = re.compile(
r"(?P<head>^[ \t]*VAR(?:_INPUT|_OUTPUT|_IN_OUT|_TEMP|_EXTERNAL)?\b[^\n]*\n)"
r"(?P<body>.*?)"
r"(?P<tail>^[ \t]*END_VAR)",
re.DOTALL | re.MULTILINE | re.IGNORECASE,
)
def strip_comments(text):
"""Remove (* ... *) spans, preserving line count for error messages."""
return COMMENT.sub(lambda m: " " * len(m.group(0).replace("\n", "")) +
"\n" * m.group(0).count("\n"), text)
def sanitise_declarations(text):
"""Make VAR blocks digestible by the Editor's declaration parser.
The Editor (4.2.11) parses each POU's declaration sections with a
parser separate from the body compiler, and that parser rejects:
- comment-only lines inside a VAR block, e.g. "(* process image *)"
- a (* ... *) comment that wraps onto the following line
- more than one declaration on a line, e.g. "A : INT; B : INT;"
A trailing single-line comment after a declaration is fine. So:
fold multi-line comments onto one line, drop comment-only lines, and
put each declaration on its own line. Bodies are untouched, and
src/ keeps its original formatting only the generated project is
reflowed.
Returns (text, dropped_line_count, split_count).
"""
dropped = 0
split = 0
def fix_block(m):
nonlocal dropped, split
body = m.group("body")
# Fold any comment spanning lines onto a single line.
body = COMMENT.sub(lambda c: " ".join(c.group(0).split()), body)
kept = []
for line in body.splitlines():
stripped = line.strip()
if not stripped:
kept.append(line)
continue
if COMMENT.sub("", stripped).strip() == "":
dropped += 1 # comment-only line
continue
# One declaration per line. Split on ';', keeping it, and
# re-attach anything trailing the last one (a comment).
decls = re.findall(r"[^;]+;", stripped)
if len(decls) > 1:
indent = line[: len(line) - len(line.lstrip())]
trailer = stripped[sum(len(d) for d in decls):].strip()
if trailer:
decls[-1] = decls[-1] + " " + trailer
kept.extend(indent + d.strip() for d in decls)
split += len(decls) - 1
else:
kept.append(line)
return m.group("head") + "\n".join(kept) + "\n" + m.group("tail")
return VAR_BLOCK.sub(fix_block, text), dropped, split
def parse_globals(text):
"""Return the VAR_GLOBAL declarations as Editor globalVariables entries.
CONSTANT blocks are flagged: the Editor's class enum has no
'constant', so those become ordinary globals with an initial value.
"""
code = strip_comments(text)
entries = []
constants = []
for block in re.finditer(
r"VAR_GLOBAL(?P<qual>\s+CONSTANT)?(?P<body>.*?)END_VAR",
code,
re.DOTALL | re.IGNORECASE,
):
is_const = bool(block.group("qual"))
for line in block.group("body").splitlines():
if not line.strip():
continue
m = VAR_DECL.match(line)
if not m:
sys.exit(f"GEN FAILED: cannot parse global declaration:\n {line.strip()}")
name = m.group("name")
raw_type = m.group("type").strip()
init = (m.group("init") or "").strip()
arr = ARRAY_TYPE.match(raw_type)
if arr:
base = arr.group("base").upper()
dims = [d.strip() for d in arr.group("dims").split(",")]
type_obj = {
"definition": "array",
"value": f"ARRAY[{','.join(dims)}] OF {base}",
"data": {
"baseType": {"definition": "base-type", "value": base},
"dimensions": [{"dimension": d} for d in dims],
},
}
else:
type_obj = {"definition": "base-type", "value": raw_type.upper()}
entries.append(
{
"name": name,
"type": type_obj,
"class": "global",
"location": m.group("loc") or "",
"documentation": "",
"debug": False,
"initialValue": init,
}
)
if is_const:
constants.append(name)
return entries, constants
def parse_config(text):
"""Extract tasks and program instances from the CONFIGURATION file."""
code = strip_comments(text)
tasks = []
for m in re.finditer(
r"TASK\s+(?P<name>\w+)\s*\(\s*INTERVAL\s*:=\s*(?P<interval>[^,)]+)"
r"(?:\s*,\s*PRIORITY\s*:=\s*(?P<prio>\d+))?\s*\)",
code,
re.IGNORECASE,
):
tasks.append(
{
"name": m.group("name"),
"triggering": "Cyclic",
"interval": m.group("interval").strip(),
"priority": int(m.group("prio") or 0),
}
)
instances = []
for m in re.finditer(
r"PROGRAM\s+(?P<inst>\w+)\s+WITH\s+(?P<task>\w+)\s*:\s*(?P<prog>\w+)\s*;",
code,
re.IGNORECASE,
):
instances.append(
{
"name": m.group("inst"),
"program": m.group("prog"),
"task": m.group("task"),
}
)
if not tasks:
sys.exit(f"GEN FAILED: no TASK found in {config_file}")
if not instances:
sys.exit(f"GEN FAILED: no PROGRAM instance found in {config_file}")
return tasks, instances
def main():
ap = argparse.ArgumentParser(description="Generate an OpenPLC Editor v4 project.")
ap.add_argument("--out", default="editor-project", help="output directory")
ap.add_argument("--name", default="wrps", help="project name")
ap.add_argument(
"--mode",
choices=("field", "sim"),
default="field",
help="field = control only; sim = control plus PROGRAM SIMULATION",
)
args = ap.parse_args()
out = (ROOT / args.out).resolve()
config_file = CONFIG_FILE[args.mode]
pou_folder = {**POU_FOLDER, **MODE_ONLY[args.mode]}
missing = [n for n in [GLOBALS_FILE, config_file, *pou_folder] if not (SRC / n).is_file()]
if missing:
sys.exit("GEN FAILED: missing source file(s):\n" + "\n".join(f" src/{n}" for n in missing))
globals_entries, constants = parse_globals((SRC / GLOBALS_FILE).read_text(encoding="utf-8"))
tasks, instances = parse_config((SRC / config_file).read_text(encoding="utf-8"))
# --- POU files -----------------------------------------------------
if out.exists():
shutil.rmtree(out)
for folder in ("programs", "function-blocks", "functions"):
(out / "pous" / folder).mkdir(parents=True)
pou_names = []
total_dropped = total_split = 0
for filename, folder in pou_folder.items():
text = (SRC / filename).read_text(encoding="utf-8")
text, dropped, split = sanitise_declarations(text)
total_dropped += dropped
total_split += split
m = POU_HEADER.search(strip_comments(text))
if not m:
sys.exit(f"GEN FAILED: no POU header found in src/{filename}")
kind, name = m.group(1).upper(), m.group(2)
expected = "programs" if kind == "PROGRAM" else (
"function-blocks" if kind == "FUNCTION_BLOCK" else "functions")
if expected != folder:
sys.exit(
f"GEN FAILED: src/{filename} declares {kind} {name} but is mapped "
f"to pous/{folder}/ — fix POU_FOLDER."
)
(out / "pous" / folder / f"{name}.st").write_text(text, encoding="utf-8")
pou_names.append(f"{name} ({folder})")
# Every instance must name a program we actually emitted.
programs = {p.split(" ")[0] for p in pou_names if "(programs)" in p}
for inst in instances:
if inst["program"] not in programs:
sys.exit(
f"GEN FAILED: configuration instance '{inst['name']}' references program "
f"'{inst['program']}', which is not among the generated programs: "
f"{sorted(programs)}"
)
project = {
"meta": {"name": args.name, "type": "plc-project"},
"data": {
"dataTypes": [],
"pous": [], # the Editor discovers POUs from pous/, verified 2026-08-13
"configuration": {
"resource": {
"tasks": tasks,
"instances": instances,
"globalVariables": globals_entries,
}
},
"libraries": [],
},
}
(out / "project.json").write_text(json.dumps(project, indent=2) + "\n", encoding="utf-8")
located = sum(1 for g in globals_entries if g["location"])
print(f"OK {out}")
print(f"OK {len(pou_names)} POUs: {', '.join(pou_names)}")
print(f"OK {len(globals_entries)} globals ({located} located)")
print(
f"OK declaration sections sanitised for the Editor parser "
f"({total_dropped} comment-only lines dropped, {total_split} declarations "
f"split onto their own line; src/ unchanged)"
)
print(f"OK tasks: {[(t['name'], t['interval']) for t in tasks]}")
print(f"OK instances: {[(i['name'], i['program']) for i in instances]}")
if constants:
print(
f"WARN {len(constants)} VAR_GLOBAL CONSTANT entries emitted as ordinary "
f"globals — the Editor's class enum has no 'constant'.\n"
f" They keep their initial values and no POU writes them, so behaviour "
f"is unchanged, but they are no longer compiler-enforced read-only:\n"
f" {', '.join(constants)}"
)
if __name__ == "__main__":
main()

70
03-plc/register-map.csv Normal file
View file

@ -0,0 +1,70 @@
iec_address,modbus_object,modbus_address,tag,description,units,scaling,access
%IW0,Input register,0,LIT-101,Wet well level,mm,1,R
%IW1,Input register,1,FIT-201,Inlet flow,L/s,x10,R
%IW2,Input register,2,FIT-301,Discharge flow,L/s,x10,R
%IW3,Input register,3,PIT-302,Manifold pressure,kPa,1,R
%IW4,Input register,4,PIT-311,PU-301 discharge pressure,kPa,1,R
%IW5,Input register,5,PIT-321,PU-302 discharge pressure,kPa,1,R
%IW6,Input register,6,PIT-331,PU-303 discharge pressure,kPa,1,R
%IW7,Input register,7,VE-314,PU-301 bearing vibration,mm/s,x10,R
%IW8,Input register,8,VE-324,PU-302 bearing vibration,mm/s,x10,R
%IW9,Input register,9,VE-334,PU-303 bearing vibration,mm/s,x10,R
%IX0.0,Discrete input,0,LSHH-102,High high level (TRUE = wet),,,R
%IX0.1,Discrete input,1,LSLL-103,"Low low level (TRUE = wet, FALSE = dry)",,,R
%IX0.2,Discrete input,2,LSH-104,Spill detected (TRUE = spilling),,,R
%IX0.3,Discrete input,3,TE-312,PU-301 motor thermal (TRUE = healthy),,,R
%IX0.4,Discrete input,4,TE-322,PU-302 motor thermal (TRUE = healthy),,,R
%IX0.5,Discrete input,5,TE-332,PU-303 motor thermal (TRUE = healthy),,,R
%IX0.6,Discrete input,6,MSE-313,PU-301 seal leak (TRUE = leak),,,R
%IX0.7,Discrete input,7,MSE-323,PU-302 seal leak (TRUE = leak),,,R
%IX1.0,Discrete input,8,MSE-333,PU-303 seal leak (TRUE = leak),,,R
%IX1.1,Discrete input,9,XA-502,Mains healthy (TRUE = healthy),,,R
%QX0.0,Coil,0,PU-301,Run command,,,R
%QX0.1,Coil,1,PU-302,Run command,,,R
%QX0.2,Coil,2,PU-303,Run command,,,R
%QX0.3,Coil,3,PU-301,Running,,,R
%QX0.4,Coil,4,PU-302,Running,,,R
%QX0.5,Coil,5,PU-303,Running,,,R
%QX0.6,Coil,6,PU-301,Available,,,R
%QX0.7,Coil,7,PU-302,Available,,,R
%QX1.0,Coil,8,PU-303,Available,,,R
%QX1.1,Coil,9,STN,Station in auto,,,R
%QX1.2,Coil,10,STN,High level alarm,,,R
%QX1.3,Coil,11,STN,Spill active,,,R
%QX1.4,Coil,12,PU-301,Tripped,,,R
%QX1.5,Coil,13,PU-302,Tripped,,,R
%QX1.6,Coil,14,PU-303,Tripped,,,R
%QW0,Holding register,0,STN,Wet well level,mm,1,R
%QW1,Holding register,1,STN,Inflow,L/s,x10,R
%QW2,Holding register,2,STN,Total discharge flow,L/s,x10,R
%QW3,Holding register,3,STN,Pumps running,count,1,R
%QW4,Holding register,4,STN,Common drive speed,Hz,x10,R
%QW5,Holding register,5,STN,Time to spill weir (32767 = drawing down),s,1,R
%QW6,Holding register,6,STN,Time to LSHH (32767 = drawing down),s,1,R
%QW7,Holding register,7,STN,Net accumulation (signed),L/s,x10,R
%QW8,Holding register,8,PU-301,Run hours,h,1,R
%QW9,Holding register,9,PU-302,Run hours,h,1,R
%QW10,Holding register,10,PU-303,Run hours,h,1,R
%QW11,Holding register,11,STN,Volume remaining to spill,m3,1,R
%QW12,Holding register,12,STN,Station state (enum 3.1),,1,R
%QW13,Holding register,13,PU-301,Pump state (enum 3.2),,1,R
%QW14,Holding register,14,PU-302,Pump state (enum 3.2),,1,R
%QW15,Holding register,15,PU-303,Pump state (enum 3.2),,1,R
%QW16,Holding register,16,STN,"Current duty pump (0 = none, 1-3)",,1,R
%QW17,Holding register,17,STN,Alarm bitmask (section 6) - READ AS UNSIGNED,,1,R
%QW20,Holding register,20,STN,Command acknowledge (echoes %MW1),,1,R
%MW0,Holding register,1024,STN,"Station mode: 1 = auto, 2 = off",,1,RW
%MW1,Holding register,1025,STN,Command word (section 3.3),,1,RW
%MW2,Holding register,1026,STN,Command parameter (pump number),,1,RW
%MW3,Holding register,1027,STN,Level control setpoint,mm,1,RW
%MW4,Holding register,1028,STN,Start duty level,mm,1,RW
%MW5,Holding register,1029,STN,Start pump 2 level,mm,1,RW
%MW6,Holding register,1030,STN,Start pump 3 level,mm,1,RW
%MW7,Holding register,1031,STN,Stop all level,mm,1,RW
%MW8,Holding register,1032,STN,High level alarm,mm,1,RW
%MW9,Holding register,1033,STN,Minimum drive speed,Hz,x10,RW
%MW10,Holding register,1034,STN,Service interval,h,1,RW
%MW20,Holding register,1044,SIM,SIM ONLY: manual inflow (mode 0),L/s,x10,RW
%MW21,Holding register,1045,SIM,SIM ONLY: scenario 0=man 1=diurnal 2=wet 3=ref,,1,RW
%MW22,Holding register,1046,SIM,"SIM ONLY: write 1 to reset scenario, self-clearing",,1,RW
%MW23,Holding register,1047,SIM,SIM ONLY: time scale 1-120,x,1,RW
1 iec_address modbus_object modbus_address tag description units scaling access
2 %IW0 Input register 0 LIT-101 Wet well level mm 1 R
3 %IW1 Input register 1 FIT-201 Inlet flow L/s x10 R
4 %IW2 Input register 2 FIT-301 Discharge flow L/s x10 R
5 %IW3 Input register 3 PIT-302 Manifold pressure kPa 1 R
6 %IW4 Input register 4 PIT-311 PU-301 discharge pressure kPa 1 R
7 %IW5 Input register 5 PIT-321 PU-302 discharge pressure kPa 1 R
8 %IW6 Input register 6 PIT-331 PU-303 discharge pressure kPa 1 R
9 %IW7 Input register 7 VE-314 PU-301 bearing vibration mm/s x10 R
10 %IW8 Input register 8 VE-324 PU-302 bearing vibration mm/s x10 R
11 %IW9 Input register 9 VE-334 PU-303 bearing vibration mm/s x10 R
12 %IX0.0 Discrete input 0 LSHH-102 High high level (TRUE = wet) R
13 %IX0.1 Discrete input 1 LSLL-103 Low low level (TRUE = wet, FALSE = dry) R
14 %IX0.2 Discrete input 2 LSH-104 Spill detected (TRUE = spilling) R
15 %IX0.3 Discrete input 3 TE-312 PU-301 motor thermal (TRUE = healthy) R
16 %IX0.4 Discrete input 4 TE-322 PU-302 motor thermal (TRUE = healthy) R
17 %IX0.5 Discrete input 5 TE-332 PU-303 motor thermal (TRUE = healthy) R
18 %IX0.6 Discrete input 6 MSE-313 PU-301 seal leak (TRUE = leak) R
19 %IX0.7 Discrete input 7 MSE-323 PU-302 seal leak (TRUE = leak) R
20 %IX1.0 Discrete input 8 MSE-333 PU-303 seal leak (TRUE = leak) R
21 %IX1.1 Discrete input 9 XA-502 Mains healthy (TRUE = healthy) R
22 %QX0.0 Coil 0 PU-301 Run command R
23 %QX0.1 Coil 1 PU-302 Run command R
24 %QX0.2 Coil 2 PU-303 Run command R
25 %QX0.3 Coil 3 PU-301 Running R
26 %QX0.4 Coil 4 PU-302 Running R
27 %QX0.5 Coil 5 PU-303 Running R
28 %QX0.6 Coil 6 PU-301 Available R
29 %QX0.7 Coil 7 PU-302 Available R
30 %QX1.0 Coil 8 PU-303 Available R
31 %QX1.1 Coil 9 STN Station in auto R
32 %QX1.2 Coil 10 STN High level alarm R
33 %QX1.3 Coil 11 STN Spill active R
34 %QX1.4 Coil 12 PU-301 Tripped R
35 %QX1.5 Coil 13 PU-302 Tripped R
36 %QX1.6 Coil 14 PU-303 Tripped R
37 %QW0 Holding register 0 STN Wet well level mm 1 R
38 %QW1 Holding register 1 STN Inflow L/s x10 R
39 %QW2 Holding register 2 STN Total discharge flow L/s x10 R
40 %QW3 Holding register 3 STN Pumps running count 1 R
41 %QW4 Holding register 4 STN Common drive speed Hz x10 R
42 %QW5 Holding register 5 STN Time to spill weir (32767 = drawing down) s 1 R
43 %QW6 Holding register 6 STN Time to LSHH (32767 = drawing down) s 1 R
44 %QW7 Holding register 7 STN Net accumulation (signed) L/s x10 R
45 %QW8 Holding register 8 PU-301 Run hours h 1 R
46 %QW9 Holding register 9 PU-302 Run hours h 1 R
47 %QW10 Holding register 10 PU-303 Run hours h 1 R
48 %QW11 Holding register 11 STN Volume remaining to spill m3 1 R
49 %QW12 Holding register 12 STN Station state (enum 3.1) 1 R
50 %QW13 Holding register 13 PU-301 Pump state (enum 3.2) 1 R
51 %QW14 Holding register 14 PU-302 Pump state (enum 3.2) 1 R
52 %QW15 Holding register 15 PU-303 Pump state (enum 3.2) 1 R
53 %QW16 Holding register 16 STN Current duty pump (0 = none, 1-3) 1 R
54 %QW17 Holding register 17 STN Alarm bitmask (section 6) - READ AS UNSIGNED 1 R
55 %QW20 Holding register 20 STN Command acknowledge (echoes %MW1) 1 R
56 %MW0 Holding register 1024 STN Station mode: 1 = auto, 2 = off 1 RW
57 %MW1 Holding register 1025 STN Command word (section 3.3) 1 RW
58 %MW2 Holding register 1026 STN Command parameter (pump number) 1 RW
59 %MW3 Holding register 1027 STN Level control setpoint mm 1 RW
60 %MW4 Holding register 1028 STN Start duty level mm 1 RW
61 %MW5 Holding register 1029 STN Start pump 2 level mm 1 RW
62 %MW6 Holding register 1030 STN Start pump 3 level mm 1 RW
63 %MW7 Holding register 1031 STN Stop all level mm 1 RW
64 %MW8 Holding register 1032 STN High level alarm mm 1 RW
65 %MW9 Holding register 1033 STN Minimum drive speed Hz x10 RW
66 %MW10 Holding register 1034 STN Service interval h 1 RW
67 %MW20 Holding register 1044 SIM SIM ONLY: manual inflow (mode 0) L/s x10 RW
68 %MW21 Holding register 1045 SIM SIM ONLY: scenario 0=man 1=diurnal 2=wet 3=ref 1 RW
69 %MW22 Holding register 1046 SIM SIM ONLY: write 1 to reset scenario, self-clearing 1 RW
70 %MW23 Holding register 1047 SIM SIM ONLY: time scale 1-120 x 1 RW

252
03-plc/src/10_globals.st Normal file
View file

@ -0,0 +1,252 @@
(* =====================================================================
WRPS-CTL-003 Waterloo Road Pump Station
10_globals.st - constants, located I/O, process image globals
This file and 50_prog_io_mux.st are the ONLY files permitted to
reference located variables (%I*, %Q*, %M*). See brief section 10.
===================================================================== *)
VAR_GLOBAL CONSTANT
(* Plant geometry - WRPS-PRO-001 *)
CFG_AREA_M2 : REAL := 120.0; (* wet well plan area, m2 *)
CFG_SPILL_M : REAL := 6.000; (* spill weir level, m *)
CFG_LSHH_M : REAL := 5.500; (* LSHH level, m *)
(* Drive limits - hard physical limits, section 4.3 *)
CFG_MIN_HZ : REAL := 38.0;
CFG_MAX_HZ : REAL := 50.0;
(* Setpoint defaults, section 2.3. Seeded into %MW at first scan. *)
DEF_MODE : INT := 1; (* 1 = auto, 2 = off *)
DEF_SP_LEVEL : INT := 4200; (* mm *)
DEF_START_DUTY : INT := 4000;
DEF_START_P2 : INT := 4500;
DEF_START_P3 : INT := 5000;
DEF_STOP_ALL : INT := 1000;
DEF_HIGH_ALARM : INT := 5200;
DEF_MIN_SPEED : INT := 380; (* Hz x 10 *)
DEF_SERVICE_HRS : INT := 4000;
(* Published "meaningless / drawing down" sentinel, section 4.4 *)
CFG_NO_TIME : INT := 32767;
END_VAR
(* ---------------------------------------------------------------------
2.1 Field inputs - %IW / %IX
--------------------------------------------------------------------- *)
VAR_GLOBAL
IW_LIT101 AT %IW0 : INT; (* wet well level, mm 0-7000 *)
IW_FIT201 AT %IW1 : INT; (* inlet flow, L/s x 10 *)
IW_FIT301 AT %IW2 : INT; (* discharge flow, L/s x 10 *)
IW_PIT302 AT %IW3 : INT; (* manifold pressure, kPa *)
IW_PIT311 AT %IW4 : INT; (* pump discharge pressure, kPa *)
IW_PIT321 AT %IW5 : INT;
IW_PIT331 AT %IW6 : INT;
IW_VE314 AT %IW7 : INT; (* bearing vibration, mm/s x 10 *)
IW_VE324 AT %IW8 : INT;
IW_VE334 AT %IW9 : INT;
IX_LSHH102 AT %IX0.0 : BOOL; (* TRUE = wet *)
IX_LSLL103 AT %IX0.1 : BOOL; (* TRUE = wet, FALSE = dry *)
IX_LSH104 AT %IX0.2 : BOOL; (* TRUE = spilling *)
IX_TE312 AT %IX0.3 : BOOL; (* TRUE = healthy *)
IX_TE322 AT %IX0.4 : BOOL;
IX_TE332 AT %IX0.5 : BOOL;
IX_MSE313 AT %IX0.6 : BOOL; (* TRUE = seal leak *)
IX_MSE323 AT %IX0.7 : BOOL;
IX_MSE333 AT %IX1.0 : BOOL;
IX_XA502 AT %IX1.1 : BOOL; (* TRUE = mains healthy *)
END_VAR
(* ---------------------------------------------------------------------
2.2 PLC outputs - %QX / %QW, read by CI Server
--------------------------------------------------------------------- *)
VAR_GLOBAL
QX_RunCmd1 AT %QX0.0 : BOOL;
QX_RunCmd2 AT %QX0.1 : BOOL;
QX_RunCmd3 AT %QX0.2 : BOOL;
QX_Running1 AT %QX0.3 : BOOL;
QX_Running2 AT %QX0.4 : BOOL;
QX_Running3 AT %QX0.5 : BOOL;
QX_Avail1 AT %QX0.6 : BOOL;
QX_Avail2 AT %QX0.7 : BOOL;
QX_Avail3 AT %QX1.0 : BOOL;
QX_InAuto AT %QX1.1 : BOOL;
QX_HighLevel AT %QX1.2 : BOOL;
QX_SpillActive AT %QX1.3 : BOOL;
QX_Tripped1 AT %QX1.4 : BOOL;
QX_Tripped2 AT %QX1.5 : BOOL;
QX_Tripped3 AT %QX1.6 : BOOL;
QW_Level AT %QW0 : INT; (* mm *)
QW_Inflow AT %QW1 : INT; (* L/s x 10 *)
QW_Discharge AT %QW2 : INT; (* L/s x 10 *)
QW_PumpsRunning AT %QW3 : INT;
QW_Speed AT %QW4 : INT; (* Hz x 10 *)
QW_TimeToSpill AT %QW5 : INT; (* s, 32767 = drawing down *)
QW_TimeToLSHH AT %QW6 : INT; (* s, 32767 = drawing down *)
QW_NetAccum AT %QW7 : INT; (* L/s x 10, signed *)
QW_RunHours1 AT %QW8 : INT; (* hours *)
QW_RunHours2 AT %QW9 : INT;
QW_RunHours3 AT %QW10 : INT;
QW_VolToSpill AT %QW11 : INT; (* m3 *)
QW_StationState AT %QW12 : INT; (* enum, section 3.1 *)
QW_PumpState1 AT %QW13 : INT; (* enum, section 3.2 *)
QW_PumpState2 AT %QW14 : INT;
QW_PumpState3 AT %QW15 : INT;
QW_DutyPump AT %QW16 : INT; (* 0 = none, 1-3 *)
QW_AlarmWord AT %QW17 : INT; (* bitmask, section 6, unsigned *)
QW_CmdAck AT %QW20 : INT; (* echoes %MW1 when executed *)
END_VAR
(* ---------------------------------------------------------------------
2.3 Commands and setpoints - %MW, written by CI Server
NOTE: on this runtime %MW is NOT Modbus holding register 0. The
v4 Modbus slave segments holding registers: %QW occupies HR 0-1023
and %MW starts at HR 1024. %MW0 is therefore HR 1024. See
docs/register-map.csv, which carries the offset.
--------------------------------------------------------------------- *)
VAR_GLOBAL
MW_Mode AT %MW0 : INT; (* 1 = auto, 2 = off *)
MW_CmdWord AT %MW1 : INT; (* section 3.3 *)
MW_CmdParam AT %MW2 : INT; (* pump number, etc. *)
MW_SpLevel AT %MW3 : INT; (* level control setpoint, mm *)
MW_StartDuty AT %MW4 : INT;
MW_StartP2 AT %MW5 : INT;
MW_StartP3 AT %MW6 : INT;
MW_StopAll AT %MW7 : INT;
MW_HighAlarm AT %MW8 : INT;
MW_MinSpeed AT %MW9 : INT; (* Hz x 10 *)
MW_ServiceHrs AT %MW10 : INT; (* hours *)
END_VAR
(* ---------------------------------------------------------------------
2.4 Simulation control - %MW20-23, section 8.2
Section 2 calls this block "simulation build only". It is declared
here in both builds instead, because located variables may only be
declared in this file and splitting it would need a second file with
located-variable permission. In the field build these registers are
simply unused: nothing reads them, since PROGRAM SIMULATION is not
part of that build. register-map.csv marks them sim-only.
--------------------------------------------------------------------- *)
VAR_GLOBAL
MW_SimInflow AT %MW20 : INT; (* manual inflow, L/s x 10 *)
MW_SimMode AT %MW21 : INT; (* 0 man, 1 diurnal, 2 wet, 3 ref *)
MW_SimReset AT %MW22 : INT; (* write 1 to reset; self-clears *)
MW_SimTimeScale AT %MW23 : INT; (* 1-120, default 1 *)
END_VAR
(* ---------------------------------------------------------------------
Process image globals.
IO_MUX writes these from the located variables above; CONTROL reads
only these. In the pass 2 simulation build the same globals are
written from PROGRAM SIMULATION instead, and no control POU changes.
--------------------------------------------------------------------- *)
VAR_GLOBAL
g_LevelRaw_mm : INT; (* unconditioned, for fault test *)
g_Level_mm : INT;
g_Level_m : REAL;
g_Inflow_Lps : REAL;
g_Disch_Lps : REAL;
g_ManifoldP_kPa : REAL;
g_PumpP_kPa : ARRAY[1..3] OF REAL;
g_Vib_mms : ARRAY[1..3] OF REAL;
g_LSHH : BOOL; (* TRUE = high high, wet *)
g_LSLL_Wet : BOOL; (* TRUE = wet; FALSE = dry *)
g_SpillDetected : BOOL;
g_ThermalOK : ARRAY[1..3] OF BOOL;
g_SealLeak : ARRAY[1..3] OF BOOL;
g_MainsOK : BOOL;
END_VAR
(* Raw command / setpoint image, copied from %MW by IO_MUX.
CONTROL validates and clamps these before use. *)
VAR_GLOBAL
g_cmd_Mode : INT;
g_cmd_Word : INT;
g_cmd_Param : INT;
g_sp_Level : INT;
g_sp_StartDuty : INT;
g_sp_StartP2 : INT;
g_sp_StartP3 : INT;
g_sp_StopAll : INT;
g_sp_HighAlarm : INT;
g_sp_MinSpeed : INT;
g_sp_ServiceHrs : INT;
END_VAR
(* Published values, written by CONTROL, copied to %QW / %QX by IO_MUX. *)
VAR_GLOBAL
g_o_RunCmd : ARRAY[1..3] OF BOOL;
g_o_Running : ARRAY[1..3] OF BOOL;
g_o_Available : ARRAY[1..3] OF BOOL;
g_o_Tripped : ARRAY[1..3] OF BOOL;
g_o_InAuto : BOOL;
g_o_HighLevel : BOOL;
g_o_SpillActive : BOOL;
g_o_Level_mm : INT;
g_o_Inflow_x10 : INT;
g_o_Disch_x10 : INT;
g_o_PumpsRun : INT;
g_o_Speed_x10 : INT;
g_o_TimeToSpill : INT;
g_o_TimeToLSHH : INT;
g_o_NetAccum : INT;
g_o_RunHours : ARRAY[1..3] OF INT;
g_o_VolToSpill : INT;
g_o_StationState: INT;
g_o_PumpState : ARRAY[1..3] OF INT;
g_o_DutyPump : INT;
g_o_AlarmWord : INT;
g_o_CmdAck : INT;
END_VAR
(* ---------------------------------------------------------------------
Simulation image, section 8.
PROGRAM SIMULATION writes these; IO_MUX copies them into the process
image above when g_SimActive is TRUE, in place of %IW / %IX. In the
field build SIMULATION is absent, g_SimActive stays FALSE, and none
of these are ever written.
g_SimActive is set by SIMULATION every scan rather than declared
TRUE here, so the field build cannot accidentally mux in an
all-zero simulation image. In the simulation build SIMULATION is
scheduled before IO_MUX, so it is already TRUE - and the simulated
image already populated - on the first scan. That ordering is not
cosmetic: see 91_config_sim.st.
--------------------------------------------------------------------- *)
VAR_GLOBAL
g_SimActive : BOOL; (* FALSE in the field build *)
g_sim_ClearReset : BOOL; (* SIMULATION -> IO_MUX, clears %MW22 *)
g_simcmd_Inflow : INT; (* copies of %MW20-23 for SIMULATION *)
g_simcmd_Mode : INT;
g_simcmd_Reset : INT;
g_simcmd_TimeScale : INT;
g_sim_Level_mm : INT;
g_sim_Inflow_x10 : INT;
g_sim_Disch_x10 : INT;
g_sim_ManifoldP : INT;
g_sim_PumpP : ARRAY[1..3] OF INT;
g_sim_Vib_x10 : ARRAY[1..3] OF INT;
g_sim_LSHH : BOOL;
g_sim_LSLL_Wet : BOOL;
g_sim_Spill : BOOL;
g_sim_ThermalOK : ARRAY[1..3] OF BOOL;
g_sim_SealLeak : ARRAY[1..3] OF BOOL;
g_sim_MainsOK : BOOL;
END_VAR

141
03-plc/src/20_fb_pump.st Normal file
View file

@ -0,0 +1,141 @@
(* =====================================================================
20_fb_pump.st - FB_PUMP, section 4.1
One instance per pump. Owns everything about a single unit:
trips, availability, minimum run/off timers, run hours.
Additions to the brief's input list, each required to produce a
specified output and documented in README.md:
MinOffBypass - LSHH override of the minimum-off timer (4.1)
ServiceInterval - %MW10, needed for ServiceDue (4.1)
ResetHours - command 5, "reset run hours / service done" (3.3)
===================================================================== *)
FUNCTION_BLOCK FB_PUMP
VAR_INPUT
RunRequest : BOOL;
SpeedRef : REAL; (* Hz, common to all running units *)
ThermalOK : BOOL; (* TE-31x, TRUE = healthy *)
SealLeak : BOOL; (* MSE-31x, TRUE = leak *)
Vibration : REAL; (* VE-31x, mm/s *)
DischPressure : REAL; (* PIT-31x, kPa *)
ResetTrip : BOOL;
Lockout : BOOL;
MinOffBypass : BOOL;
ServiceInterval : REAL; (* hours *)
ResetHours : BOOL;
END_VAR
VAR_OUTPUT
RunCmd : BOOL;
Running : BOOL;
Available : BOOL;
Tripped : BOOL;
State : INT; (* enum, section 3.2 *)
RunHours : REAL;
ServiceDue : BOOL;
VibAlarm : BOOL; (* 7.1 mm/s, alarm only - bitmask 10-12 *)
SealAlarm : BOOL; (* does not affect availability *)
END_VAR
VAR CONSTANT
SCAN_S : REAL := 0.1; (* must match TASK INTERVAL *)
VIB_ALARM : REAL := 7.1; (* mm/s *)
VIB_TRIP : REAL := 11.0; (* mm/s *)
NOFLOW_KPA : REAL := 150.0;
END_VAR
VAR
MinRunTmr : TON;
MinOffTmr : TON;
NoFlowTmr : TON;
HasRun : BOOL; (* pump has run at least once since restart *)
StartOK : BOOL;
END_VAR
(* --- 1. Trip reset. Runs first so that a reset issued while the
initiating condition is still present re-trips immediately
rather than latching clear. --------------------------------- *)
IF ResetTrip THEN
Tripped := FALSE;
END_IF;
(* --- 2. Trip conditions. All latch; they clear only on reset. ---- *)
IF NOT ThermalOK THEN
Tripped := TRUE; (* TE-31x, section 5 *)
END_IF;
IF Vibration > VIB_TRIP THEN
Tripped := TRUE; (* VE-31x > 11.0 mm/s *)
END_IF;
(* No-flow: 20 s after RunCmd goes true, low discharge pressure trips
the unit. Monitored continuously once the window has elapsed, not
sampled once, so a loss of flow while running is also caught. *)
NoFlowTmr(IN := RunCmd, PT := T#20s);
IF NoFlowTmr.Q AND (DischPressure < NOFLOW_KPA) THEN
Tripped := TRUE;
END_IF;
(* --- 3. Alarms that do not affect availability, section 4.1 ------- *)
VibAlarm := Vibration > VIB_ALARM;
SealAlarm := SealLeak;
(* --- 4. Availability. A seal leak is deliberately absent here:
it raises an alarm only, per WRPS-PRO-001 5.5. --------------- *)
Available := ThermalOK AND NOT Tripped AND NOT Lockout;
(* --- 5. Minimum run / minimum off timers.
MinOff is gated on HasRun so that a cold start is not blocked
for 5 minutes after a runtime restart. ---------------------- *)
MinRunTmr(IN := RunCmd, PT := T#5m);
MinOffTmr(IN := (NOT RunCmd) AND HasRun, PT := T#5m);
StartOK := (NOT HasRun) OR MinOffTmr.Q OR MinOffBypass;
(* --- 6. Run command ---------------------------------------------- *)
IF Tripped OR Lockout OR NOT ThermalOK THEN
RunCmd := FALSE;
ELSIF RunCmd THEN
(* running: honour minimum run before accepting a stop *)
IF (NOT RunRequest) AND MinRunTmr.Q THEN
RunCmd := FALSE;
END_IF;
ELSE
IF RunRequest AND StartOK THEN
RunCmd := TRUE;
HasRun := TRUE;
END_IF;
END_IF;
Running := RunCmd;
(* --- 7. Run hours. Scan-time increments while running only. ------ *)
IF ResetHours THEN
RunHours := 0.0;
END_IF;
IF Running THEN
RunHours := RunHours + SCAN_S / 3600.0;
END_IF;
ServiceDue := RunHours >= ServiceInterval;
(* --- 8. Published state, section 3.2 ------------------------------ *)
IF Tripped THEN
State := 6; (* Tripped *)
ELSIF Lockout THEN
State := 7; (* Maintenance lockout *)
ELSIF NOT ThermalOK THEN
State := 0; (* Unavailable *)
ELSIF Running AND NOT RunRequest THEN
State := 4; (* Min-run inhibit *)
ELSIF RunCmd AND NOT NoFlowTmr.Q THEN
State := 2; (* Start delay *)
ELSIF Running THEN
State := 3; (* Running *)
ELSIF HasRun AND NOT MinOffTmr.Q THEN
State := 5; (* Min-off inhibit *)
ELSE
State := 1; (* Available, stopped *)
END_IF;
END_FUNCTION_BLOCK

View file

@ -0,0 +1,128 @@
(* =====================================================================
21_fb_duty_selector.st - FB_DUTY_SELECT, section 4.2
Decides WHICH units run, never HOW MANY.
Ranking, best first:
1. unavailable units excluded entirely
2. not-due-for-service ranks above due-for-service
3. within a group, lower run hours first
4. ties break by ascending pump number (deterministic for demos)
The two rules that outrank the ranking:
- never stop a running unit to start a better-ranked one
- service-due is a preference, never a veto
===================================================================== *)
FUNCTION_BLOCK FB_DUTY_SELECT
VAR_INPUT
Available : ARRAY[1..3] OF BOOL;
RunHours : ARRAY[1..3] OF REAL;
ServiceDue : ARRAY[1..3] OF BOOL;
RunningNow : ARRAY[1..3] OF BOOL;
PumpsRequired : INT;
END_VAR
VAR_OUTPUT
RunRequest : ARRAY[1..3] OF BOOL;
DutyPump : INT; (* 0 = none, 1-3 *)
END_VAR
VAR CONSTANT
(* Service-due penalty. Larger than any credible run-hours value,
so a due unit always ranks below every not-due unit while still
remaining in the list - rule 2 is a preference, not a veto. *)
SERVICE_PENALTY : REAL := 1000000.0;
END_VAR
VAR
Rank : ARRAY[1..3] OF INT; (* pump numbers, best first *)
Used : ARRAY[1..3] OF BOOL;
Sel : ARRAY[1..3] OF BOOL;
i : INT;
k : INT;
best : INT;
bestKey : REAL;
key : REAL;
nRanked : INT;
slots : INT;
cnt : INT;
END_VAR
(* --- 1. Reset working state --------------------------------------- *)
FOR i := 1 TO 3 DO
Used[i] := FALSE;
Sel[i] := FALSE;
Rank[i] := 0;
END_FOR;
nRanked := 0;
(* --- 2. Build the ranked list by repeated selection.
Scanning i ascending with a strict "<" test means an equal key
never displaces an earlier pump, which is rule 4. ------------ *)
FOR k := 1 TO 3 DO
best := 0;
bestKey := 0.0;
FOR i := 1 TO 3 DO
IF Available[i] AND NOT Used[i] THEN
key := RunHours[i];
IF ServiceDue[i] THEN
key := key + SERVICE_PENALTY;
END_IF;
IF (best = 0) OR (key < bestKey) THEN
best := i;
bestKey := key;
END_IF;
END_IF;
END_FOR;
IF best > 0 THEN
nRanked := nRanked + 1;
Rank[nRanked] := best;
Used[best] := TRUE;
END_IF;
END_FOR;
(* --- 3. Clamp the demand ------------------------------------------ *)
slots := PumpsRequired;
IF slots < 0 THEN
slots := 0;
END_IF;
IF slots > 3 THEN
slots := 3;
END_IF;
(* --- 4. Pass A: units already running keep their slots.
Walking in rank order means that if the demand has dropped it
is the worst-ranked running unit that loses its slot. -------- *)
cnt := 0;
FOR k := 1 TO nRanked DO
i := Rank[k];
IF RunningNow[i] AND (cnt < slots) THEN
Sel[i] := TRUE;
cnt := cnt + 1;
END_IF;
END_FOR;
(* --- 5. Pass B: fill the slots that remain, by rank --------------- *)
FOR k := 1 TO nRanked DO
i := Rank[k];
IF (NOT Sel[i]) AND (cnt < slots) THEN
Sel[i] := TRUE;
cnt := cnt + 1;
END_IF;
END_FOR;
(* --- 6. Publish. DutyPump is the best-ranked selected unit. ------- *)
DutyPump := 0;
FOR k := 1 TO nRanked DO
i := Rank[k];
IF Sel[i] AND (DutyPump = 0) THEN
DutyPump := i;
END_IF;
END_FOR;
FOR i := 1 TO 3 DO
RunRequest[i] := Sel[i];
END_FOR;
END_FUNCTION_BLOCK

View file

@ -0,0 +1,74 @@
(* =====================================================================
22_fb_level_control.st - FB_LEVEL_CTRL, section 4.3
PI control, no derivative term. Output is the common drive speed
for every running unit.
Direct acting: level above setpoint raises speed.
The 38.0-50.0 Hz clamp is a hard physical limit, not a preference -
below 38 Hz the 22 m static lift means no delivery at all.
===================================================================== *)
FUNCTION_BLOCK FB_LEVEL_CTRL
VAR_INPUT
Level : REAL; (* m *)
Setpoint : REAL; (* m *)
Enable : BOOL;
MinSpeed : REAL; (* Hz, already clamped >= 38.0 by CONTROL *)
MaxSpeed : REAL; (* Hz *)
END_VAR
VAR_OUTPUT
Speed : REAL; (* Hz *)
END_VAR
VAR CONSTANT
SCAN_S : REAL := 0.1; (* must match TASK INTERVAL *)
KP : REAL := 12.0; (* Hz/m - starting gain, expect retune *)
TI : REAL := 120.0; (* s *)
END_VAR
VAR
Integ : REAL := 38.0; (* integrator carries the bias term *)
Err : REAL;
Raw : REAL;
Integrate : BOOL;
END_VAR
IF NOT Enable THEN
(* Hold at minimum and reset the integrator, so that a restart does
not inherit stale integral action. *)
Integ := MinSpeed;
Speed := MinSpeed;
ELSE
Err := Level - Setpoint;
Raw := KP * Err + Integ;
(* Anti-windup: freeze the integrator whenever the output is
clamped, except when the error would drive it back into range. *)
Integrate := FALSE;
IF (Raw > MinSpeed) AND (Raw < MaxSpeed) THEN
Integrate := TRUE;
ELSIF (Raw >= MaxSpeed) AND (Err < 0.0) THEN
Integrate := TRUE;
ELSIF (Raw <= MinSpeed) AND (Err > 0.0) THEN
Integrate := TRUE;
END_IF;
IF Integrate THEN
Integ := Integ + (KP / TI) * Err * SCAN_S;
END_IF;
Raw := KP * Err + Integ;
IF Raw > MaxSpeed THEN
Speed := MaxSpeed;
ELSIF Raw < MinSpeed THEN
Speed := MinSpeed;
ELSE
Speed := Raw;
END_IF;
END_IF;
END_FUNCTION_BLOCK

View file

@ -0,0 +1,97 @@
(* =====================================================================
23_fb_headroom.st - FB_HEADROOM, section 4.4
Pure calculation. No control action, no alarms.
Reference condition, WRPS-PRO-001 7.3 and pass 1 test 14:
level 4.00 m, inflow 165 L/s, one pump at 120 L/s
NetInflow = 45.0 L/s -> %QW7 = 450
VolToSpill = (6.0 - 4.0) * 120 = 240 m3
TimeToSpill = 240 * 1000 / 45 = 5333.3 s -> %QW5 = 5333
===================================================================== *)
FUNCTION_BLOCK FB_HEADROOM
VAR_INPUT
Level : REAL; (* m *)
Inflow : REAL; (* L/s, unfiltered *)
TotalDischarge : REAL; (* L/s *)
END_VAR
VAR_OUTPUT
InflowFilt : REAL; (* L/s, 30 s lag *)
NetInflow : REAL; (* L/s *)
VolToSpill : REAL; (* m3 *)
VolToLSHH : REAL; (* m3 *)
TimeToSpill : INT; (* s, 32767 = drawing down *)
TimeToLSHH : INT; (* s, 32767 = drawing down *)
END_VAR
VAR CONSTANT
SCAN_S : REAL := 0.1; (* must match TASK INTERVAL *)
TAU_S : REAL := 30.0; (* inflow filter time constant *)
AREA_M2 : REAL := 120.0;
SPILL_M : REAL := 6.000;
LSHH_M : REAL := 5.500;
MIN_NET : REAL := 0.5; (* L/s, below this the figure is
meaningless - drawing down *)
NO_TIME : INT := 32767;
MAX_TIME : REAL := 32767.0;
END_VAR
VAR
Primed : BOOL := FALSE;
t : REAL;
END_VAR
(* --- Inflow filter. First-order lag, 30 s.
Primed on the first scan rather than ramping from zero: without
this the reference figure in test 14 would take ~2 minutes to
settle and would not reproduce exactly on demand. ---------------- *)
IF NOT Primed THEN
InflowFilt := Inflow;
Primed := TRUE;
ELSE
InflowFilt := InflowFilt + (Inflow - InflowFilt) * SCAN_S / TAU_S;
END_IF;
NetInflow := InflowFilt - TotalDischarge;
VolToSpill := (SPILL_M - Level) * AREA_M2;
VolToLSHH := (LSHH_M - Level) * AREA_M2;
IF VolToSpill < 0.0 THEN
VolToSpill := 0.0;
END_IF;
IF VolToLSHH < 0.0 THEN
VolToLSHH := 0.0;
END_IF;
(* --- Time to spill weir ------------------------------------------- *)
IF NetInflow <= MIN_NET THEN
TimeToSpill := NO_TIME;
ELSE
t := VolToSpill * 1000.0 / NetInflow;
IF t >= MAX_TIME THEN
TimeToSpill := NO_TIME;
ELSIF t < 0.0 THEN
TimeToSpill := 0;
ELSE
TimeToSpill := REAL_TO_INT(t);
END_IF;
END_IF;
(* --- Time to LSHH -------------------------------------------------- *)
IF NetInflow <= MIN_NET THEN
TimeToLSHH := NO_TIME;
ELSE
t := VolToLSHH * 1000.0 / NetInflow;
IF t >= MAX_TIME THEN
TimeToLSHH := NO_TIME;
ELSIF t < 0.0 THEN
TimeToLSHH := 0;
ELSE
TimeToLSHH := REAL_TO_INT(t);
END_IF;
END_IF;
END_FUNCTION_BLOCK

View file

@ -0,0 +1,595 @@
(* =====================================================================
30_prog_control.st - PROGRAM CONTROL, section 4.5
Contains no located variable reference of any kind. Every input
arrives through the process image globals written by IO_MUX, and
every output leaves the same way. This is what makes the pass 2
simulation a mux change rather than a control change.
Execution order below follows section 4.5 step for step.
===================================================================== *)
PROGRAM CONTROL
VAR_EXTERNAL
(* process image in *)
g_LevelRaw_mm : INT;
g_Level_mm : INT;
g_Level_m : REAL;
g_Inflow_Lps : REAL;
g_Disch_Lps : REAL;
g_PumpP_kPa : ARRAY[1..3] OF REAL;
g_Vib_mms : ARRAY[1..3] OF REAL;
g_LSHH : BOOL;
g_LSLL_Wet : BOOL;
g_SpillDetected : BOOL;
g_ThermalOK : ARRAY[1..3] OF BOOL;
g_SealLeak : ARRAY[1..3] OF BOOL;
g_MainsOK : BOOL;
(* commands and setpoints in, unvalidated *)
g_cmd_Mode : INT;
g_cmd_Word : INT;
g_cmd_Param : INT;
g_sp_Level : INT;
g_sp_StartDuty : INT;
g_sp_StartP2 : INT;
g_sp_StartP3 : INT;
g_sp_StopAll : INT;
g_sp_HighAlarm : INT;
g_sp_MinSpeed : INT;
g_sp_ServiceHrs : INT;
(* published out *)
g_o_RunCmd : ARRAY[1..3] OF BOOL;
g_o_Running : ARRAY[1..3] OF BOOL;
g_o_Available : ARRAY[1..3] OF BOOL;
g_o_Tripped : ARRAY[1..3] OF BOOL;
g_o_InAuto : BOOL;
g_o_HighLevel : BOOL;
g_o_SpillActive : BOOL;
g_o_Level_mm : INT;
g_o_Inflow_x10 : INT;
g_o_Disch_x10 : INT;
g_o_PumpsRun : INT;
g_o_Speed_x10 : INT;
g_o_TimeToSpill : INT;
g_o_TimeToLSHH : INT;
g_o_NetAccum : INT;
g_o_RunHours : ARRAY[1..3] OF INT;
g_o_VolToSpill : INT;
g_o_StationState: INT;
g_o_PumpState : ARRAY[1..3] OF INT;
g_o_DutyPump : INT;
g_o_AlarmWord : INT;
g_o_CmdAck : INT;
END_VAR
VAR CONSTANT
SPILL_MM : INT := 6000; (* spill weir, mm *)
LEVEL_MAX_MM : INT := 7000; (* LIT-101 range top *)
HARD_MIN_HZ : REAL := 38.0;
HARD_MAX_HZ : REAL := 50.0;
END_VAR
VAR
(* --- function block instances. Three explicit pump instances
rather than an ARRAY OF FB_PUMP: arrays of function blocks
are the kind of construct section 9 warns about. --------- *)
Pump1 : FB_PUMP;
Pump2 : FB_PUMP;
Pump3 : FB_PUMP;
Duty : FB_DUTY_SELECT;
LvlCtl : FB_LEVEL_CTRL;
Head : FB_HEADROOM;
(* --- validated setpoints, seeded with the section 2.3 defaults
and held at the last good value on a bad write ---------- *)
v_Mode : INT := 1;
v_SpLevel : INT := 4200;
v_StartDuty : INT := 4000;
v_StartP2 : INT := 4500;
v_StartP3 : INT := 5000;
v_StopAll : INT := 1000;
v_HighAlarm : INT := 5200;
v_MinSpeed : INT := 380;
v_ServiceHrs : INT := 4000;
SpRejected : BOOL; (* latched, bit 15, cleared by cmd 6 *)
SpOK : BOOL;
(* --- command handshake ------------------------------------------ *)
CmdBusy : BOOL;
ResetTrip : ARRAY[1..3] OF BOOL;
ResetHours : ARRAY[1..3] OF BOOL;
Lockout : ARRAY[1..3] OF BOOL;
AckAlarms : BOOL;
p : INT;
(* --- staging ---------------------------------------------------- *)
PumpsRequired : INT; (* held across scans - the hysteresis *)
PumpsAllowed : INT; (* after start stagger *)
StaggerTmr : TON;
StaggerArm : BOOL;
(* --- interlocks -------------------------------------------------- *)
DryRun : BOOL;
DryLockout : BOOL;
LevelRangeFault : BOOL;
LevelFrozen : BOOL;
LevelFault : BOOL;
LevelRef : INT;
LevelMoved : BOOL;
FrozenTmr : TON;
AnyRunning : BOOL;
(* --- duty selector interface ------------------------------------ *)
Avail : ARRAY[1..3] OF BOOL;
Hours : ARRAY[1..3] OF REAL;
SvcDue : ARRAY[1..3] OF BOOL;
RunNow : ARRAY[1..3] OF BOOL;
Req : ARRAY[1..3] OF BOOL;
(* --- misc -------------------------------------------------------- *)
Speed : REAL;
MinSpeedHz : REAL;
SpLevel_m : REAL;
HighLevel : BOOL;
PumpsRun : INT;
Alarm : DINT;
i : INT;
r : REAL;
Primed : BOOL := FALSE;
END_VAR
(* =====================================================================
Step 1 - read and clamp setpoints, section 2.3
Every setpoint is validated as a set, not individually: the start
levels only make sense in order. A rejected write holds the last
good value and raises bit 15 rather than acting on it.
===================================================================== *)
(* Mode *)
IF (g_cmd_Mode = 1) OR (g_cmd_Mode = 2) THEN
v_Mode := g_cmd_Mode;
ELSE
SpRejected := TRUE;
END_IF;
(* Level setpoints. A start level at or above the spill weir must
never be accepted, section 2.3. *)
SpOK := TRUE;
IF (g_sp_StopAll < 0) OR (g_sp_StopAll >= g_sp_StartDuty) THEN
SpOK := FALSE;
END_IF;
IF (g_sp_StartDuty >= g_sp_StartP2) OR (g_sp_StartDuty >= SPILL_MM) THEN
SpOK := FALSE;
END_IF;
IF (g_sp_StartP2 >= g_sp_StartP3) OR (g_sp_StartP2 >= SPILL_MM) THEN
SpOK := FALSE;
END_IF;
IF (g_sp_StartP3 >= SPILL_MM) THEN
SpOK := FALSE;
END_IF;
IF (g_sp_Level <= g_sp_StopAll) OR (g_sp_Level >= SPILL_MM) THEN
SpOK := FALSE;
END_IF;
IF (g_sp_HighAlarm <= 0) OR (g_sp_HighAlarm > SPILL_MM) THEN
SpOK := FALSE;
END_IF;
IF SpOK THEN
v_SpLevel := g_sp_Level;
v_StartDuty := g_sp_StartDuty;
v_StartP2 := g_sp_StartP2;
v_StartP3 := g_sp_StartP3;
v_StopAll := g_sp_StopAll;
v_HighAlarm := g_sp_HighAlarm;
ELSE
SpRejected := TRUE;
END_IF;
(* Minimum drive speed, Hz x 10, bounded by the hard physical limits *)
IF (g_sp_MinSpeed >= 380) AND (g_sp_MinSpeed <= 500) THEN
v_MinSpeed := g_sp_MinSpeed;
ELSE
SpRejected := TRUE;
END_IF;
(* Service interval *)
IF g_sp_ServiceHrs > 0 THEN
v_ServiceHrs := g_sp_ServiceHrs;
ELSE
SpRejected := TRUE;
END_IF;
MinSpeedHz := INT_TO_REAL(v_MinSpeed) / 10.0;
IF MinSpeedHz < HARD_MIN_HZ THEN
MinSpeedHz := HARD_MIN_HZ;
END_IF;
SpLevel_m := INT_TO_REAL(v_SpLevel) / 1000.0;
(* =====================================================================
Step 2 - command word and acknowledge, section 3.3
Executes on the rising edge of a non-zero %MW1, echoes the value to
%QW20, then takes no further action until %MW1 returns to 0.
===================================================================== *)
(* one-shot pulses, consumed by the FB_PUMP calls later this scan *)
FOR i := 1 TO 3 DO
ResetTrip[i] := FALSE;
ResetHours[i] := FALSE;
END_FOR;
AckAlarms := FALSE;
IF (g_cmd_Word <> 0) AND NOT CmdBusy THEN
CmdBusy := TRUE;
p := g_cmd_Param;
CASE g_cmd_Word OF
1: (* reset all trips *)
FOR i := 1 TO 3 DO
ResetTrip[i] := TRUE;
END_FOR;
(* the dry run lockout is manual-reset and only clears once
the level has actually recovered, section 5 *)
IF g_Level_mm > v_StopAll THEN
DryLockout := FALSE;
END_IF;
2: (* reset trip on pump in %MW2 *)
IF (p >= 1) AND (p <= 3) THEN
ResetTrip[p] := TRUE;
END_IF;
3: (* lock out pump in %MW2 *)
IF (p >= 1) AND (p <= 3) THEN
Lockout[p] := TRUE;
END_IF;
4: (* release lockout on pump in %MW2 *)
IF (p >= 1) AND (p <= 3) THEN
Lockout[p] := FALSE;
END_IF;
5: (* reset run hours on pump in %MW2 - service done *)
IF (p >= 1) AND (p <= 3) THEN
ResetHours[p] := TRUE;
END_IF;
6: (* acknowledge alarms *)
AckAlarms := TRUE;
SpRejected := FALSE;
END_CASE;
g_o_CmdAck := g_cmd_Word;
ELSIF g_cmd_Word = 0 THEN
CmdBusy := FALSE;
g_o_CmdAck := 0;
END_IF;
(* =====================================================================
Level signal integrity, section 5
A frozen transmitter reading a plausible value is the failure that
actually causes spills, and a range check alone cannot see it.
===================================================================== *)
LevelRangeFault := (g_LevelRaw_mm < 0) OR (g_LevelRaw_mm > LEVEL_MAX_MM);
IF NOT Primed THEN
LevelRef := g_LevelRaw_mm;
Primed := TRUE;
END_IF;
IF ABS(g_LevelRaw_mm - LevelRef) > 1 THEN
LevelRef := g_LevelRaw_mm;
LevelMoved := TRUE;
ELSE
LevelMoved := FALSE;
END_IF;
AnyRunning := Pump1.Running OR Pump2.Running OR Pump3.Running;
FrozenTmr(IN := AnyRunning AND NOT LevelMoved, PT := T#10m);
LevelFrozen := FrozenTmr.Q;
LevelFault := LevelRangeFault OR LevelFrozen;
(* =====================================================================
Step 3 - determine PumpsRequired from level
The band between StopAll and StartDuty holds the previous value.
That hysteresis is the whole point; it is never recomputed from
scratch.
===================================================================== *)
IF NOT LevelFault THEN
IF g_Level_mm >= v_StartP3 THEN
PumpsRequired := 3;
ELSIF g_Level_mm >= v_StartP2 THEN
PumpsRequired := 2;
ELSIF g_Level_mm >= v_StartDuty THEN
PumpsRequired := 1;
ELSIF g_Level_mm <= v_StopAll THEN
PumpsRequired := 0;
END_IF;
(* otherwise: hold *)
ELSE
(* Fall back to discrete level control, section 5. LSHH and LSLL
are independent instruments and remain trustworthy. *)
IF g_LSHH THEN
PumpsRequired := 3;
END_IF;
(* otherwise: hold, and let the LSLL override below stop the
station if the well is actually dry *)
END_IF;
(* =====================================================================
Step 4 - LSHH override. Start all available, bypass min-off.
===================================================================== *)
IF g_LSHH THEN
PumpsRequired := 3;
END_IF;
(* =====================================================================
Step 5 - LSLL override. Fail-safe: the instrument reads TRUE when
wet, so a broken wire reads dry and stops the station.
===================================================================== *)
DryRun := NOT g_LSLL_Wet;
IF DryRun THEN
PumpsRequired := 0;
DryLockout := TRUE; (* latched, manual reset via command 1 *)
END_IF;
IF DryLockout THEN
PumpsRequired := 0;
END_IF;
(* =====================================================================
Step 6 - station mode off
===================================================================== *)
IF v_Mode = 2 THEN
PumpsRequired := 0;
END_IF;
(* =====================================================================
Step 11 (applied here, before selection) - stagger starts
Held second and third starts by 30 s each, to limit inrush and the
hydraulic transient. Applied before FB_DUTY_SELECT because it
limits how many units may start, which is an input to selection,
not a correction applied afterwards. Stops are never staggered.
LSHH bypasses the stagger as well as the min-off timers, so that
the emergency response is immediate.
===================================================================== *)
IF g_LSHH THEN
PumpsAllowed := PumpsRequired;
StaggerArm := FALSE;
ELSE
StaggerTmr(IN := StaggerArm, PT := T#30s);
IF PumpsAllowed < PumpsRequired THEN
IF PumpsAllowed = 0 THEN
PumpsAllowed := 1; (* first unit starts at once *)
StaggerArm := FALSE;
ELSIF StaggerTmr.Q THEN
PumpsAllowed := PumpsAllowed + 1;
StaggerArm := FALSE;
ELSE
StaggerArm := TRUE;
END_IF;
ELSE
IF PumpsAllowed > PumpsRequired THEN
PumpsAllowed := PumpsRequired;
END_IF;
StaggerArm := FALSE;
END_IF;
END_IF;
(* =====================================================================
Step 7 - duty selection
===================================================================== *)
Avail[1] := Pump1.Available; Avail[2] := Pump2.Available; Avail[3] := Pump3.Available;
Hours[1] := Pump1.RunHours; Hours[2] := Pump2.RunHours; Hours[3] := Pump3.RunHours;
SvcDue[1] := Pump1.ServiceDue; SvcDue[2] := Pump2.ServiceDue; SvcDue[3] := Pump3.ServiceDue;
RunNow[1] := Pump1.Running; RunNow[2] := Pump2.Running; RunNow[3] := Pump3.Running;
Duty(Available := Avail,
RunHours := Hours,
ServiceDue := SvcDue,
RunningNow := RunNow,
PumpsRequired := PumpsAllowed);
Req[1] := Duty.RunRequest[1];
Req[2] := Duty.RunRequest[2];
Req[3] := Duty.RunRequest[3];
(* =====================================================================
Step 8 - level control. On LSHH force 50.0 Hz.
===================================================================== *)
LvlCtl(Level := g_Level_m,
Setpoint := SpLevel_m,
Enable := (PumpsAllowed > 0),
MinSpeed := MinSpeedHz,
MaxSpeed := HARD_MAX_HZ);
Speed := LvlCtl.Speed;
IF g_LSHH THEN
Speed := HARD_MAX_HZ;
END_IF;
(* =====================================================================
Step 9 - the pumps
===================================================================== *)
Pump1(RunRequest := Req[1],
SpeedRef := Speed,
ThermalOK := g_ThermalOK[1],
SealLeak := g_SealLeak[1],
Vibration := g_Vib_mms[1],
DischPressure := g_PumpP_kPa[1],
ResetTrip := ResetTrip[1],
Lockout := Lockout[1],
MinOffBypass := g_LSHH,
ServiceInterval := INT_TO_REAL(v_ServiceHrs),
ResetHours := ResetHours[1]);
Pump2(RunRequest := Req[2],
SpeedRef := Speed,
ThermalOK := g_ThermalOK[2],
SealLeak := g_SealLeak[2],
Vibration := g_Vib_mms[2],
DischPressure := g_PumpP_kPa[2],
ResetTrip := ResetTrip[2],
Lockout := Lockout[2],
MinOffBypass := g_LSHH,
ServiceInterval := INT_TO_REAL(v_ServiceHrs),
ResetHours := ResetHours[2]);
Pump3(RunRequest := Req[3],
SpeedRef := Speed,
ThermalOK := g_ThermalOK[3],
SealLeak := g_SealLeak[3],
Vibration := g_Vib_mms[3],
DischPressure := g_PumpP_kPa[3],
ResetTrip := ResetTrip[3],
Lockout := Lockout[3],
MinOffBypass := g_LSHH,
ServiceInterval := INT_TO_REAL(v_ServiceHrs),
ResetHours := ResetHours[3]);
(* =====================================================================
Step 10 - headroom
===================================================================== *)
Head(Level := g_Level_m,
Inflow := g_Inflow_Lps,
TotalDischarge := g_Disch_Lps);
(* =====================================================================
Step 12 - publish
===================================================================== *)
PumpsRun := 0;
IF Pump1.Running THEN PumpsRun := PumpsRun + 1; END_IF;
IF Pump2.Running THEN PumpsRun := PumpsRun + 1; END_IF;
IF Pump3.Running THEN PumpsRun := PumpsRun + 1; END_IF;
HighLevel := g_Level_mm >= v_HighAlarm;
g_o_RunCmd[1] := Pump1.RunCmd;
g_o_RunCmd[2] := Pump2.RunCmd;
g_o_RunCmd[3] := Pump3.RunCmd;
g_o_Running[1] := Pump1.Running;
g_o_Running[2] := Pump2.Running;
g_o_Running[3] := Pump3.Running;
g_o_Available[1] := Pump1.Available;
g_o_Available[2] := Pump2.Available;
g_o_Available[3] := Pump3.Available;
g_o_Tripped[1] := Pump1.Tripped;
g_o_Tripped[2] := Pump2.Tripped;
g_o_Tripped[3] := Pump3.Tripped;
g_o_PumpState[1] := Pump1.State;
g_o_PumpState[2] := Pump2.State;
g_o_PumpState[3] := Pump3.State;
g_o_InAuto := (v_Mode = 1);
g_o_HighLevel := HighLevel;
g_o_SpillActive := g_SpillDetected;
g_o_Level_mm := g_Level_mm;
g_o_PumpsRun := PumpsRun;
g_o_DutyPump := Duty.DutyPump;
(* scaled analogues, clamped into 16-bit signed range *)
r := g_Inflow_Lps * 10.0;
IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
g_o_Inflow_x10 := REAL_TO_INT(r);
r := g_Disch_Lps * 10.0;
IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
g_o_Disch_x10 := REAL_TO_INT(r);
r := Speed * 10.0;
IF r > 32767.0 THEN r := 32767.0; ELSIF r < 0.0 THEN r := 0.0; END_IF;
g_o_Speed_x10 := REAL_TO_INT(r);
r := Head.NetInflow * 10.0;
IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
g_o_NetAccum := REAL_TO_INT(r);
r := Head.VolToSpill;
IF r > 32767.0 THEN r := 32767.0; ELSIF r < 0.0 THEN r := 0.0; END_IF;
g_o_VolToSpill := REAL_TO_INT(r);
g_o_TimeToSpill := Head.TimeToSpill;
g_o_TimeToLSHH := Head.TimeToLSHH;
r := Pump1.RunHours;
IF r > 32767.0 THEN r := 32767.0; END_IF;
g_o_RunHours[1] := REAL_TO_INT(r);
r := Pump2.RunHours;
IF r > 32767.0 THEN r := 32767.0; END_IF;
g_o_RunHours[2] := REAL_TO_INT(r);
r := Pump3.RunHours;
IF r > 32767.0 THEN r := 32767.0; END_IF;
g_o_RunHours[3] := REAL_TO_INT(r);
(* --- station state, section 3.1 ---------------------------------- *)
IF v_Mode = 2 THEN
g_o_StationState := 0; (* Off *)
ELSIF g_LSHH THEN
g_o_StationState := 4; (* Emergency *)
ELSIF DryLockout THEN
g_o_StationState := 5; (* Dry run lockout *)
ELSIF LevelFault THEN
g_o_StationState := 6; (* Fault *)
ELSIF HighLevel THEN
g_o_StationState := 3; (* High level *)
ELSIF PumpsRun > 0 THEN
g_o_StationState := 2; (* Pumping *)
ELSE
g_o_StationState := 1; (* Idle *)
END_IF;
(* --- alarm bitmask, section 6.
Accumulated in a DINT because bit 15 does not fit a signed INT.
Values at or above 32768 are folded into the negative half of the
16-bit word; CI Server must read %QW17 as UNSIGNED. ------------- *)
Alarm := 0;
IF HighLevel THEN Alarm := Alarm + 1; END_IF; (* bit0 *)
IF g_LSHH THEN Alarm := Alarm + 2; END_IF; (* bit1 *)
IF DryRun OR DryLockout THEN Alarm := Alarm + 4; END_IF; (* bit2 *)
IF g_SpillDetected THEN Alarm := Alarm + 8; END_IF; (* bit3 *)
IF Pump1.Tripped THEN Alarm := Alarm + 16; END_IF; (* bit4 *)
IF Pump2.Tripped THEN Alarm := Alarm + 32; END_IF; (* bit5 *)
IF Pump3.Tripped THEN Alarm := Alarm + 64; END_IF; (* bit6 *)
IF Pump1.SealAlarm THEN Alarm := Alarm + 128; END_IF; (* bit7 *)
IF Pump2.SealAlarm THEN Alarm := Alarm + 256; END_IF; (* bit8 *)
IF Pump3.SealAlarm THEN Alarm := Alarm + 512; END_IF; (* bit9 *)
IF Pump1.VibAlarm THEN Alarm := Alarm + 1024; END_IF; (* bit10 *)
IF Pump2.VibAlarm THEN Alarm := Alarm + 2048; END_IF; (* bit11 *)
IF Pump3.VibAlarm THEN Alarm := Alarm + 4096; END_IF; (* bit12 *)
IF LevelFault THEN Alarm := Alarm + 8192; END_IF; (* bit13 *)
IF NOT g_MainsOK THEN Alarm := Alarm + 16384; END_IF; (* bit14 *)
IF SpRejected THEN Alarm := Alarm + 32768; END_IF; (* bit15 *)
IF Alarm >= 32768 THEN
g_o_AlarmWord := DINT_TO_INT(Alarm - 65536);
ELSE
g_o_AlarmWord := DINT_TO_INT(Alarm);
END_IF;
END_PROGRAM

View file

@ -0,0 +1,308 @@
(* =====================================================================
40_prog_simulation.st - PROGRAM SIMULATION, section 8.2
Pass 2 only. Excluded from the field build by build.py.
Runs on the same 100 ms task, FIRST - before IO_MUX and CONTROL - so
that a simulated image exists on the very first scan. See the header
of 91_config_sim.st for why that matters: with this program last, the
first scan latches trips on all three pumps from an all-zero field
image. The cost is that it acts on the run commands and speed
CONTROL published on the previous scan, 100 ms earlier.
It writes only g_sim_* globals; IO_MUX feeds those into the process
image while g_SimActive is TRUE. This program contains no located
variable and no control logic.
Two time bases, deliberately:
simulated time - level integration, inflow scenarios, the sim
clock. Multiplied by the time scale (%MW23).
real time - the per-pump start delay only. CONTROL's timers
are in real seconds (FB_PUMP's no-flow TON is
T#20s), so a start delay scaled by 60 would be
invisible to it and the no-flow trip could never
be demonstrated. Section 8.2 asks for the delay
precisely so that trip has something to detect,
so the delay stays real.
===================================================================== *)
PROGRAM SIMULATION
VAR_EXTERNAL
g_simcmd_Inflow : INT;
g_simcmd_Mode : INT;
g_simcmd_Reset : INT;
g_simcmd_TimeScale : INT;
g_o_RunCmd : ARRAY[1..3] OF BOOL;
g_o_Speed_x10 : INT;
g_SimActive : BOOL;
g_sim_ClearReset : BOOL;
g_sim_Level_mm : INT;
g_sim_Inflow_x10 : INT;
g_sim_Disch_x10 : INT;
g_sim_ManifoldP : INT;
g_sim_PumpP : ARRAY[1..3] OF INT;
g_sim_Vib_x10 : ARRAY[1..3] OF INT;
g_sim_LSHH : BOOL;
g_sim_LSLL_Wet : BOOL;
g_sim_Spill : BOOL;
g_sim_ThermalOK : ARRAY[1..3] OF BOOL;
g_sim_SealLeak : ARRAY[1..3] OF BOOL;
g_sim_MainsOK : BOOL;
END_VAR
VAR CONSTANT
SCAN_S : REAL := 0.1; (* task interval, matches 91_config_sim *)
AREA_M2 : REAL := 120.0; (* wet well plan area *)
SPILL_M : REAL := 6.0; (* spill weir *)
LSHH_M : REAL := 5.5; (* high high float *)
LSLL_M : REAL := 0.30; (* low low float, below = dry *)
START_DLY_S : REAL := 3.0; (* real seconds, command to flow *)
HZ_LO : REAL := 38.0; (* static lift cutoff *)
HZ_HI : REAL := 50.0;
Q_LO : REAL := 65.0; (* L/s at HZ_LO *)
Q_HI : REAL := 120.0; (* L/s at HZ_HI *)
P_IDLE : REAL := 80.0; (* kPa, not delivering *)
P_BASE : REAL := 220.0; (* kPa at zero flow while delivering *)
P_PER_LPS : REAL := 1.4;
VIB_IDLE : REAL := 0.2; (* mm/s, stopped *)
VIB_BASE : REAL := 1.5; (* mm/s at zero flow *)
VIB_PER_LPS : REAL := 0.02; (* stays well under FB_PUMP's 7.1 alarm *)
END_VAR
VAR
Init : BOOL := FALSE;
Volume_m3 : REAL;
Level_m : REAL;
Inflow_Lps : REAL;
SumFlow_Lps : REAL;
SimClock_s : REAL; (* simulated seconds since reset *)
StartDly_s : ARRAY[1..3] OF REAL;
Delivering : ARRAY[1..3] OF BOOL;
Flow_Lps : ARRAY[1..3] OF REAL;
Press_kPa : ARRAY[1..3] OF REAL;
Vib_mms : ARRAY[1..3] OF REAL;
TimeScale : REAL;
dt_s : REAL; (* simulated seconds this scan *)
Speed_Hz : REAL;
UnitQ_Lps : REAL; (* per-unit flow before derating *)
Derate : REAL;
nDelivering : INT;
i : INT;
Rnd : DINT := 12345; (* LCG state, section 8.3 *)
Noise : REAL;
END_VAR
(* ---------------------------------------------------------------------
Reset / first scan.
%MW22 = 1 restores the initial conditions of the selected mode
(section 8.2). The acknowledgement is a global; IO_MUX clears %MW22,
because this program may not touch located variables.
--------------------------------------------------------------------- *)
g_sim_ClearReset := FALSE;
IF (NOT Init) OR (g_simcmd_Reset = 1) THEN
Init := TRUE;
g_sim_ClearReset := TRUE;
SimClock_s := 0.0;
IF g_simcmd_Mode = 3 THEN
Level_m := 4.000; (* section 7.3 reference condition *)
ELSE
Level_m := 3.500; (* below start duty, station idle *)
END_IF;
Volume_m3 := Level_m * AREA_M2;
FOR i := 1 TO 3 DO
StartDly_s[i] := 0.0;
Delivering[i] := FALSE;
Flow_Lps[i] := 0.0;
Press_kPa[i] := P_IDLE;
Vib_mms[i] := VIB_IDLE;
END_FOR;
END_IF;
(* ---------------------------------------------------------------------
Time base. %MW23 = 1..120, anything outside that is treated as 1.
--------------------------------------------------------------------- *)
IF (g_simcmd_TimeScale >= 1) AND (g_simcmd_TimeScale <= 120) THEN
TimeScale := INT_TO_REAL(g_simcmd_TimeScale);
ELSE
TimeScale := 1.0;
END_IF;
dt_s := SCAN_S * TimeScale;
SimClock_s := SimClock_s + dt_s;
(* ---------------------------------------------------------------------
Inflow generator, section 8.2. %MW21 selects the mode.
--------------------------------------------------------------------- *)
CASE g_simcmd_Mode OF
1: (* diurnal dry weather: 40-110 L/s over a 24 h simulated period *)
Inflow_Lps := 75.0 + 35.0 * SIN(6.283185 * SimClock_s / 86400.0);
2: (* wet weather: ramp 20 min to 300, hold 40 min, decay over 90 min *)
IF SimClock_s < 1200.0 THEN
Inflow_Lps := 75.0 + (300.0 - 75.0) * SimClock_s / 1200.0;
ELSIF SimClock_s < 3600.0 THEN
Inflow_Lps := 300.0;
ELSIF SimClock_s < 9000.0 THEN
Inflow_Lps := 300.0 - (300.0 - 75.0) * (SimClock_s - 3600.0) / 5400.0;
ELSE
Inflow_Lps := 75.0;
END_IF;
3: (* demo reference, section 7.3: held at exactly 165 L/s *)
Inflow_Lps := 165.0;
ELSE (* 0 and anything unrecognised: manual, %MW20 in L/s x 10 *)
Inflow_Lps := INT_TO_REAL(g_simcmd_Inflow) / 10.0;
END_CASE;
IF Inflow_Lps < 0.0 THEN
Inflow_Lps := 0.0;
END_IF;
(* ---------------------------------------------------------------------
Per-pump flow model, section 8.2.
Speed comes from CONTROL's published common drive speed, which is
this scan's value because SIMULATION runs after CONTROL.
--------------------------------------------------------------------- *)
Speed_Hz := INT_TO_REAL(g_o_Speed_x10) / 10.0;
IF Speed_Hz < HZ_LO THEN
UnitQ_Lps := 0.0; (* static lift cutoff *)
ELSE
IF Speed_Hz > HZ_HI THEN
Speed_Hz := HZ_HI;
END_IF;
UnitQ_Lps := Q_LO + (Speed_Hz - HZ_LO) * (Q_HI - Q_LO) / (HZ_HI - HZ_LO);
END_IF;
(* Start delay on REAL time - see the header comment. *)
nDelivering := 0;
FOR i := 1 TO 3 DO
IF g_o_RunCmd[i] THEN
IF StartDly_s[i] < START_DLY_S THEN
StartDly_s[i] := StartDly_s[i] + SCAN_S;
END_IF;
Delivering[i] := (StartDly_s[i] >= START_DLY_S) AND (UnitQ_Lps > 0.0);
ELSE
StartDly_s[i] := 0.0;
Delivering[i] := FALSE;
END_IF;
IF Delivering[i] THEN
nDelivering := nDelivering + 1;
END_IF;
END_FOR;
(* Parallel derating: 3 units give ~360 L/s, not a naive 3 x 120. *)
CASE nDelivering OF
1: Derate := 1.00;
2: Derate := 0.94;
3: Derate := 0.88;
ELSE Derate := 1.00;
END_CASE;
SumFlow_Lps := 0.0;
FOR i := 1 TO 3 DO
IF Delivering[i] THEN
Flow_Lps[i] := UnitQ_Lps * Derate;
Press_kPa[i] := P_BASE + P_PER_LPS * Flow_Lps[i];
Vib_mms[i] := VIB_BASE + VIB_PER_LPS * Flow_Lps[i];
SumFlow_Lps := SumFlow_Lps + Flow_Lps[i];
ELSE
Flow_Lps[i] := 0.0;
Press_kPa[i] := P_IDLE;
IF g_o_RunCmd[i] THEN
Vib_mms[i] := VIB_BASE; (* spinning up, no flow yet *)
ELSE
Vib_mms[i] := VIB_IDLE;
END_IF;
END_IF;
END_FOR;
(* ---------------------------------------------------------------------
Wet well integration, section 8.2.
On reaching the weir the level holds at 6.00 m and the excess is
discarded - the station is seen to spill rather than running the
level off scale.
--------------------------------------------------------------------- *)
Volume_m3 := Volume_m3 + (Inflow_Lps - SumFlow_Lps) * dt_s / 1000.0;
IF Volume_m3 < 0.0 THEN
Volume_m3 := 0.0;
END_IF;
IF Volume_m3 > SPILL_M * AREA_M2 THEN
Volume_m3 := SPILL_M * AREA_M2;
END_IF;
Level_m := Volume_m3 / AREA_M2;
(* ---------------------------------------------------------------------
Publish, section 8.3: small noise so the trends are not perfectly
smooth and FB_HEADROOM's inflow filter has something to filter.
LCG kept small enough that the DINT multiply cannot overflow.
--------------------------------------------------------------------- *)
Rnd := (Rnd * 75 + 74) MOD 65537;
Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.01; (* +/-0.5% *)
g_sim_Level_mm := REAL_TO_INT(Level_m * 1000.0 * (1.0 + Noise));
Rnd := (Rnd * 75 + 74) MOD 65537;
Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.04; (* +/-2% *)
g_sim_Inflow_x10 := REAL_TO_INT(Inflow_Lps * 10.0 * (1.0 + Noise));
Rnd := (Rnd * 75 + 74) MOD 65537;
Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.04;
g_sim_Disch_x10 := REAL_TO_INT(SumFlow_Lps * 10.0 * (1.0 + Noise));
FOR i := 1 TO 3 DO
g_sim_PumpP[i] := REAL_TO_INT(Press_kPa[i]);
g_sim_Vib_x10[i] := REAL_TO_INT(Vib_mms[i] * 10.0);
END_FOR;
(* Manifold: the highest delivering unit's pressure, idle if none. *)
g_sim_ManifoldP := REAL_TO_INT(P_IDLE);
IF nDelivering > 0 THEN
g_sim_ManifoldP := REAL_TO_INT(P_BASE + P_PER_LPS * UnitQ_Lps * Derate);
END_IF;
(* --- level switches. Sense conventions are section 2.1's. --------- *)
g_sim_LSHH := Level_m >= LSHH_M;
g_sim_LSLL_Wet := Level_m > LSLL_M; (* FALSE = dry *)
g_sim_Spill := Level_m >= (SPILL_M - 0.001);
(* --- plant health. Healthy unless a fault is injected; injection is
not modelled yet, so these are constant. ---------------------- *)
FOR i := 1 TO 3 DO
g_sim_ThermalOK[i] := TRUE;
g_sim_SealLeak[i] := FALSE;
END_FOR;
g_sim_MainsOK := TRUE;
(* Tell IO_MUX to take its process image from here, not from %IW/%IX. *)
g_SimActive := TRUE;
END_PROGRAM

View file

@ -0,0 +1,292 @@
(* =====================================================================
50_prog_io_mux.st - PROGRAM IO_MUX, section 8.1
The only POU besides 10_globals.st permitted to touch located
variables. Runs BEFORE CONTROL in the task list.
Field build: %IW / %IX -> process image globals
Simulation build: PROGRAM SIMULATION outputs -> the same globals
Because IO_MUX runs before CONTROL, the %QW / %QX values it
publishes are the ones CONTROL computed on the PREVIOUS scan. At a
100 ms task that is a 100 ms publication lag on outputs only; it
does not affect any control decision. Noted in README.md.
===================================================================== *)
PROGRAM IO_MUX
VAR_EXTERNAL
(* located - field inputs *)
IW_LIT101 : INT; IW_FIT201 : INT; IW_FIT301 : INT; IW_PIT302 : INT;
IW_PIT311 : INT; IW_PIT321 : INT; IW_PIT331 : INT;
IW_VE314 : INT; IW_VE324 : INT; IW_VE334 : INT;
IX_LSHH102 : BOOL; IX_LSLL103 : BOOL; IX_LSH104 : BOOL;
IX_TE312 : BOOL; IX_TE322 : BOOL; IX_TE332 : BOOL;
IX_MSE313 : BOOL; IX_MSE323 : BOOL; IX_MSE333 : BOOL;
IX_XA502 : BOOL;
(* located - outputs *)
QX_RunCmd1 : BOOL; QX_RunCmd2 : BOOL; QX_RunCmd3 : BOOL;
QX_Running1 : BOOL; QX_Running2 : BOOL; QX_Running3 : BOOL;
QX_Avail1 : BOOL; QX_Avail2 : BOOL; QX_Avail3 : BOOL;
QX_InAuto : BOOL; QX_HighLevel : BOOL; QX_SpillActive : BOOL;
QX_Tripped1 : BOOL; QX_Tripped2 : BOOL; QX_Tripped3 : BOOL;
QW_Level : INT; QW_Inflow : INT; QW_Discharge : INT;
QW_PumpsRunning : INT; QW_Speed : INT;
QW_TimeToSpill : INT; QW_TimeToLSHH : INT; QW_NetAccum : INT;
QW_RunHours1 : INT; QW_RunHours2 : INT; QW_RunHours3 : INT;
QW_VolToSpill : INT; QW_StationState : INT;
QW_PumpState1 : INT; QW_PumpState2 : INT; QW_PumpState3 : INT;
QW_DutyPump : INT; QW_AlarmWord : INT; QW_CmdAck : INT;
(* located - commands and setpoints *)
MW_Mode : INT; MW_CmdWord : INT; MW_CmdParam : INT;
MW_SpLevel : INT; MW_StartDuty : INT; MW_StartP2 : INT;
MW_StartP3 : INT; MW_StopAll : INT; MW_HighAlarm : INT;
MW_MinSpeed : INT; MW_ServiceHrs : INT;
(* located - simulation control, section 8.2 *)
MW_SimInflow : INT; MW_SimMode : INT;
MW_SimReset : INT; MW_SimTimeScale : INT;
(* process image *)
g_LevelRaw_mm : INT; g_Level_mm : INT; g_Level_m : REAL;
g_Inflow_Lps : REAL; g_Disch_Lps : REAL; g_ManifoldP_kPa : REAL;
g_PumpP_kPa : ARRAY[1..3] OF REAL;
g_Vib_mms : ARRAY[1..3] OF REAL;
g_LSHH : BOOL; g_LSLL_Wet : BOOL; g_SpillDetected : BOOL;
g_ThermalOK : ARRAY[1..3] OF BOOL;
g_SealLeak : ARRAY[1..3] OF BOOL;
g_MainsOK : BOOL;
g_cmd_Mode : INT; g_cmd_Word : INT; g_cmd_Param : INT;
g_sp_Level : INT; g_sp_StartDuty : INT; g_sp_StartP2 : INT;
g_sp_StartP3 : INT; g_sp_StopAll : INT; g_sp_HighAlarm : INT;
g_sp_MinSpeed : INT; g_sp_ServiceHrs : INT;
g_o_RunCmd : ARRAY[1..3] OF BOOL;
g_o_Running : ARRAY[1..3] OF BOOL;
g_o_Available : ARRAY[1..3] OF BOOL;
g_o_Tripped : ARRAY[1..3] OF BOOL;
g_o_InAuto : BOOL; g_o_HighLevel : BOOL; g_o_SpillActive : BOOL;
g_o_Level_mm : INT; g_o_Inflow_x10 : INT; g_o_Disch_x10 : INT;
g_o_PumpsRun : INT; g_o_Speed_x10 : INT;
g_o_TimeToSpill : INT; g_o_TimeToLSHH : INT; g_o_NetAccum : INT;
g_o_RunHours : ARRAY[1..3] OF INT;
g_o_VolToSpill : INT; g_o_StationState : INT;
g_o_PumpState : ARRAY[1..3] OF INT;
g_o_DutyPump : INT; g_o_AlarmWord : INT; g_o_CmdAck : INT;
(* defaults *)
DEF_MODE : INT; DEF_SP_LEVEL : INT; DEF_START_DUTY : INT;
DEF_START_P2 : INT; DEF_START_P3 : INT; DEF_STOP_ALL : INT;
DEF_HIGH_ALARM : INT; DEF_MIN_SPEED : INT; DEF_SERVICE_HRS : INT;
(* simulation image and control, section 8 *)
g_SimActive : BOOL; g_sim_ClearReset : BOOL;
g_simcmd_Inflow : INT; g_simcmd_Mode : INT;
g_simcmd_Reset : INT; g_simcmd_TimeScale : INT;
g_sim_Level_mm : INT; g_sim_Inflow_x10 : INT; g_sim_Disch_x10 : INT;
g_sim_ManifoldP : INT;
g_sim_PumpP : ARRAY[1..3] OF INT;
g_sim_Vib_x10 : ARRAY[1..3] OF INT;
g_sim_LSHH : BOOL; g_sim_LSLL_Wet : BOOL; g_sim_Spill : BOOL;
g_sim_ThermalOK : ARRAY[1..3] OF BOOL;
g_sim_SealLeak : ARRAY[1..3] OF BOOL;
g_sim_MainsOK : BOOL;
END_VAR
VAR
Seeded : BOOL := FALSE;
END_VAR
(* ---------------------------------------------------------------------
Seed the %MW setpoint defaults once, at first scan.
Section 9 forbids relying on retained variables, so after a runtime
restart every %MW reads 0. Writing the defaults once here means
CI Server sees real values rather than zeros, and CONTROL's
validation does not reject an all-zero image on every scan.
This is a one-shot write, not a per-scan overwrite: everything
CI Server writes afterwards survives, per section 2.
--------------------------------------------------------------------- *)
IF NOT Seeded THEN
Seeded := TRUE;
MW_Mode := DEF_MODE;
MW_CmdWord := 0;
MW_CmdParam := 0;
MW_SpLevel := DEF_SP_LEVEL;
MW_StartDuty := DEF_START_DUTY;
MW_StartP2 := DEF_START_P2;
MW_StartP3 := DEF_START_P3;
MW_StopAll := DEF_STOP_ALL;
MW_HighAlarm := DEF_HIGH_ALARM;
MW_MinSpeed := DEF_MIN_SPEED;
MW_ServiceHrs := DEF_SERVICE_HRS;
(* Simulation defaults, section 8.2: manual inflow at the dry
weather average, time scale 1. Unused in the field build. *)
MW_SimInflow := 750;
MW_SimMode := 0;
MW_SimReset := 0;
MW_SimTimeScale := 1;
END_IF;
(* ---------------------------------------------------------------------
Publish the previous scan's results to %QW / %QX
--------------------------------------------------------------------- *)
QX_RunCmd1 := g_o_RunCmd[1];
QX_RunCmd2 := g_o_RunCmd[2];
QX_RunCmd3 := g_o_RunCmd[3];
QX_Running1 := g_o_Running[1];
QX_Running2 := g_o_Running[2];
QX_Running3 := g_o_Running[3];
QX_Avail1 := g_o_Available[1];
QX_Avail2 := g_o_Available[2];
QX_Avail3 := g_o_Available[3];
QX_InAuto := g_o_InAuto;
QX_HighLevel := g_o_HighLevel;
QX_SpillActive := g_o_SpillActive;
QX_Tripped1 := g_o_Tripped[1];
QX_Tripped2 := g_o_Tripped[2];
QX_Tripped3 := g_o_Tripped[3];
QW_Level := g_o_Level_mm;
QW_Inflow := g_o_Inflow_x10;
QW_Discharge := g_o_Disch_x10;
QW_PumpsRunning := g_o_PumpsRun;
QW_Speed := g_o_Speed_x10;
QW_TimeToSpill := g_o_TimeToSpill;
QW_TimeToLSHH := g_o_TimeToLSHH;
QW_NetAccum := g_o_NetAccum;
QW_RunHours1 := g_o_RunHours[1];
QW_RunHours2 := g_o_RunHours[2];
QW_RunHours3 := g_o_RunHours[3];
QW_VolToSpill := g_o_VolToSpill;
QW_StationState := g_o_StationState;
QW_PumpState1 := g_o_PumpState[1];
QW_PumpState2 := g_o_PumpState[2];
QW_PumpState3 := g_o_PumpState[3];
QW_DutyPump := g_o_DutyPump;
QW_AlarmWord := g_o_AlarmWord;
QW_CmdAck := g_o_CmdAck;
(* ---------------------------------------------------------------------
Commands and setpoints in
--------------------------------------------------------------------- *)
g_cmd_Mode := MW_Mode;
g_cmd_Word := MW_CmdWord;
g_cmd_Param := MW_CmdParam;
g_sp_Level := MW_SpLevel;
g_sp_StartDuty := MW_StartDuty;
g_sp_StartP2 := MW_StartP2;
g_sp_StartP3 := MW_StartP3;
g_sp_StopAll := MW_StopAll;
g_sp_HighAlarm := MW_HighAlarm;
g_sp_MinSpeed := MW_MinSpeed;
g_sp_ServiceHrs := MW_ServiceHrs;
(* ---------------------------------------------------------------------
Simulation control out, and the reset acknowledgement.
SIMULATION may not touch located variables, so clearing %MW22 after
a reset happens here.
--------------------------------------------------------------------- *)
g_simcmd_Inflow := MW_SimInflow;
g_simcmd_Mode := MW_SimMode;
g_simcmd_Reset := MW_SimReset;
g_simcmd_TimeScale := MW_SimTimeScale;
IF g_sim_ClearReset THEN
MW_SimReset := 0;
END_IF;
(* ---------------------------------------------------------------------
THE MUX, section 8.1.
Field build: g_SimActive is FALSE (SIMULATION is not compiled
in and nothing ever sets it), so the located
inputs are used.
Simulation build: SIMULATION sets it TRUE every scan and the
process image comes from g_sim_* instead.
CONTROL sees identical globals either way and cannot tell which
source it is running on - that is the point of section 8.1.
--------------------------------------------------------------------- *)
IF g_SimActive THEN
g_LevelRaw_mm := g_sim_Level_mm;
g_Level_mm := g_sim_Level_mm;
g_Level_m := INT_TO_REAL(g_sim_Level_mm) / 1000.0;
g_Inflow_Lps := INT_TO_REAL(g_sim_Inflow_x10) / 10.0;
g_Disch_Lps := INT_TO_REAL(g_sim_Disch_x10) / 10.0;
g_ManifoldP_kPa := INT_TO_REAL(g_sim_ManifoldP);
g_PumpP_kPa[1] := INT_TO_REAL(g_sim_PumpP[1]);
g_PumpP_kPa[2] := INT_TO_REAL(g_sim_PumpP[2]);
g_PumpP_kPa[3] := INT_TO_REAL(g_sim_PumpP[3]);
g_Vib_mms[1] := INT_TO_REAL(g_sim_Vib_x10[1]) / 10.0;
g_Vib_mms[2] := INT_TO_REAL(g_sim_Vib_x10[2]) / 10.0;
g_Vib_mms[3] := INT_TO_REAL(g_sim_Vib_x10[3]) / 10.0;
g_LSHH := g_sim_LSHH;
g_LSLL_Wet := g_sim_LSLL_Wet;
g_SpillDetected := g_sim_Spill;
g_ThermalOK[1] := g_sim_ThermalOK[1];
g_ThermalOK[2] := g_sim_ThermalOK[2];
g_ThermalOK[3] := g_sim_ThermalOK[3];
g_SealLeak[1] := g_sim_SealLeak[1];
g_SealLeak[2] := g_sim_SealLeak[2];
g_SealLeak[3] := g_sim_SealLeak[3];
g_MainsOK := g_sim_MainsOK;
ELSE
(* ---------------------------------------------------------------------
FIELD SOURCE - analogue inputs, scaled to engineering units
--------------------------------------------------------------------- *)
g_LevelRaw_mm := IW_LIT101;
g_Level_mm := IW_LIT101;
g_Level_m := INT_TO_REAL(IW_LIT101) / 1000.0;
g_Inflow_Lps := INT_TO_REAL(IW_FIT201) / 10.0;
g_Disch_Lps := INT_TO_REAL(IW_FIT301) / 10.0;
g_ManifoldP_kPa := INT_TO_REAL(IW_PIT302);
g_PumpP_kPa[1] := INT_TO_REAL(IW_PIT311);
g_PumpP_kPa[2] := INT_TO_REAL(IW_PIT321);
g_PumpP_kPa[3] := INT_TO_REAL(IW_PIT331);
g_Vib_mms[1] := INT_TO_REAL(IW_VE314) / 10.0;
g_Vib_mms[2] := INT_TO_REAL(IW_VE324) / 10.0;
g_Vib_mms[3] := INT_TO_REAL(IW_VE334) / 10.0;
(* --- discrete inputs. Sense conventions are section 2.1's, and are
applied here so that CONTROL never has to know them. --------- *)
g_LSHH := IX_LSHH102; (* TRUE = wet *)
g_LSLL_Wet := IX_LSLL103; (* TRUE = wet; FALSE = dry, so a
broken wire stops the station *)
g_SpillDetected := IX_LSH104;
g_ThermalOK[1] := IX_TE312; (* TRUE = healthy *)
g_ThermalOK[2] := IX_TE322;
g_ThermalOK[3] := IX_TE332;
g_SealLeak[1] := IX_MSE313; (* TRUE = leak *)
g_SealLeak[2] := IX_MSE323;
g_SealLeak[3] := IX_MSE333;
g_MainsOK := IX_XA502; (* TRUE = healthy *)
END_IF;
END_PROGRAM

View file

@ -0,0 +1,23 @@
(* =====================================================================
90_config_field.st - CONFIGURATION, field build
Scan task: 100 ms cyclic, section 4.
Declaration order inside the resource sets execution order within
the task: IO_MUX first, then CONTROL. Section 8.1 requires the mux
to run before the control logic.
The pass 2 simulation build replaces this file with 91_config_sim.st,
which adds PROGRAM SIMULATION after CONTROL.
===================================================================== *)
CONFIGURATION Config0
RESOURCE Res0 ON PLC
TASK plc_task(INTERVAL := T#100ms, PRIORITY := 0);
PROGRAM inst_mux WITH plc_task : IO_MUX;
PROGRAM inst_ctl WITH plc_task : CONTROL;
END_RESOURCE
END_CONFIGURATION

View file

@ -0,0 +1,45 @@
(* =====================================================================
91_config_sim.st - CONFIGURATION, simulation build
Replaces 90_config_field.st in `--mode sim`. Identical to it except
for the third program instance.
Scan task: 100 ms cyclic, section 4. The interval must stay at
100 ms: FB_PUMP, FB_LEVEL_CTRL and FB_HEADROOM all integrate against
a hardcoded SCAN_S := 0.1, as does PROGRAM SIMULATION.
Declaration order sets execution order within the task:
SIMULATION first - models the plant, using the run commands and
speed CONTROL published on the previous scan
IO_MUX second - simulated image in, previous scan's outputs out
CONTROL third - reads only the process image
Section 8.2 says SIMULATION runs AFTER CONTROL. It runs first here,
deliberately. With it last, the first scan after every start has no
simulated image yet, so IO_MUX falls back to the (all zero) field
inputs - and an all-zero field means ThermalOK FALSE and LSLL dry,
which makes FB_PUMP latch a trip on all three units and the station
enter dry-run lockout. Those latch until a reset command, so every
demo would have to begin by clearing trips that never happened.
The cost of running it first is that SIMULATION reacts to run
commands one scan late - 100 ms at 1x. That is the same lag IO_MUX
already has on published outputs, and it is invisible next to a
3 s pump start delay.
Simulation time scaling (%MW23) applies only inside SIMULATION.
CONTROL's timers stay in real seconds; see README.md.
===================================================================== *)
CONFIGURATION Config0
RESOURCE Res0 ON PLC
TASK plc_task(INTERVAL := T#100ms, PRIORITY := 0);
PROGRAM inst_sim WITH plc_task : SIMULATION;
PROGRAM inst_mux WITH plc_task : IO_MUX;
PROGRAM inst_ctl WITH plc_task : CONTROL;
END_RESOURCE
END_CONFIGURATION