From 13a05d01359b7b0375b06871537e2b74c1aa1bea Mon Sep 17 00:00:00 2001 From: Clio Liu Date: Wed, 2 Sep 2026 15:48:49 +1000 Subject: [PATCH] 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. --- .gitignore | 10 +- 03-plc/DEPLOY.md | 235 ++++++++ 03-plc/README.md | 239 ++++++++ 03-plc/build.py | 316 +++++++++++ 03-plc/editor-devices/README.md | 52 ++ 03-plc/editor-devices/configuration.json | 11 + 03-plc/editor-devices/pin-mapping.json | 1 + 03-plc/editor-devices/servers/Modbus01.json | 9 + 03-plc/gen_project.py | 351 ++++++++++++ 03-plc/register-map.csv | 70 +++ 03-plc/src/10_globals.st | 252 +++++++++ 03-plc/src/20_fb_pump.st | 141 +++++ 03-plc/src/21_fb_duty_selector.st | 128 +++++ 03-plc/src/22_fb_level_control.st | 74 +++ 03-plc/src/23_fb_headroom.st | 97 ++++ 03-plc/src/30_prog_control.st | 595 ++++++++++++++++++++ 03-plc/src/40_prog_simulation.st | 308 ++++++++++ 03-plc/src/50_prog_io_mux.st | 292 ++++++++++ 03-plc/src/90_config_field.st | 23 + 03-plc/src/91_config_sim.st | 45 ++ 20 files changed, 3248 insertions(+), 1 deletion(-) create mode 100644 03-plc/DEPLOY.md create mode 100644 03-plc/README.md create mode 100644 03-plc/build.py create mode 100644 03-plc/editor-devices/README.md create mode 100644 03-plc/editor-devices/configuration.json create mode 100644 03-plc/editor-devices/pin-mapping.json create mode 100644 03-plc/editor-devices/servers/Modbus01.json create mode 100644 03-plc/gen_project.py create mode 100644 03-plc/register-map.csv create mode 100644 03-plc/src/10_globals.st create mode 100644 03-plc/src/20_fb_pump.st create mode 100644 03-plc/src/21_fb_duty_selector.st create mode 100644 03-plc/src/22_fb_level_control.st create mode 100644 03-plc/src/23_fb_headroom.st create mode 100644 03-plc/src/30_prog_control.st create mode 100644 03-plc/src/40_prog_simulation.st create mode 100644 03-plc/src/50_prog_io_mux.st create mode 100644 03-plc/src/90_config_field.st create mode 100644 03-plc/src/91_config_sim.st diff --git a/.gitignore b/.gitignore index b3dbfc6..d9ef0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,13 @@ venv/ .pytest_cache/ # --- Build output ------------------------------------------------------------ -# Rules are added as each generator is brought across during the audit. *.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/ diff --git a/03-plc/DEPLOY.md b/03-plc/DEPLOY.md new file mode 100644 index 0000000..4d14c78 --- /dev/null +++ b/03-plc/DEPLOY.md @@ -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 0–1023 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/`. diff --git a/03-plc/README.md b/03-plc/README.md new file mode 100644 index 0000000..8ae2997 --- /dev/null +++ b/03-plc/README.md @@ -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 0–1023 and `%MW` starts at **HR 1024**. So `%MW3` (level setpoint) is +**HR 1027**. `register-map.csv` carries the resolved addresses — build the +SCADA-side point list in `04-scada/modbus/` from that file, never from §2 of the +brief. + +**`%QW17` (alarm bitmask) must be read as unsigned 16-bit.** Bit 15 does not fit a +signed INT, so values ≥ 32768 fold into the negative half of the word. +**`%QW7` (net accumulation) is genuinely signed** — a live read returned `65532`, +which is −4. + +Two conditions must both hold for port 502 to answer, and neither is a container +setting: a program must be **running**, and the Editor project must define a +Modbus **Server** so the runtime's `modbus_slave` plugin is enabled. See +`DEPLOY.md` §A3. + +## Setpoints: seeded defaults vs live tuning + +`IO_MUX` writes the `%MW` setpoint defaults **once, on first scan**, so CI Server +sees real values rather than zeros after a restart. It is a one-shot write — +**anything CI Server writes afterwards survives** (CTL-003 §2.3). + +These are operator-adjustable. They are meant to be changed from demo to demo, and +they are: the live station currently runs lower start levels than the seeded +defaults, so it cycles faster and reads better on screen. + +| `%MW` | Seeded default (`10_globals.st`) | Live 2026-09-02 | +|---|---|---| +| 3 Level control setpoint | 4200 mm | 2700 | +| 4 Start duty level | 4000 mm | 1800 | +| 5 Start pump 2 | 4500 mm | 3600 | +| 6 Start pump 3 | 5000 mm | 4200 | +| 7 Stop all | 1000 mm | 900 | +| 8 High level alarm | 5200 mm | 4560 | +| 9 Minimum drive speed | 380 | 380 | +| 10 Service interval | 4000 h | 4000 | + +> [!IMPORTANT] +> **Tuning lives only in the container's volume, not in this repo.** Restart the +> runtime and every setpoint reverts to the seeded default, and the station will +> behave differently on stage. If a particular set of values is the one you want +> to demo, write them down — the repo cannot restore them for you. + +## Simulation + +`--mode sim` adds `PROGRAM SIMULATION`, which models the wet well, the pumps and +the field instruments. `IO_MUX` then feeds `CONTROL` from the model instead of +`%IW`/`%IX`. **No control POU changes between the two builds** — that is the point +of §8.1. + +| Register | HR | Meaning | +|---|---|---| +| `%MW20` | 1044 | Manual inflow, L/s × 10 (scenario 0 only) | +| `%MW21` | 1045 | Scenario: 0 manual · 1 diurnal · 2 wet weather · 3 demo reference | +| `%MW22` | 1046 | Write 1 to reset the scenario; self-clearing | +| `%MW23` | 1047 | Time scale 1–120, default 1 | + +Scenarios (§8.2): **1** is a 40–110 L/s sinusoid over a 24 h simulated period; +**2** ramps to 300 L/s over 20 simulated minutes, holds 40, decays over 90; +**3** is the §7.3 demo reference — level starts at 4.00 m with 165 L/s inflow. + +> ⚠️ **Reset does not clear run hours or trip states.** Those live in `FB_PUMP`, +> not in the simulation, and the simulation is forbidden from reaching into +> control state. Use the control commands: command 1 resets trips, command 5 +> resets run hours (`%MW1`/`%MW2`). Pass-2 test 5 asks for one reset that does all +> of it; here it takes two writes. + +### The time-scaling caveat + +`%MW23` scales **simulation** time only. `CONTROL`'s timers stay in real seconds +and must — `FB_PUMP`'s no-flow trip is a `T#20s` TON, and scaling both time bases +is what makes these demos impossible to debug. + +The consequence is real and intended: at 60×, a 5-minute minimum-run timer covers +5 simulated hours. Wrong, but harmless for a demo — and obvious, rather than a +subtly mis-tuned controller. For the same reason the per-pump **start delay (3 s) +runs on real time**: scaled, at 60× it would elapse in 50 ms and the no-flow trip +could never be demonstrated, which is the one thing §8.2 says the delay is for. + +Use 1× while testing logic, 30–60× when presenting. + +## Reset between demos + +Restart the runtime. Nothing is retained (§9), and `IO_MUX` re-seeds the `%MW` +defaults on first scan — see the warning about tuning above. + +--- + +## Decisions and deviations + +Everything below departs from a literal reading of the brief. Each is a judgement +call, not an oversight. + +**FB_PUMP has three inputs the brief does not list.** `MinOffBypass` (LSHH must +override the min-off timer, §4.1), `ServiceInterval` (`ServiceDue` is defined +against `%MW10`) and `ResetHours` (command 5). Each specified output is otherwise +unreachable. + +**LSHH bypasses the start stagger as well as the min-off timers.** §4.1 and §5 +mention only min-off, but test 10 expects *all available units at 50.0 Hz* on LSHH, +and a 30 s-per-unit stagger would take 60 s to get there. Treated as an emergency +response. + +**The stagger is applied before duty selection, not after.** §4.5 lists it as step +11. It limits how many units *may start*, which is an input to selection rather +than a correction applied to its result. + +**No-flow is monitored continuously after the 20 s window**, not sampled once at +t = 20 s. Either reading satisfies test 8; this one also catches a loss of flow +while running. + +**The dry-run lockout is reset by command 1** (reset all trips), and only once +level has recovered above stop level, per §5. §3.3 defines no dedicated command. + +### Simulation deviations + +**`%MW20–23` are declared in both builds**, not "simulation build only" as §2 says. +Located variables may only be declared in `10_globals.st`, and splitting that file +would need a second file with located-variable permission, weakening the §10 grep +check. In the field build the registers are simply unused. `register-map.csv` marks +them `SIM ONLY`. + +**Scenario 3 holds inflow at exactly 165 L/s** rather than "then ramp slowly" +(§8.2). The ramp rate is unspecified, and pass-2 test 2 pins the outcome precisely: +spill at ~89 simulated minutes from 4.00 m. That figure only holds at a constant +165 L/s against one pump at 120 L/s — 240 m3 / 45 L/s = 5333 s = 88.9 min, which is +also the `%QW5` countdown the test expects. Any ramp makes both numbers wrong. + +**Plant health is constant** (`ThermalOK` TRUE, `SealLeak` FALSE, `MainsOK` TRUE). +Fault injection is not modelled because §8 does not ask for it. Test 4 needs PU-301 +*locked out*, which is a control command rather than a simulated fault, so it is +reachable. A thermal or seal-leak trip is not demonstrable without adding injection +registers. + +**`SIMULATION` runs FIRST in the sim build, not after `CONTROL` as §8.2 says.** +Found by running it: with `SIMULATION` last, the first scan after every start has +no simulated image, so `IO_MUX` falls back to the all-zero field inputs — +`ThermalOK` FALSE and `LSLL` dry — and `FB_PUMP` **latches a trip on all three +units** while the station enters dry-run lockout. Both latch until a reset command, +so every demo would begin by clearing trips that never happened. Observed live +2026-08-14: `%QW12 = 5`, `%QW17 = 116`, all three trip coils true, until command 1 +was issued by hand. + +Running it first costs one scan of staleness — `SIMULATION` acts on the run +commands and speed `CONTROL` published 100 ms earlier. That is the same lag +`IO_MUX` already has on published outputs, and it is invisible beside a 3 s pump +start delay. + +**`FB_HEADROOM`'s inflow filter is primed with its first sample** rather than +ramping from zero. Without this, test 14's reference figure takes ~2 minutes to +settle and cannot be reproduced on demand, which §7 requires. + +**Outputs are published one scan late.** §8.1 requires `IO_MUX` to be the only POU +touching located variables *and* to run before `CONTROL`, so the `%QW`/`%QX` values +it writes are the previous scan's. 100 ms lag on published values only; no control +decision is affected. + +**Three explicit `FB_PUMP` instances**, not `ARRAY[1..3] OF FB_PUMP` — arrays of +function blocks are the kind of construct §9 warns about. + +**`SCAN_S := 0.1` is a constant inside each FB** that uses scan time for +integration (run hours, PI integral, inflow filter). It must match the `TASK +INTERVAL` in the configuration file. Changing one without the other silently +changes controller tuning and run-hour accumulation. + +--- + +## Status — 2026-09-02 + +| | | +|---|---| +| Simulation build | **Compiled, deployed and running** on `yau-sls-poc-lin001`, polled live by CI Server on `yau-poc-cicore1` | +| Register map | 69 points, **reproduces byte-identically** from `build.py` | +| Modbus contract | Verified against the live PLC — addresses, segmentation and signedness all as documented | +| Field build | Compiled and verified 2026-08-14 (65/65 points, 11/11 RW). Superseded by the sim build now running | + +### ⚠️ 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. diff --git a/03-plc/build.py b/03-plc/build.py new file mode 100644 index 0000000..ff4b2a9 --- /dev/null +++ b/03-plc/build.py @@ -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() diff --git a/03-plc/editor-devices/README.md b/03-plc/editor-devices/README.md new file mode 100644 index 0000000..715fd02 --- /dev/null +++ b/03-plc/editor-devices/README.md @@ -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. diff --git a/03-plc/editor-devices/configuration.json b/03-plc/editor-devices/configuration.json new file mode 100644 index 0000000..2799099 --- /dev/null +++ b/03-plc/editor-devices/configuration.json @@ -0,0 +1,11 @@ +{ + "deviceBoard": "OpenPLC Runtime v4", + "communicationPort": "", + "runtimeIpAddress": "localhost", + "vendorScreenData": {}, + "vendorScreenDataByBoard": { + "OpenPLC Simulator": {}, + "OpenPLC Runtime v4": {} + }, + "selectedPlatformOptions": {} +} \ No newline at end of file diff --git a/03-plc/editor-devices/pin-mapping.json b/03-plc/editor-devices/pin-mapping.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/03-plc/editor-devices/pin-mapping.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/03-plc/editor-devices/servers/Modbus01.json b/03-plc/editor-devices/servers/Modbus01.json new file mode 100644 index 0000000..36e4125 --- /dev/null +++ b/03-plc/editor-devices/servers/Modbus01.json @@ -0,0 +1,9 @@ +{ + "name": "Modbus01", + "protocol": "modbus-tcp", + "modbusSlaveConfig": { + "enabled": true, + "networkInterface": "0.0.0.0", + "port": 502 + } +} \ No newline at end of file diff --git a/03-plc/gen_project.py b/03-plc/gen_project.py new file mode 100644 index 0000000..5bfeaeb --- /dev/null +++ b/03-plc/gen_project.py @@ -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[A-Za-z_][A-Za-z0-9_]*)\s* + (?:AT\s+(?P%[A-Za-z]+[0-9.]+)\s*)? + :\s*(?P.+?) + (?:\s*:=\s*(?P.+?))? + \s*;""", + re.VERBOSE, +) + +ARRAY_TYPE = re.compile( + r"^ARRAY\s*\[\s*(?P[^\]]+?)\s*\]\s*OF\s+(?P[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^[ \t]*VAR(?:_INPUT|_OUTPUT|_IN_OUT|_TEMP|_EXTERNAL)?\b[^\n]*\n)" + r"(?P.*?)" + r"(?P^[ \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\s+CONSTANT)?(?P.*?)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\w+)\s*\(\s*INTERVAL\s*:=\s*(?P[^,)]+)" + r"(?:\s*,\s*PRIORITY\s*:=\s*(?P\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\w+)\s+WITH\s+(?P\w+)\s*:\s*(?P\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() diff --git a/03-plc/register-map.csv b/03-plc/register-map.csv new file mode 100644 index 0000000..e7ffc02 --- /dev/null +++ b/03-plc/register-map.csv @@ -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 diff --git a/03-plc/src/10_globals.st b/03-plc/src/10_globals.st new file mode 100644 index 0000000..cb2e807 --- /dev/null +++ b/03-plc/src/10_globals.st @@ -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 diff --git a/03-plc/src/20_fb_pump.st b/03-plc/src/20_fb_pump.st new file mode 100644 index 0000000..e0320b6 --- /dev/null +++ b/03-plc/src/20_fb_pump.st @@ -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 diff --git a/03-plc/src/21_fb_duty_selector.st b/03-plc/src/21_fb_duty_selector.st new file mode 100644 index 0000000..0150b42 --- /dev/null +++ b/03-plc/src/21_fb_duty_selector.st @@ -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 diff --git a/03-plc/src/22_fb_level_control.st b/03-plc/src/22_fb_level_control.st new file mode 100644 index 0000000..bf4e103 --- /dev/null +++ b/03-plc/src/22_fb_level_control.st @@ -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 diff --git a/03-plc/src/23_fb_headroom.st b/03-plc/src/23_fb_headroom.st new file mode 100644 index 0000000..13f3f11 --- /dev/null +++ b/03-plc/src/23_fb_headroom.st @@ -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 diff --git a/03-plc/src/30_prog_control.st b/03-plc/src/30_prog_control.st new file mode 100644 index 0000000..513fb69 --- /dev/null +++ b/03-plc/src/30_prog_control.st @@ -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 diff --git a/03-plc/src/40_prog_simulation.st b/03-plc/src/40_prog_simulation.st new file mode 100644 index 0000000..8245e0b --- /dev/null +++ b/03-plc/src/40_prog_simulation.st @@ -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 diff --git a/03-plc/src/50_prog_io_mux.st b/03-plc/src/50_prog_io_mux.st new file mode 100644 index 0000000..2c21236 --- /dev/null +++ b/03-plc/src/50_prog_io_mux.st @@ -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 diff --git a/03-plc/src/90_config_field.st b/03-plc/src/90_config_field.st new file mode 100644 index 0000000..2129449 --- /dev/null +++ b/03-plc/src/90_config_field.st @@ -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 diff --git a/03-plc/src/91_config_sim.st b/03-plc/src/91_config_sim.st new file mode 100644 index 0000000..8de14ea --- /dev/null +++ b/03-plc/src/91_config_sim.st @@ -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