feat(plc): ST sources, generators and the Modbus contract
The PLC program from the old repo's 04-plc/, flattened into one folder and
checked against the running system.
Verified during the move:
- build.py regenerates register-map.csv byte-identically (69 points)
- polled the live PLC: the SIMULATION build is what is deployed and
running, %MW21=2 wet weather, values moving, run hours accumulating
- addresses, %MW HR1024 segmentation and %QW17/%QW7 signedness all
match the map
Corrections against the old repo:
- 10_globals.st header cited WRPS-CTL-002 (the FDS); it means CTL-003
- build.py wrote the map to its parent directory; now beside itself
- deploy/README.md was a single-file folder; now DEPLOY.md
- dropped the empty editor-devices/remote/
- README no longer claims the simulation build is uncompiled - it is
the one running
Two open items are now stated plainly rather than buried:
- none of the 20 acceptance tests in CTL-003 have ever been run
- the OpenPLC Editor lived only on the retired dev-ubuntu host, so
there is currently NO route to deploy a new program (DEPLOY.md 0)
Documents the setpoint distinction: IO_MUX seeds %MW defaults once at
first scan, operators retune them live, and that tuning exists only in
the container volume - a restart reverts it.
This commit is contained in:
parent
f6b32b42a1
commit
13a05d0135
20 changed files with 3248 additions and 1 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -28,5 +28,13 @@ venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
# --- Build output ------------------------------------------------------------
|
# --- Build output ------------------------------------------------------------
|
||||||
# Rules are added as each generator is brought across during the audit.
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Generated by 03-plc/build.py - a single-file review artefact. Rebuild it.
|
||||||
|
# NOTE: 03-plc/register-map.csv IS committed even though it is generated: it is
|
||||||
|
# the PLC-side half of the Modbus contract, so changes must show up in diffs.
|
||||||
|
03-plc/build/
|
||||||
|
|
||||||
|
# Generated by 03-plc/gen_project.py - the OpenPLC Editor v4 project, rebuilt
|
||||||
|
# from src/. Regenerate it; never hand-edit it, never commit it.
|
||||||
|
03-plc/editor-project/
|
||||||
|
|
|
||||||
235
03-plc/DEPLOY.md
Normal file
235
03-plc/DEPLOY.md
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
# Deploying a program to the OpenPLC runtime
|
||||||
|
|
||||||
|
Headless upload is **not available**: Runtime v4 accepts only STruC++ codegen
|
||||||
|
output, and that compiler ships solely inside the OpenPLC Editor's GUI bundle.
|
||||||
|
The repo prepares the project; a human drives the Editor.
|
||||||
|
|
||||||
|
The chain — repo `src/` → generated project → STruC++ → runtime compile → running
|
||||||
|
PLC → Modbus TCP read — **was proven end to end on 2026-08-14**, on the
|
||||||
|
now-retired `dev-ubuntu` host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. ⚠️ Read this first — the toolchain is not currently available
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **You cannot deploy a new program today.** The OpenPLC Editor v4 AppImage lived
|
||||||
|
> only on `dev-ubuntu`, which has been retired. It is **not installed on
|
||||||
|
> `yau-sls-poc-lin001`**, and there is no `wrps` Editor project there — the home
|
||||||
|
> directory holds only `openplc-compose.yml` and the migration tarballs.
|
||||||
|
>
|
||||||
|
> The PLC that is running is a **committed Docker image** (`docker commit`) with an
|
||||||
|
> already-compiled program baked into its writable layer. It runs fine and will
|
||||||
|
> keep running. But editing `src/` and regenerating the project produces something
|
||||||
|
> with nowhere to go.
|
||||||
|
|
||||||
|
**To restore the ability to deploy, someone must:**
|
||||||
|
|
||||||
|
1. Install OpenPLC Editor v4.2.11 somewhere with a graphical desktop that can
|
||||||
|
reach `10.0.0.17:8443`. Options: a desktop VM on the `10.0.0.0/24` LAN, or a
|
||||||
|
workstation on the WireGuard VPN.
|
||||||
|
2. Recreate the Editor project — §A below is the complete list of what is *not*
|
||||||
|
generated and must be configured by hand.
|
||||||
|
3. Connect it to the runtime at `https://10.0.0.17:8443` (not `localhost` — the
|
||||||
|
Editor is no longer on the same host as the runtime).
|
||||||
|
4. Prove the chain with a trivial change before relying on it.
|
||||||
|
|
||||||
|
Until that is done, treat the PLC program as **frozen**. Everything below §A is
|
||||||
|
the runbook for once it is restored, and is accurate apart from the host and
|
||||||
|
address changes noted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. One-time setup (redo on a fresh project)
|
||||||
|
|
||||||
|
These steps are **not** generated by `gen_project.py` and are easy to miss. A
|
||||||
|
reference copy of the resulting files is in `editor-devices/`.
|
||||||
|
|
||||||
|
### A1. Create the project
|
||||||
|
|
||||||
|
**WHERE:** OpenPLC Editor, on the machine you install it on.
|
||||||
|
**WHAT:** New Project, name `wrps`.
|
||||||
|
**WHAT FOR:** creates the folder skeleton the generator writes into. The base
|
||||||
|
program language does not matter — the generator replaces `pous/` entirely,
|
||||||
|
including deleting the placeholder `main`.
|
||||||
|
|
||||||
|
### A2. Point it at the runtime
|
||||||
|
|
||||||
|
**WHERE:** Editor → **Device → Configuration**.
|
||||||
|
**WHAT:** Device `OpenPLC Runtime V4`, IP **`10.0.0.17`**, port `8443`, runtime
|
||||||
|
user `admin` (password in `02-environment/secrets.local.md`, git-ignored).
|
||||||
|
**WHAT FOR:** identifies the target board and the REST endpoint.
|
||||||
|
|
||||||
|
> The reference copy in `editor-devices/configuration.json` still says
|
||||||
|
> `localhost`, which was correct when the Editor ran on the same host as the
|
||||||
|
> runtime. It is not correct now — use the LAN address.
|
||||||
|
|
||||||
|
### A3. Add the Modbus **server** — the step easiest to get wrong
|
||||||
|
|
||||||
|
**WHERE:** Editor → **Device → Servers** → add a Modbus TCP server (`Modbus01`).
|
||||||
|
**WHAT:** **Enable Server** on; leave `networkInterface` `0.0.0.0` and port `502`.
|
||||||
|
**WHAT FOR:** this makes the Editor ship `conf/modbus_slave.json` in the upload
|
||||||
|
bundle, which flips the runtime's `modbus_slave` plugin to enabled. Without it the
|
||||||
|
plugin stays off and **nothing ever binds port 502**, no matter how the container
|
||||||
|
is configured.
|
||||||
|
|
||||||
|
> `0.0.0.0` here is correct and is **not** in conflict with the container binding
|
||||||
|
> 502 to `10.0.0.17`. This setting is *inside* the container — the slave listens on
|
||||||
|
> all of the container's own interfaces. Docker's port publishing is what restricts
|
||||||
|
> exposure to the host's LAN address. Do not "fix" one to match the other.
|
||||||
|
|
||||||
|
> ⚠️ **Do not use Device → Remote Devices for this.** That is the Modbus
|
||||||
|
> *master*: it makes the PLC poll someone else, inverting the architecture
|
||||||
|
> invariant, and its IO groups claim `%IW` addresses that collide with the field
|
||||||
|
> inputs in `src/10_globals.st`. CI Server needs no entry in the PLC project at
|
||||||
|
> all — it is a client and simply connects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Every deployment
|
||||||
|
|
||||||
|
### B0. Generate and install the project
|
||||||
|
|
||||||
|
**WHERE:** wherever this repo is checked out.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python gen_project.py --mode sim --out editor-project
|
||||||
|
```
|
||||||
|
|
||||||
|
then copy `editor-project/project.json` and `editor-project/pous/` over the
|
||||||
|
Editor project folder.
|
||||||
|
|
||||||
|
**WHAT FOR:** the Editor has **no flat-`.st` import** — a v4 project is a folder
|
||||||
|
(`project.json` + one file per POU). This also writes the located addresses
|
||||||
|
(`%IW0`, `%QX0.0`, …) straight into `project.json`, which the Global Variables grid
|
||||||
|
will not let you type by hand.
|
||||||
|
|
||||||
|
`devices/` and `build/` are never touched — only `project.json` and `pous/`.
|
||||||
|
|
||||||
|
**This repo is canonical. Never edit the Editor project in place** — changes there
|
||||||
|
are wiped on the next generate. Fixes go into `src/`.
|
||||||
|
|
||||||
|
### B1. Launch the Editor
|
||||||
|
|
||||||
|
**WHERE:** a graphical desktop session — not over SSH.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/Applications/OpenPLC.Editor-4.2.11.AppImage --no-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
**WHAT FOR:** `--no-sandbox` is required (Electron) and `libfuse2t64` must be
|
||||||
|
installed, or the AppImage exits immediately.
|
||||||
|
|
||||||
|
### B2. Open the project
|
||||||
|
|
||||||
|
> ⚠️ **The Editor must be CLOSED while files are copied in** — not merely showing
|
||||||
|
> a different tab. It holds `project.json` in memory and writes that copy back on
|
||||||
|
> its next save, silently discarding whatever was installed.
|
||||||
|
>
|
||||||
|
> Correct order: **quit the Editor → copy and verify the md5 → reopen → build.**
|
||||||
|
>
|
||||||
|
> This has actually happened (2026-08-14). The symptom is confusing because it is
|
||||||
|
> *partial*: new POU files survive — the Editor discovers those by scanning
|
||||||
|
> `pous/` — while `project.json` reverts, so the build fails with
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> error: VAR_EXTERNAL 'G_SIM_LEVEL_MM' in program 'SIMULATION'
|
||||||
|
> has no matching VAR_GLOBAL declaration
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> for every *new* global, while pre-existing globals resolve normally. New program
|
||||||
|
> instances vanish from the task list the same way. If you see that pattern, the ST
|
||||||
|
> is probably fine — check `project.json` before touching the code.
|
||||||
|
|
||||||
|
### B3. Connect to the runtime — required before any upload
|
||||||
|
|
||||||
|
**WHERE:** Editor → **Device → Configuration** → **Connect to OpenPLC Runtime**.
|
||||||
|
**WHAT:** authenticate as `admin`.
|
||||||
|
**WHAT FOR:** Upload and Start PLC are unavailable until the session is connected.
|
||||||
|
The console confirms with `[connection] status -> connected`.
|
||||||
|
|
||||||
|
### B4. Build and upload
|
||||||
|
|
||||||
|
**WHERE:** the left icon rail: Search · Open/Close Toolbox · **Build Options** ·
|
||||||
|
**Start PLC** · Debugger. There is no separate upload button.
|
||||||
|
|
||||||
|
> ⚠️ **Turn *compile-only* mode off.** If the console ends with *"Compile only
|
||||||
|
> mode — skipping upload to runtime"*, nothing was uploaded. In that mode the
|
||||||
|
> composed bundle is **discarded** rather than written to `build/`, so there is no
|
||||||
|
> zip left behind for any automated upload path either.
|
||||||
|
|
||||||
|
**WHAT FOR:** the Editor runs STruC++ (v0.6.2), composes the bundle, POSTs it to
|
||||||
|
`/api/upload-file`, and the runtime rebuilds `new_libplc.so` with Make while the
|
||||||
|
Editor polls `/api/compilation-status`.
|
||||||
|
|
||||||
|
### B5. Start the PLC
|
||||||
|
|
||||||
|
The runtime opens its **Modbus slave only while a program is running**.
|
||||||
|
|
||||||
|
### B6. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ../05-tests/verify_modbus.py --host 10.0.0.17 --port 502 --unit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
`10.0.0.17` is only routable from inside the VNet or over the WireGuard VPN. From
|
||||||
|
outside, poll from a throwaway container on the host instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh lin001 "docker run --rm --network openplc-net python:3.12-alpine sh -c 'pip install -q pymodbus && python -c \"...\"'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What "working" looks like in the Editor log:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Final state - modbus_slave: enabled=True
|
||||||
|
PLC started.
|
||||||
|
Upload complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas, each one actually hit
|
||||||
|
|
||||||
|
**The Editor's declaration parser** is separate from the body compiler and rejects
|
||||||
|
three things inside a `VAR` block:
|
||||||
|
|
||||||
|
- comment-only lines, e.g. `(* process image in *)`
|
||||||
|
- a `(* … *)` comment that wraps onto the next line
|
||||||
|
- more than one declaration on a line, e.g. `A : INT; B : INT;`
|
||||||
|
|
||||||
|
A trailing single-line comment is fine. `gen_project.py` handles all three, so
|
||||||
|
`src/` keeps its comments and formatting. Symptom of a regression: *"could not be
|
||||||
|
fully parsed … preserved as raw text"*, then *"No variable defined in POU"*.
|
||||||
|
|
||||||
|
**The Editor class enum has no `constant`** (`input|output|inOut|external|local|
|
||||||
|
temp|global`), so `VAR_GLOBAL CONSTANT` entries are emitted as ordinary globals
|
||||||
|
with their initial values. Behaviour is unchanged — nothing writes them — but they
|
||||||
|
are no longer compiler-enforced read-only. The generator warns each run.
|
||||||
|
|
||||||
|
**Task interval must be `T#100ms`.** The generator takes it from the configuration
|
||||||
|
file in `src/`. Run hours, the PI integral and the inflow filter all hardcode
|
||||||
|
`SCAN_S := 0.1`; a mismatch silently skews tuning and accumulation.
|
||||||
|
|
||||||
|
**EtherCAT gets enabled** on the runtime from an `ethercat.json` the Editor project
|
||||||
|
emits by default. Harmless so far, but it is a plugin running for no reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes that will bite otherwise
|
||||||
|
|
||||||
|
- **`%MW` is not holding register 0.** `%QW` occupies HR 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/`.
|
||||||
239
03-plc/README.md
Normal file
239
03-plc/README.md
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
# 03-plc — the PLC program
|
||||||
|
|
||||||
|
IEC 61131-3 Structured Text for the Waterloo Road Pump Station, implementing
|
||||||
|
`01-design/WRPS-CTL-003`. Control logic and a wet-well simulation, in separate
|
||||||
|
POUs, from one source tree.
|
||||||
|
|
||||||
|
**`src/` is canonical.** Everything else here is generated from it, or describes
|
||||||
|
how it reaches the runtime.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/*.st the program - concatenated in lexical order
|
||||||
|
build.py builds a flat .st AND generates register-map.csv
|
||||||
|
gen_project.py generates the OpenPLC Editor v4 project (the deploy artefact)
|
||||||
|
register-map.csv GENERATED - the PLC half of the Modbus contract
|
||||||
|
editor-devices/ the GUI-configured half of the Editor project (reference copy)
|
||||||
|
DEPLOY.md how a program actually reaches the runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python build.py --mode field # control only
|
||||||
|
python build.py --mode sim # control + PROGRAM SIMULATION
|
||||||
|
python gen_project.py --mode sim --out editor-project
|
||||||
|
```
|
||||||
|
|
||||||
|
`build.py` emits `build/wrps.st` (git-ignored) and regenerates `register-map.csv`.
|
||||||
|
**Never hand-edit the register map** — change the `REGISTERS` table in `build.py`
|
||||||
|
and rebuild. The build refuses to emit if any file other than `src/10_globals.st`
|
||||||
|
or `src/50_prog_io_mux.st` references a located variable in code (CTL-003 §8.1,
|
||||||
|
§10). Comments may mention them.
|
||||||
|
|
||||||
|
`gen_project.py` emits the Editor project, which is what actually gets deployed —
|
||||||
|
see `DEPLOY.md`.
|
||||||
|
|
||||||
|
## What is running right now
|
||||||
|
|
||||||
|
**The simulation build**, verified live on 2026-09-02 by polling the PLC:
|
||||||
|
|
||||||
|
```
|
||||||
|
level 2696 mm · inflow 74.3 L/s · discharge 75.0 L/s · 1 pump running · 40.1 Hz
|
||||||
|
run hours 113/107/100 h · vol to spill 396 m3 · state 2 (Pumping) · alarms 0x0000
|
||||||
|
%MW21 = 2 (wet weather scenario) · %MW23 = 1 (real time)
|
||||||
|
```
|
||||||
|
|
||||||
|
All `%IW` read zero — the mux is taking simulated values, as designed. Values move
|
||||||
|
between polls; run hours accumulate.
|
||||||
|
|
||||||
|
## The Modbus contract
|
||||||
|
|
||||||
|
**`%MW` is not holding register 0.** The v4 slave segments holding registers: `%QW`
|
||||||
|
occupies HR 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.
|
||||||
316
03-plc/build.py
Normal file
316
03-plc/build.py
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""WRPS-CTL-003 build.
|
||||||
|
|
||||||
|
Concatenates src/*.st in lexical order into a single flat .st file for
|
||||||
|
import into OpenPLC Editor v4.
|
||||||
|
|
||||||
|
python build.py --mode field
|
||||||
|
python build.py --mode sim
|
||||||
|
|
||||||
|
Also emits register-map.csv (the PLC-side point list) from the
|
||||||
|
same source of truth as the ST, so CI Server tag configuration and the
|
||||||
|
PLC cannot drift apart (brief section 9).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
SRC = ROOT / "src"
|
||||||
|
BUILD = ROOT / "build"
|
||||||
|
|
||||||
|
# The register map is the PLC-side point list and the upstream half of
|
||||||
|
# the Modbus contract. The SCADA-side point list is a separate,
|
||||||
|
# differently shaped file kept under 04-scada/modbus/ and derived from
|
||||||
|
# this one - never the other way round.
|
||||||
|
PLC_DIR = ROOT # register-map.csv sits beside this script
|
||||||
|
|
||||||
|
# Files excluded per mode. Everything else in src/ is concatenated.
|
||||||
|
EXCLUDE = {
|
||||||
|
"field": {"40_prog_simulation.st", "91_config_sim.st"},
|
||||||
|
"sim": {"90_config_field.st"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Files that must exist for a mode to build. Listed explicitly rather
|
||||||
|
# than globbed so that a missing file fails loudly instead of silently
|
||||||
|
# emitting a short file (brief section 1).
|
||||||
|
REQUIRED = {
|
||||||
|
"field": [
|
||||||
|
"10_globals.st",
|
||||||
|
"20_fb_pump.st",
|
||||||
|
"21_fb_duty_selector.st",
|
||||||
|
"22_fb_level_control.st",
|
||||||
|
"23_fb_headroom.st",
|
||||||
|
"30_prog_control.st",
|
||||||
|
"50_prog_io_mux.st",
|
||||||
|
"90_config_field.st",
|
||||||
|
],
|
||||||
|
"sim": [
|
||||||
|
"10_globals.st",
|
||||||
|
"20_fb_pump.st",
|
||||||
|
"21_fb_duty_selector.st",
|
||||||
|
"22_fb_level_control.st",
|
||||||
|
"23_fb_headroom.st",
|
||||||
|
"30_prog_control.st",
|
||||||
|
"40_prog_simulation.st",
|
||||||
|
"50_prog_io_mux.st",
|
||||||
|
"91_config_sim.st",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only these files may reference located variables (brief sections 8.1, 10).
|
||||||
|
LOCATED_ALLOWED = {"10_globals.st", "50_prog_io_mux.st"}
|
||||||
|
LOCATED_TOKENS = ("%I", "%Q", "%M")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Register map - the single source of truth for section 2.
|
||||||
|
#
|
||||||
|
# IMPORTANT: on OpenPLC Runtime v4 the Modbus slave segments holding
|
||||||
|
# registers. %QW occupies holding registers 0-1023 and %MW starts at
|
||||||
|
# 1024, so %MW0 is holding register 1024. This offset is applied in
|
||||||
|
# modbus_address() below and is the reason this file is generated
|
||||||
|
# rather than hand-maintained.
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
MW_HR_OFFSET = 1024
|
||||||
|
|
||||||
|
# (iec, tag, description, units, scaling, access)
|
||||||
|
REGISTERS = [
|
||||||
|
# --- 2.1 field inputs -------------------------------------------
|
||||||
|
("%IW0", "LIT-101", "Wet well level", "mm", "1", "R"),
|
||||||
|
("%IW1", "FIT-201", "Inlet flow", "L/s", "x10", "R"),
|
||||||
|
("%IW2", "FIT-301", "Discharge flow", "L/s", "x10", "R"),
|
||||||
|
("%IW3", "PIT-302", "Manifold pressure", "kPa", "1", "R"),
|
||||||
|
("%IW4", "PIT-311", "PU-301 discharge pressure", "kPa", "1", "R"),
|
||||||
|
("%IW5", "PIT-321", "PU-302 discharge pressure", "kPa", "1", "R"),
|
||||||
|
("%IW6", "PIT-331", "PU-303 discharge pressure", "kPa", "1", "R"),
|
||||||
|
("%IW7", "VE-314", "PU-301 bearing vibration", "mm/s", "x10", "R"),
|
||||||
|
("%IW8", "VE-324", "PU-302 bearing vibration", "mm/s", "x10", "R"),
|
||||||
|
("%IW9", "VE-334", "PU-303 bearing vibration", "mm/s", "x10", "R"),
|
||||||
|
("%IX0.0", "LSHH-102", "High high level (TRUE = wet)", "", "", "R"),
|
||||||
|
("%IX0.1", "LSLL-103", "Low low level (TRUE = wet, FALSE = dry)", "", "", "R"),
|
||||||
|
("%IX0.2", "LSH-104", "Spill detected (TRUE = spilling)", "", "", "R"),
|
||||||
|
("%IX0.3", "TE-312", "PU-301 motor thermal (TRUE = healthy)", "", "", "R"),
|
||||||
|
("%IX0.4", "TE-322", "PU-302 motor thermal (TRUE = healthy)", "", "", "R"),
|
||||||
|
("%IX0.5", "TE-332", "PU-303 motor thermal (TRUE = healthy)", "", "", "R"),
|
||||||
|
("%IX0.6", "MSE-313", "PU-301 seal leak (TRUE = leak)", "", "", "R"),
|
||||||
|
("%IX0.7", "MSE-323", "PU-302 seal leak (TRUE = leak)", "", "", "R"),
|
||||||
|
("%IX1.0", "MSE-333", "PU-303 seal leak (TRUE = leak)", "", "", "R"),
|
||||||
|
("%IX1.1", "XA-502", "Mains healthy (TRUE = healthy)", "", "", "R"),
|
||||||
|
# --- 2.2 PLC outputs --------------------------------------------
|
||||||
|
("%QX0.0", "PU-301", "Run command", "", "", "R"),
|
||||||
|
("%QX0.1", "PU-302", "Run command", "", "", "R"),
|
||||||
|
("%QX0.2", "PU-303", "Run command", "", "", "R"),
|
||||||
|
("%QX0.3", "PU-301", "Running", "", "", "R"),
|
||||||
|
("%QX0.4", "PU-302", "Running", "", "", "R"),
|
||||||
|
("%QX0.5", "PU-303", "Running", "", "", "R"),
|
||||||
|
("%QX0.6", "PU-301", "Available", "", "", "R"),
|
||||||
|
("%QX0.7", "PU-302", "Available", "", "", "R"),
|
||||||
|
("%QX1.0", "PU-303", "Available", "", "", "R"),
|
||||||
|
("%QX1.1", "STN", "Station in auto", "", "", "R"),
|
||||||
|
("%QX1.2", "STN", "High level alarm", "", "", "R"),
|
||||||
|
("%QX1.3", "STN", "Spill active", "", "", "R"),
|
||||||
|
("%QX1.4", "PU-301", "Tripped", "", "", "R"),
|
||||||
|
("%QX1.5", "PU-302", "Tripped", "", "", "R"),
|
||||||
|
("%QX1.6", "PU-303", "Tripped", "", "", "R"),
|
||||||
|
("%QW0", "STN", "Wet well level", "mm", "1", "R"),
|
||||||
|
("%QW1", "STN", "Inflow", "L/s", "x10", "R"),
|
||||||
|
("%QW2", "STN", "Total discharge flow", "L/s", "x10", "R"),
|
||||||
|
("%QW3", "STN", "Pumps running", "count", "1", "R"),
|
||||||
|
("%QW4", "STN", "Common drive speed", "Hz", "x10", "R"),
|
||||||
|
("%QW5", "STN", "Time to spill weir (32767 = drawing down)", "s", "1", "R"),
|
||||||
|
("%QW6", "STN", "Time to LSHH (32767 = drawing down)", "s", "1", "R"),
|
||||||
|
("%QW7", "STN", "Net accumulation (signed)", "L/s", "x10", "R"),
|
||||||
|
("%QW8", "PU-301", "Run hours", "h", "1", "R"),
|
||||||
|
("%QW9", "PU-302", "Run hours", "h", "1", "R"),
|
||||||
|
("%QW10", "PU-303", "Run hours", "h", "1", "R"),
|
||||||
|
("%QW11", "STN", "Volume remaining to spill", "m3", "1", "R"),
|
||||||
|
("%QW12", "STN", "Station state (enum 3.1)", "", "1", "R"),
|
||||||
|
("%QW13", "PU-301", "Pump state (enum 3.2)", "", "1", "R"),
|
||||||
|
("%QW14", "PU-302", "Pump state (enum 3.2)", "", "1", "R"),
|
||||||
|
("%QW15", "PU-303", "Pump state (enum 3.2)", "", "1", "R"),
|
||||||
|
("%QW16", "STN", "Current duty pump (0 = none, 1-3)", "", "1", "R"),
|
||||||
|
("%QW17", "STN", "Alarm bitmask (section 6) - READ AS UNSIGNED", "", "1", "R"),
|
||||||
|
("%QW20", "STN", "Command acknowledge (echoes %MW1)", "", "1", "R"),
|
||||||
|
# --- 2.3 commands and setpoints ---------------------------------
|
||||||
|
("%MW0", "STN", "Station mode: 1 = auto, 2 = off", "", "1", "RW"),
|
||||||
|
("%MW1", "STN", "Command word (section 3.3)", "", "1", "RW"),
|
||||||
|
("%MW2", "STN", "Command parameter (pump number)", "", "1", "RW"),
|
||||||
|
("%MW3", "STN", "Level control setpoint", "mm", "1", "RW"),
|
||||||
|
("%MW4", "STN", "Start duty level", "mm", "1", "RW"),
|
||||||
|
("%MW5", "STN", "Start pump 2 level", "mm", "1", "RW"),
|
||||||
|
("%MW6", "STN", "Start pump 3 level", "mm", "1", "RW"),
|
||||||
|
("%MW7", "STN", "Stop all level", "mm", "1", "RW"),
|
||||||
|
("%MW8", "STN", "High level alarm", "mm", "1", "RW"),
|
||||||
|
("%MW9", "STN", "Minimum drive speed", "Hz", "x10", "RW"),
|
||||||
|
("%MW10", "STN", "Service interval", "h", "1", "RW"),
|
||||||
|
# --- 8.2 simulation control (simulation build only) --------------
|
||||||
|
("%MW20", "SIM", "SIM ONLY: manual inflow (mode 0)", "L/s", "x10", "RW"),
|
||||||
|
("%MW21", "SIM", "SIM ONLY: scenario 0=man 1=diurnal 2=wet 3=ref", "", "1", "RW"),
|
||||||
|
("%MW22", "SIM", "SIM ONLY: write 1 to reset scenario, self-clearing", "", "1", "RW"),
|
||||||
|
("%MW23", "SIM", "SIM ONLY: time scale 1-120", "x", "1", "RW"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def modbus_address(iec):
|
||||||
|
"""Map an IEC located address to (object type, Modbus address).
|
||||||
|
|
||||||
|
Reflects the v4 simple_modbus segmented data blocks, not v3.
|
||||||
|
"""
|
||||||
|
body = iec[2:]
|
||||||
|
kind = iec[:2]
|
||||||
|
if kind == "%I":
|
||||||
|
if body[0] == "W":
|
||||||
|
return "Input register", int(body[1:])
|
||||||
|
byte, bit = body[1:].split(".")
|
||||||
|
return "Discrete input", int(byte) * 8 + int(bit)
|
||||||
|
if kind == "%Q":
|
||||||
|
if body[0] == "W":
|
||||||
|
return "Holding register", int(body[1:])
|
||||||
|
byte, bit = body[1:].split(".")
|
||||||
|
return "Coil", int(byte) * 8 + int(bit)
|
||||||
|
if kind == "%M":
|
||||||
|
return "Holding register", MW_HR_OFFSET + int(body[1:])
|
||||||
|
raise ValueError(f"unrecognised located address: {iec}")
|
||||||
|
|
||||||
|
|
||||||
|
def git_hash():
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return out.stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return "nogit"
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(text):
|
||||||
|
"""Blank out (* ... *) comment spans, preserving line structure.
|
||||||
|
|
||||||
|
Line structure is preserved so that reported line numbers still
|
||||||
|
match the source file. ST block comments do not nest.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
depth = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
if text.startswith("(*", i):
|
||||||
|
depth += 1
|
||||||
|
out.append(" ")
|
||||||
|
i += 2
|
||||||
|
elif text.startswith("*)", i) and depth:
|
||||||
|
depth -= 1
|
||||||
|
out.append(" ")
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
ch = text[i]
|
||||||
|
out.append(ch if (depth == 0 or ch == "\n") else " ")
|
||||||
|
i += 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def check_located(files):
|
||||||
|
"""Enforce the section 10 grep check as a build step.
|
||||||
|
|
||||||
|
Comments are stripped first: the brief bans located variables in
|
||||||
|
the code, not in the commentary explaining the register map.
|
||||||
|
"""
|
||||||
|
offenders = []
|
||||||
|
for path in files:
|
||||||
|
if path.name in LOCATED_ALLOWED:
|
||||||
|
continue
|
||||||
|
code = strip_comments(path.read_text(encoding="utf-8"))
|
||||||
|
for n, line in enumerate(code.splitlines(), 1):
|
||||||
|
if any(tok in line for tok in LOCATED_TOKENS):
|
||||||
|
offenders.append(f" {path.name}:{n}: {line.strip()}")
|
||||||
|
if offenders:
|
||||||
|
sys.exit(
|
||||||
|
"BUILD FAILED: located variable referenced outside "
|
||||||
|
+ " / ".join(sorted(LOCATED_ALLOWED))
|
||||||
|
+ "\n"
|
||||||
|
+ "\n".join(offenders)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_register_map():
|
||||||
|
PLC_DIR.mkdir(exist_ok=True)
|
||||||
|
out = PLC_DIR / "register-map.csv"
|
||||||
|
with out.open("w", newline="", encoding="utf-8") as fh:
|
||||||
|
w = csv.writer(fh)
|
||||||
|
w.writerow(
|
||||||
|
[
|
||||||
|
"iec_address",
|
||||||
|
"modbus_object",
|
||||||
|
"modbus_address",
|
||||||
|
"tag",
|
||||||
|
"description",
|
||||||
|
"units",
|
||||||
|
"scaling",
|
||||||
|
"access",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for iec, tag, desc, units, scaling, access in REGISTERS:
|
||||||
|
obj, addr = modbus_address(iec)
|
||||||
|
w.writerow([iec, obj, addr, tag, desc, units, scaling, access])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Build WRPS-CTL-002 flat ST.")
|
||||||
|
ap.add_argument("--mode", choices=("field", "sim"), required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
missing = [n for n in REQUIRED[args.mode] if not (SRC / n).is_file()]
|
||||||
|
if missing:
|
||||||
|
sys.exit(
|
||||||
|
f"BUILD FAILED: missing source file(s) for mode '{args.mode}':\n"
|
||||||
|
+ "\n".join(f" src/{n}" for n in missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lexical order by filename, per brief section 1.
|
||||||
|
files = sorted(
|
||||||
|
(p for p in SRC.glob("*.st") if p.name not in EXCLUDE[args.mode]),
|
||||||
|
key=lambda p: p.name,
|
||||||
|
)
|
||||||
|
if not files:
|
||||||
|
sys.exit("BUILD FAILED: no source files found in src/")
|
||||||
|
|
||||||
|
check_located(files)
|
||||||
|
|
||||||
|
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
header = [
|
||||||
|
"(* =====================================================================",
|
||||||
|
" WRPS-CTL-002 Waterloo Road Pump Station",
|
||||||
|
" GENERATED FILE - do not edit. Edit src/ and rebuild.",
|
||||||
|
"",
|
||||||
|
f" mode : {args.mode}",
|
||||||
|
f" git : {git_hash()}",
|
||||||
|
f" generated : {stamp}",
|
||||||
|
f" sources : {', '.join(p.name for p in files)}",
|
||||||
|
" ===================================================================== *)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
parts = ["\n".join(header)]
|
||||||
|
for p in files:
|
||||||
|
parts.append(f"\n(* ---- {p.name} ---- *)\n")
|
||||||
|
parts.append(p.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
BUILD.mkdir(exist_ok=True)
|
||||||
|
out = BUILD / "wrps.st"
|
||||||
|
out.write_text("\n".join(parts), encoding="utf-8")
|
||||||
|
|
||||||
|
regmap = write_register_map()
|
||||||
|
print(f"OK {out} ({out.stat().st_size} bytes, {len(files)} sources)")
|
||||||
|
print(f"OK {regmap} ({len(REGISTERS)} registers)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
52
03-plc/editor-devices/README.md
Normal file
52
03-plc/editor-devices/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Editor `devices/` — reference copy, NOT generated
|
||||||
|
|
||||||
|
These files are the **hand-made** half of the OpenPLC Editor project. Everything
|
||||||
|
else in the project (`project.json`, `pous/`) is generated from `src/` by
|
||||||
|
`../gen_project.py`; `devices/` is not, because it is configured through GUI
|
||||||
|
dialogs and holds machine-specific settings.
|
||||||
|
|
||||||
|
They live here so the setup is not lost when the Editor project is recreated —
|
||||||
|
which it will have to be, because the machine that held it has been retired. See
|
||||||
|
`../DEPLOY.md` §0.
|
||||||
|
|
||||||
|
**This is a reference copy, not the live one.** `DEPLOY.md` §A is the procedure
|
||||||
|
for reproducing it through the GUI; these files show what the result should look
|
||||||
|
like.
|
||||||
|
|
||||||
|
Captured 2026-08-14, from the working setup that first served Modbus data.
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `configuration.json` | Device board (`OpenPLC Runtime v4`) and runtime address |
|
||||||
|
| `pin-mapping.json` | Board pin map — Editor default, unused by this program |
|
||||||
|
| `servers/Modbus01.json` | The Modbus TCP **server** definition — the file that enables the runtime's `modbus_slave` plugin |
|
||||||
|
|
||||||
|
## Two things to know before reusing these
|
||||||
|
|
||||||
|
**`configuration.json` says `localhost`, and that is now wrong.** It was correct
|
||||||
|
when the Editor and the runtime were on the same host. The runtime is now on
|
||||||
|
`yau-sls-poc-lin001`, so a new Editor installation must point at **`10.0.0.17`**,
|
||||||
|
port `8443`.
|
||||||
|
|
||||||
|
**`servers/Modbus01.json` says `networkInterface: 0.0.0.0`, and that is correct —
|
||||||
|
leave it alone.** It looks like it contradicts the container publishing port 502
|
||||||
|
on `10.0.0.17` only, but the two settings operate at different layers:
|
||||||
|
|
||||||
|
| Layer | Setting | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| PLC runtime, **inside** the container | `0.0.0.0:502` | the Modbus slave listens on all of the container's own interfaces |
|
||||||
|
| Docker, **outside** the container | `10.0.0.17:502:502` | only the host's LAN address forwards to it |
|
||||||
|
|
||||||
|
Narrowing the runtime's own bind address gains nothing and risks the slave not
|
||||||
|
listening at all. The **Docker publish** is the security control. See
|
||||||
|
`../../02-environment/`.
|
||||||
|
|
||||||
|
## What is deliberately absent
|
||||||
|
|
||||||
|
There is **no `remote/` directory**. Remote Devices is the Modbus *master*
|
||||||
|
configuration — it would make the PLC poll someone else, inverting the
|
||||||
|
architecture invariant, and its IO groups claim `%IW` addresses that collide with
|
||||||
|
the field inputs in `src/10_globals.st`.
|
||||||
|
|
||||||
|
CI Server needs no entry in the PLC project at all. It is the client; it simply
|
||||||
|
connects.
|
||||||
11
03-plc/editor-devices/configuration.json
Normal file
11
03-plc/editor-devices/configuration.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"deviceBoard": "OpenPLC Runtime v4",
|
||||||
|
"communicationPort": "",
|
||||||
|
"runtimeIpAddress": "localhost",
|
||||||
|
"vendorScreenData": {},
|
||||||
|
"vendorScreenDataByBoard": {
|
||||||
|
"OpenPLC Simulator": {},
|
||||||
|
"OpenPLC Runtime v4": {}
|
||||||
|
},
|
||||||
|
"selectedPlatformOptions": {}
|
||||||
|
}
|
||||||
1
03-plc/editor-devices/pin-mapping.json
Normal file
1
03-plc/editor-devices/pin-mapping.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
9
03-plc/editor-devices/servers/Modbus01.json
Normal file
9
03-plc/editor-devices/servers/Modbus01.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "Modbus01",
|
||||||
|
"protocol": "modbus-tcp",
|
||||||
|
"modbusSlaveConfig": {
|
||||||
|
"enabled": true,
|
||||||
|
"networkInterface": "0.0.0.0",
|
||||||
|
"port": 502
|
||||||
|
}
|
||||||
|
}
|
||||||
351
03-plc/gen_project.py
Normal file
351
03-plc/gen_project.py
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate an OpenPLC Editor v4 project from src/*.st.
|
||||||
|
|
||||||
|
python gen_project.py --out editor-project
|
||||||
|
|
||||||
|
The Editor v4 project is a *folder*, not a file:
|
||||||
|
|
||||||
|
project.json meta + configuration (tasks, instances, globals)
|
||||||
|
pous/programs/*.st one file per PROGRAM
|
||||||
|
pous/function-blocks/*.st
|
||||||
|
pous/functions/*.st
|
||||||
|
|
||||||
|
There is no "import a flat .st" path in the Editor, so this script turns
|
||||||
|
the repo's ST sources into that layout. The repo stays canonical; the
|
||||||
|
generated project is a build artefact and is git-ignored.
|
||||||
|
|
||||||
|
What it does NOT touch, because the user configures them in the GUI:
|
||||||
|
devices/configuration.json (runtime address, credentials) and build/.
|
||||||
|
Copy only project.json and pous/ over an existing project.
|
||||||
|
|
||||||
|
Schemas below were recovered from the Editor bundle
|
||||||
|
(resources/app.asar, v4.2.11), not guessed:
|
||||||
|
|
||||||
|
scalar : {"definition": "base-type", "value": "INT"}
|
||||||
|
array : {"definition": "array",
|
||||||
|
"value": "ARRAY[1..3] OF REAL",
|
||||||
|
"data": {"baseType": {"definition": "base-type", "value": "REAL"},
|
||||||
|
"dimensions": [{"dimension": "1..3"}]}}
|
||||||
|
class : one of input|output|inOut|external|local|temp|global
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
SRC = ROOT / "src"
|
||||||
|
|
||||||
|
GLOBALS_FILE = "10_globals.st"
|
||||||
|
CONFIG_FILE = {"field": "90_config_field.st", "sim": "91_config_sim.st"}
|
||||||
|
|
||||||
|
# src file -> POU folder. Anything not listed is not a POU.
|
||||||
|
POU_FOLDER = {
|
||||||
|
"20_fb_pump.st": "function-blocks",
|
||||||
|
"21_fb_duty_selector.st": "function-blocks",
|
||||||
|
"22_fb_level_control.st": "function-blocks",
|
||||||
|
"23_fb_headroom.st": "function-blocks",
|
||||||
|
"30_prog_control.st": "programs",
|
||||||
|
"50_prog_io_mux.st": "programs",
|
||||||
|
}
|
||||||
|
|
||||||
|
# POUs added only in a given mode. Mirrors build.py's EXCLUDE.
|
||||||
|
MODE_ONLY = {
|
||||||
|
"field": {},
|
||||||
|
"sim": {"40_prog_simulation.st": "programs"},
|
||||||
|
}
|
||||||
|
|
||||||
|
POU_HEADER = re.compile(
|
||||||
|
r"^\s*(FUNCTION_BLOCK|PROGRAM|FUNCTION)\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# NAME [AT %LOC] : TYPE [:= INIT] ;
|
||||||
|
VAR_DECL = re.compile(
|
||||||
|
r"""^\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*
|
||||||
|
(?:AT\s+(?P<loc>%[A-Za-z]+[0-9.]+)\s*)?
|
||||||
|
:\s*(?P<type>.+?)
|
||||||
|
(?:\s*:=\s*(?P<init>.+?))?
|
||||||
|
\s*;""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
ARRAY_TYPE = re.compile(
|
||||||
|
r"^ARRAY\s*\[\s*(?P<dims>[^\]]+?)\s*\]\s*OF\s+(?P<base>[A-Za-z_][A-Za-z0-9_]*)$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
COMMENT = re.compile(r"\(\*.*?\*\)", re.DOTALL)
|
||||||
|
|
||||||
|
# Declaration sections the Editor parses separately from the body.
|
||||||
|
VAR_BLOCK = re.compile(
|
||||||
|
r"(?P<head>^[ \t]*VAR(?:_INPUT|_OUTPUT|_IN_OUT|_TEMP|_EXTERNAL)?\b[^\n]*\n)"
|
||||||
|
r"(?P<body>.*?)"
|
||||||
|
r"(?P<tail>^[ \t]*END_VAR)",
|
||||||
|
re.DOTALL | re.MULTILINE | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(text):
|
||||||
|
"""Remove (* ... *) spans, preserving line count for error messages."""
|
||||||
|
return COMMENT.sub(lambda m: " " * len(m.group(0).replace("\n", "")) +
|
||||||
|
"\n" * m.group(0).count("\n"), text)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitise_declarations(text):
|
||||||
|
"""Make VAR blocks digestible by the Editor's declaration parser.
|
||||||
|
|
||||||
|
The Editor (4.2.11) parses each POU's declaration sections with a
|
||||||
|
parser separate from the body compiler, and that parser rejects:
|
||||||
|
|
||||||
|
- comment-only lines inside a VAR block, e.g. "(* process image *)"
|
||||||
|
- a (* ... *) comment that wraps onto the following line
|
||||||
|
- more than one declaration on a line, e.g. "A : INT; B : INT;"
|
||||||
|
|
||||||
|
A trailing single-line comment after a declaration is fine. So:
|
||||||
|
fold multi-line comments onto one line, drop comment-only lines, and
|
||||||
|
put each declaration on its own line. Bodies are untouched, and
|
||||||
|
src/ keeps its original formatting — only the generated project is
|
||||||
|
reflowed.
|
||||||
|
|
||||||
|
Returns (text, dropped_line_count, split_count).
|
||||||
|
"""
|
||||||
|
dropped = 0
|
||||||
|
split = 0
|
||||||
|
|
||||||
|
def fix_block(m):
|
||||||
|
nonlocal dropped, split
|
||||||
|
body = m.group("body")
|
||||||
|
|
||||||
|
# Fold any comment spanning lines onto a single line.
|
||||||
|
body = COMMENT.sub(lambda c: " ".join(c.group(0).split()), body)
|
||||||
|
|
||||||
|
kept = []
|
||||||
|
for line in body.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
if COMMENT.sub("", stripped).strip() == "":
|
||||||
|
dropped += 1 # comment-only line
|
||||||
|
continue
|
||||||
|
|
||||||
|
# One declaration per line. Split on ';', keeping it, and
|
||||||
|
# re-attach anything trailing the last one (a comment).
|
||||||
|
decls = re.findall(r"[^;]+;", stripped)
|
||||||
|
if len(decls) > 1:
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
trailer = stripped[sum(len(d) for d in decls):].strip()
|
||||||
|
if trailer:
|
||||||
|
decls[-1] = decls[-1] + " " + trailer
|
||||||
|
kept.extend(indent + d.strip() for d in decls)
|
||||||
|
split += len(decls) - 1
|
||||||
|
else:
|
||||||
|
kept.append(line)
|
||||||
|
return m.group("head") + "\n".join(kept) + "\n" + m.group("tail")
|
||||||
|
|
||||||
|
return VAR_BLOCK.sub(fix_block, text), dropped, split
|
||||||
|
|
||||||
|
|
||||||
|
def parse_globals(text):
|
||||||
|
"""Return the VAR_GLOBAL declarations as Editor globalVariables entries.
|
||||||
|
|
||||||
|
CONSTANT blocks are flagged: the Editor's class enum has no
|
||||||
|
'constant', so those become ordinary globals with an initial value.
|
||||||
|
"""
|
||||||
|
code = strip_comments(text)
|
||||||
|
entries = []
|
||||||
|
constants = []
|
||||||
|
|
||||||
|
for block in re.finditer(
|
||||||
|
r"VAR_GLOBAL(?P<qual>\s+CONSTANT)?(?P<body>.*?)END_VAR",
|
||||||
|
code,
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
):
|
||||||
|
is_const = bool(block.group("qual"))
|
||||||
|
for line in block.group("body").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
m = VAR_DECL.match(line)
|
||||||
|
if not m:
|
||||||
|
sys.exit(f"GEN FAILED: cannot parse global declaration:\n {line.strip()}")
|
||||||
|
name = m.group("name")
|
||||||
|
raw_type = m.group("type").strip()
|
||||||
|
init = (m.group("init") or "").strip()
|
||||||
|
|
||||||
|
arr = ARRAY_TYPE.match(raw_type)
|
||||||
|
if arr:
|
||||||
|
base = arr.group("base").upper()
|
||||||
|
dims = [d.strip() for d in arr.group("dims").split(",")]
|
||||||
|
type_obj = {
|
||||||
|
"definition": "array",
|
||||||
|
"value": f"ARRAY[{','.join(dims)}] OF {base}",
|
||||||
|
"data": {
|
||||||
|
"baseType": {"definition": "base-type", "value": base},
|
||||||
|
"dimensions": [{"dimension": d} for d in dims],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
type_obj = {"definition": "base-type", "value": raw_type.upper()}
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"type": type_obj,
|
||||||
|
"class": "global",
|
||||||
|
"location": m.group("loc") or "",
|
||||||
|
"documentation": "",
|
||||||
|
"debug": False,
|
||||||
|
"initialValue": init,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if is_const:
|
||||||
|
constants.append(name)
|
||||||
|
|
||||||
|
return entries, constants
|
||||||
|
|
||||||
|
|
||||||
|
def parse_config(text):
|
||||||
|
"""Extract tasks and program instances from the CONFIGURATION file."""
|
||||||
|
code = strip_comments(text)
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for m in re.finditer(
|
||||||
|
r"TASK\s+(?P<name>\w+)\s*\(\s*INTERVAL\s*:=\s*(?P<interval>[^,)]+)"
|
||||||
|
r"(?:\s*,\s*PRIORITY\s*:=\s*(?P<prio>\d+))?\s*\)",
|
||||||
|
code,
|
||||||
|
re.IGNORECASE,
|
||||||
|
):
|
||||||
|
tasks.append(
|
||||||
|
{
|
||||||
|
"name": m.group("name"),
|
||||||
|
"triggering": "Cyclic",
|
||||||
|
"interval": m.group("interval").strip(),
|
||||||
|
"priority": int(m.group("prio") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
instances = []
|
||||||
|
for m in re.finditer(
|
||||||
|
r"PROGRAM\s+(?P<inst>\w+)\s+WITH\s+(?P<task>\w+)\s*:\s*(?P<prog>\w+)\s*;",
|
||||||
|
code,
|
||||||
|
re.IGNORECASE,
|
||||||
|
):
|
||||||
|
instances.append(
|
||||||
|
{
|
||||||
|
"name": m.group("inst"),
|
||||||
|
"program": m.group("prog"),
|
||||||
|
"task": m.group("task"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
sys.exit(f"GEN FAILED: no TASK found in {config_file}")
|
||||||
|
if not instances:
|
||||||
|
sys.exit(f"GEN FAILED: no PROGRAM instance found in {config_file}")
|
||||||
|
return tasks, instances
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Generate an OpenPLC Editor v4 project.")
|
||||||
|
ap.add_argument("--out", default="editor-project", help="output directory")
|
||||||
|
ap.add_argument("--name", default="wrps", help="project name")
|
||||||
|
ap.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=("field", "sim"),
|
||||||
|
default="field",
|
||||||
|
help="field = control only; sim = control plus PROGRAM SIMULATION",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
out = (ROOT / args.out).resolve()
|
||||||
|
config_file = CONFIG_FILE[args.mode]
|
||||||
|
pou_folder = {**POU_FOLDER, **MODE_ONLY[args.mode]}
|
||||||
|
|
||||||
|
missing = [n for n in [GLOBALS_FILE, config_file, *pou_folder] if not (SRC / n).is_file()]
|
||||||
|
if missing:
|
||||||
|
sys.exit("GEN FAILED: missing source file(s):\n" + "\n".join(f" src/{n}" for n in missing))
|
||||||
|
|
||||||
|
globals_entries, constants = parse_globals((SRC / GLOBALS_FILE).read_text(encoding="utf-8"))
|
||||||
|
tasks, instances = parse_config((SRC / config_file).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# --- POU files -----------------------------------------------------
|
||||||
|
if out.exists():
|
||||||
|
shutil.rmtree(out)
|
||||||
|
for folder in ("programs", "function-blocks", "functions"):
|
||||||
|
(out / "pous" / folder).mkdir(parents=True)
|
||||||
|
|
||||||
|
pou_names = []
|
||||||
|
total_dropped = total_split = 0
|
||||||
|
for filename, folder in pou_folder.items():
|
||||||
|
text = (SRC / filename).read_text(encoding="utf-8")
|
||||||
|
text, dropped, split = sanitise_declarations(text)
|
||||||
|
total_dropped += dropped
|
||||||
|
total_split += split
|
||||||
|
m = POU_HEADER.search(strip_comments(text))
|
||||||
|
if not m:
|
||||||
|
sys.exit(f"GEN FAILED: no POU header found in src/{filename}")
|
||||||
|
kind, name = m.group(1).upper(), m.group(2)
|
||||||
|
expected = "programs" if kind == "PROGRAM" else (
|
||||||
|
"function-blocks" if kind == "FUNCTION_BLOCK" else "functions")
|
||||||
|
if expected != folder:
|
||||||
|
sys.exit(
|
||||||
|
f"GEN FAILED: src/{filename} declares {kind} {name} but is mapped "
|
||||||
|
f"to pous/{folder}/ — fix POU_FOLDER."
|
||||||
|
)
|
||||||
|
(out / "pous" / folder / f"{name}.st").write_text(text, encoding="utf-8")
|
||||||
|
pou_names.append(f"{name} ({folder})")
|
||||||
|
|
||||||
|
# Every instance must name a program we actually emitted.
|
||||||
|
programs = {p.split(" ")[0] for p in pou_names if "(programs)" in p}
|
||||||
|
for inst in instances:
|
||||||
|
if inst["program"] not in programs:
|
||||||
|
sys.exit(
|
||||||
|
f"GEN FAILED: configuration instance '{inst['name']}' references program "
|
||||||
|
f"'{inst['program']}', which is not among the generated programs: "
|
||||||
|
f"{sorted(programs)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
project = {
|
||||||
|
"meta": {"name": args.name, "type": "plc-project"},
|
||||||
|
"data": {
|
||||||
|
"dataTypes": [],
|
||||||
|
"pous": [], # the Editor discovers POUs from pous/, verified 2026-08-13
|
||||||
|
"configuration": {
|
||||||
|
"resource": {
|
||||||
|
"tasks": tasks,
|
||||||
|
"instances": instances,
|
||||||
|
"globalVariables": globals_entries,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraries": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(out / "project.json").write_text(json.dumps(project, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
located = sum(1 for g in globals_entries if g["location"])
|
||||||
|
print(f"OK {out}")
|
||||||
|
print(f"OK {len(pou_names)} POUs: {', '.join(pou_names)}")
|
||||||
|
print(f"OK {len(globals_entries)} globals ({located} located)")
|
||||||
|
print(
|
||||||
|
f"OK declaration sections sanitised for the Editor parser "
|
||||||
|
f"({total_dropped} comment-only lines dropped, {total_split} declarations "
|
||||||
|
f"split onto their own line; src/ unchanged)"
|
||||||
|
)
|
||||||
|
print(f"OK tasks: {[(t['name'], t['interval']) for t in tasks]}")
|
||||||
|
print(f"OK instances: {[(i['name'], i['program']) for i in instances]}")
|
||||||
|
if constants:
|
||||||
|
print(
|
||||||
|
f"WARN {len(constants)} VAR_GLOBAL CONSTANT entries emitted as ordinary "
|
||||||
|
f"globals — the Editor's class enum has no 'constant'.\n"
|
||||||
|
f" They keep their initial values and no POU writes them, so behaviour "
|
||||||
|
f"is unchanged, but they are no longer compiler-enforced read-only:\n"
|
||||||
|
f" {', '.join(constants)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
70
03-plc/register-map.csv
Normal file
70
03-plc/register-map.csv
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
iec_address,modbus_object,modbus_address,tag,description,units,scaling,access
|
||||||
|
%IW0,Input register,0,LIT-101,Wet well level,mm,1,R
|
||||||
|
%IW1,Input register,1,FIT-201,Inlet flow,L/s,x10,R
|
||||||
|
%IW2,Input register,2,FIT-301,Discharge flow,L/s,x10,R
|
||||||
|
%IW3,Input register,3,PIT-302,Manifold pressure,kPa,1,R
|
||||||
|
%IW4,Input register,4,PIT-311,PU-301 discharge pressure,kPa,1,R
|
||||||
|
%IW5,Input register,5,PIT-321,PU-302 discharge pressure,kPa,1,R
|
||||||
|
%IW6,Input register,6,PIT-331,PU-303 discharge pressure,kPa,1,R
|
||||||
|
%IW7,Input register,7,VE-314,PU-301 bearing vibration,mm/s,x10,R
|
||||||
|
%IW8,Input register,8,VE-324,PU-302 bearing vibration,mm/s,x10,R
|
||||||
|
%IW9,Input register,9,VE-334,PU-303 bearing vibration,mm/s,x10,R
|
||||||
|
%IX0.0,Discrete input,0,LSHH-102,High high level (TRUE = wet),,,R
|
||||||
|
%IX0.1,Discrete input,1,LSLL-103,"Low low level (TRUE = wet, FALSE = dry)",,,R
|
||||||
|
%IX0.2,Discrete input,2,LSH-104,Spill detected (TRUE = spilling),,,R
|
||||||
|
%IX0.3,Discrete input,3,TE-312,PU-301 motor thermal (TRUE = healthy),,,R
|
||||||
|
%IX0.4,Discrete input,4,TE-322,PU-302 motor thermal (TRUE = healthy),,,R
|
||||||
|
%IX0.5,Discrete input,5,TE-332,PU-303 motor thermal (TRUE = healthy),,,R
|
||||||
|
%IX0.6,Discrete input,6,MSE-313,PU-301 seal leak (TRUE = leak),,,R
|
||||||
|
%IX0.7,Discrete input,7,MSE-323,PU-302 seal leak (TRUE = leak),,,R
|
||||||
|
%IX1.0,Discrete input,8,MSE-333,PU-303 seal leak (TRUE = leak),,,R
|
||||||
|
%IX1.1,Discrete input,9,XA-502,Mains healthy (TRUE = healthy),,,R
|
||||||
|
%QX0.0,Coil,0,PU-301,Run command,,,R
|
||||||
|
%QX0.1,Coil,1,PU-302,Run command,,,R
|
||||||
|
%QX0.2,Coil,2,PU-303,Run command,,,R
|
||||||
|
%QX0.3,Coil,3,PU-301,Running,,,R
|
||||||
|
%QX0.4,Coil,4,PU-302,Running,,,R
|
||||||
|
%QX0.5,Coil,5,PU-303,Running,,,R
|
||||||
|
%QX0.6,Coil,6,PU-301,Available,,,R
|
||||||
|
%QX0.7,Coil,7,PU-302,Available,,,R
|
||||||
|
%QX1.0,Coil,8,PU-303,Available,,,R
|
||||||
|
%QX1.1,Coil,9,STN,Station in auto,,,R
|
||||||
|
%QX1.2,Coil,10,STN,High level alarm,,,R
|
||||||
|
%QX1.3,Coil,11,STN,Spill active,,,R
|
||||||
|
%QX1.4,Coil,12,PU-301,Tripped,,,R
|
||||||
|
%QX1.5,Coil,13,PU-302,Tripped,,,R
|
||||||
|
%QX1.6,Coil,14,PU-303,Tripped,,,R
|
||||||
|
%QW0,Holding register,0,STN,Wet well level,mm,1,R
|
||||||
|
%QW1,Holding register,1,STN,Inflow,L/s,x10,R
|
||||||
|
%QW2,Holding register,2,STN,Total discharge flow,L/s,x10,R
|
||||||
|
%QW3,Holding register,3,STN,Pumps running,count,1,R
|
||||||
|
%QW4,Holding register,4,STN,Common drive speed,Hz,x10,R
|
||||||
|
%QW5,Holding register,5,STN,Time to spill weir (32767 = drawing down),s,1,R
|
||||||
|
%QW6,Holding register,6,STN,Time to LSHH (32767 = drawing down),s,1,R
|
||||||
|
%QW7,Holding register,7,STN,Net accumulation (signed),L/s,x10,R
|
||||||
|
%QW8,Holding register,8,PU-301,Run hours,h,1,R
|
||||||
|
%QW9,Holding register,9,PU-302,Run hours,h,1,R
|
||||||
|
%QW10,Holding register,10,PU-303,Run hours,h,1,R
|
||||||
|
%QW11,Holding register,11,STN,Volume remaining to spill,m3,1,R
|
||||||
|
%QW12,Holding register,12,STN,Station state (enum 3.1),,1,R
|
||||||
|
%QW13,Holding register,13,PU-301,Pump state (enum 3.2),,1,R
|
||||||
|
%QW14,Holding register,14,PU-302,Pump state (enum 3.2),,1,R
|
||||||
|
%QW15,Holding register,15,PU-303,Pump state (enum 3.2),,1,R
|
||||||
|
%QW16,Holding register,16,STN,"Current duty pump (0 = none, 1-3)",,1,R
|
||||||
|
%QW17,Holding register,17,STN,Alarm bitmask (section 6) - READ AS UNSIGNED,,1,R
|
||||||
|
%QW20,Holding register,20,STN,Command acknowledge (echoes %MW1),,1,R
|
||||||
|
%MW0,Holding register,1024,STN,"Station mode: 1 = auto, 2 = off",,1,RW
|
||||||
|
%MW1,Holding register,1025,STN,Command word (section 3.3),,1,RW
|
||||||
|
%MW2,Holding register,1026,STN,Command parameter (pump number),,1,RW
|
||||||
|
%MW3,Holding register,1027,STN,Level control setpoint,mm,1,RW
|
||||||
|
%MW4,Holding register,1028,STN,Start duty level,mm,1,RW
|
||||||
|
%MW5,Holding register,1029,STN,Start pump 2 level,mm,1,RW
|
||||||
|
%MW6,Holding register,1030,STN,Start pump 3 level,mm,1,RW
|
||||||
|
%MW7,Holding register,1031,STN,Stop all level,mm,1,RW
|
||||||
|
%MW8,Holding register,1032,STN,High level alarm,mm,1,RW
|
||||||
|
%MW9,Holding register,1033,STN,Minimum drive speed,Hz,x10,RW
|
||||||
|
%MW10,Holding register,1034,STN,Service interval,h,1,RW
|
||||||
|
%MW20,Holding register,1044,SIM,SIM ONLY: manual inflow (mode 0),L/s,x10,RW
|
||||||
|
%MW21,Holding register,1045,SIM,SIM ONLY: scenario 0=man 1=diurnal 2=wet 3=ref,,1,RW
|
||||||
|
%MW22,Holding register,1046,SIM,"SIM ONLY: write 1 to reset scenario, self-clearing",,1,RW
|
||||||
|
%MW23,Holding register,1047,SIM,SIM ONLY: time scale 1-120,x,1,RW
|
||||||
|
252
03-plc/src/10_globals.st
Normal file
252
03-plc/src/10_globals.st
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
(* =====================================================================
|
||||||
|
WRPS-CTL-003 Waterloo Road Pump Station
|
||||||
|
10_globals.st - constants, located I/O, process image globals
|
||||||
|
|
||||||
|
This file and 50_prog_io_mux.st are the ONLY files permitted to
|
||||||
|
reference located variables (%I*, %Q*, %M*). See brief section 10.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
VAR_GLOBAL CONSTANT
|
||||||
|
(* Plant geometry - WRPS-PRO-001 *)
|
||||||
|
CFG_AREA_M2 : REAL := 120.0; (* wet well plan area, m2 *)
|
||||||
|
CFG_SPILL_M : REAL := 6.000; (* spill weir level, m *)
|
||||||
|
CFG_LSHH_M : REAL := 5.500; (* LSHH level, m *)
|
||||||
|
|
||||||
|
(* Drive limits - hard physical limits, section 4.3 *)
|
||||||
|
CFG_MIN_HZ : REAL := 38.0;
|
||||||
|
CFG_MAX_HZ : REAL := 50.0;
|
||||||
|
|
||||||
|
(* Setpoint defaults, section 2.3. Seeded into %MW at first scan. *)
|
||||||
|
DEF_MODE : INT := 1; (* 1 = auto, 2 = off *)
|
||||||
|
DEF_SP_LEVEL : INT := 4200; (* mm *)
|
||||||
|
DEF_START_DUTY : INT := 4000;
|
||||||
|
DEF_START_P2 : INT := 4500;
|
||||||
|
DEF_START_P3 : INT := 5000;
|
||||||
|
DEF_STOP_ALL : INT := 1000;
|
||||||
|
DEF_HIGH_ALARM : INT := 5200;
|
||||||
|
DEF_MIN_SPEED : INT := 380; (* Hz x 10 *)
|
||||||
|
DEF_SERVICE_HRS : INT := 4000;
|
||||||
|
|
||||||
|
(* Published "meaningless / drawing down" sentinel, section 4.4 *)
|
||||||
|
CFG_NO_TIME : INT := 32767;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
2.1 Field inputs - %IW / %IX
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
VAR_GLOBAL
|
||||||
|
IW_LIT101 AT %IW0 : INT; (* wet well level, mm 0-7000 *)
|
||||||
|
IW_FIT201 AT %IW1 : INT; (* inlet flow, L/s x 10 *)
|
||||||
|
IW_FIT301 AT %IW2 : INT; (* discharge flow, L/s x 10 *)
|
||||||
|
IW_PIT302 AT %IW3 : INT; (* manifold pressure, kPa *)
|
||||||
|
IW_PIT311 AT %IW4 : INT; (* pump discharge pressure, kPa *)
|
||||||
|
IW_PIT321 AT %IW5 : INT;
|
||||||
|
IW_PIT331 AT %IW6 : INT;
|
||||||
|
IW_VE314 AT %IW7 : INT; (* bearing vibration, mm/s x 10 *)
|
||||||
|
IW_VE324 AT %IW8 : INT;
|
||||||
|
IW_VE334 AT %IW9 : INT;
|
||||||
|
|
||||||
|
IX_LSHH102 AT %IX0.0 : BOOL; (* TRUE = wet *)
|
||||||
|
IX_LSLL103 AT %IX0.1 : BOOL; (* TRUE = wet, FALSE = dry *)
|
||||||
|
IX_LSH104 AT %IX0.2 : BOOL; (* TRUE = spilling *)
|
||||||
|
IX_TE312 AT %IX0.3 : BOOL; (* TRUE = healthy *)
|
||||||
|
IX_TE322 AT %IX0.4 : BOOL;
|
||||||
|
IX_TE332 AT %IX0.5 : BOOL;
|
||||||
|
IX_MSE313 AT %IX0.6 : BOOL; (* TRUE = seal leak *)
|
||||||
|
IX_MSE323 AT %IX0.7 : BOOL;
|
||||||
|
IX_MSE333 AT %IX1.0 : BOOL;
|
||||||
|
IX_XA502 AT %IX1.1 : BOOL; (* TRUE = mains healthy *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
2.2 PLC outputs - %QX / %QW, read by CI Server
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
VAR_GLOBAL
|
||||||
|
QX_RunCmd1 AT %QX0.0 : BOOL;
|
||||||
|
QX_RunCmd2 AT %QX0.1 : BOOL;
|
||||||
|
QX_RunCmd3 AT %QX0.2 : BOOL;
|
||||||
|
QX_Running1 AT %QX0.3 : BOOL;
|
||||||
|
QX_Running2 AT %QX0.4 : BOOL;
|
||||||
|
QX_Running3 AT %QX0.5 : BOOL;
|
||||||
|
QX_Avail1 AT %QX0.6 : BOOL;
|
||||||
|
QX_Avail2 AT %QX0.7 : BOOL;
|
||||||
|
QX_Avail3 AT %QX1.0 : BOOL;
|
||||||
|
QX_InAuto AT %QX1.1 : BOOL;
|
||||||
|
QX_HighLevel AT %QX1.2 : BOOL;
|
||||||
|
QX_SpillActive AT %QX1.3 : BOOL;
|
||||||
|
QX_Tripped1 AT %QX1.4 : BOOL;
|
||||||
|
QX_Tripped2 AT %QX1.5 : BOOL;
|
||||||
|
QX_Tripped3 AT %QX1.6 : BOOL;
|
||||||
|
|
||||||
|
QW_Level AT %QW0 : INT; (* mm *)
|
||||||
|
QW_Inflow AT %QW1 : INT; (* L/s x 10 *)
|
||||||
|
QW_Discharge AT %QW2 : INT; (* L/s x 10 *)
|
||||||
|
QW_PumpsRunning AT %QW3 : INT;
|
||||||
|
QW_Speed AT %QW4 : INT; (* Hz x 10 *)
|
||||||
|
QW_TimeToSpill AT %QW5 : INT; (* s, 32767 = drawing down *)
|
||||||
|
QW_TimeToLSHH AT %QW6 : INT; (* s, 32767 = drawing down *)
|
||||||
|
QW_NetAccum AT %QW7 : INT; (* L/s x 10, signed *)
|
||||||
|
QW_RunHours1 AT %QW8 : INT; (* hours *)
|
||||||
|
QW_RunHours2 AT %QW9 : INT;
|
||||||
|
QW_RunHours3 AT %QW10 : INT;
|
||||||
|
QW_VolToSpill AT %QW11 : INT; (* m3 *)
|
||||||
|
QW_StationState AT %QW12 : INT; (* enum, section 3.1 *)
|
||||||
|
QW_PumpState1 AT %QW13 : INT; (* enum, section 3.2 *)
|
||||||
|
QW_PumpState2 AT %QW14 : INT;
|
||||||
|
QW_PumpState3 AT %QW15 : INT;
|
||||||
|
QW_DutyPump AT %QW16 : INT; (* 0 = none, 1-3 *)
|
||||||
|
QW_AlarmWord AT %QW17 : INT; (* bitmask, section 6, unsigned *)
|
||||||
|
QW_CmdAck AT %QW20 : INT; (* echoes %MW1 when executed *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
2.3 Commands and setpoints - %MW, written by CI Server
|
||||||
|
|
||||||
|
NOTE: on this runtime %MW is NOT Modbus holding register 0. The
|
||||||
|
v4 Modbus slave segments holding registers: %QW occupies HR 0-1023
|
||||||
|
and %MW starts at HR 1024. %MW0 is therefore HR 1024. See
|
||||||
|
docs/register-map.csv, which carries the offset.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
VAR_GLOBAL
|
||||||
|
MW_Mode AT %MW0 : INT; (* 1 = auto, 2 = off *)
|
||||||
|
MW_CmdWord AT %MW1 : INT; (* section 3.3 *)
|
||||||
|
MW_CmdParam AT %MW2 : INT; (* pump number, etc. *)
|
||||||
|
MW_SpLevel AT %MW3 : INT; (* level control setpoint, mm *)
|
||||||
|
MW_StartDuty AT %MW4 : INT;
|
||||||
|
MW_StartP2 AT %MW5 : INT;
|
||||||
|
MW_StartP3 AT %MW6 : INT;
|
||||||
|
MW_StopAll AT %MW7 : INT;
|
||||||
|
MW_HighAlarm AT %MW8 : INT;
|
||||||
|
MW_MinSpeed AT %MW9 : INT; (* Hz x 10 *)
|
||||||
|
MW_ServiceHrs AT %MW10 : INT; (* hours *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
2.4 Simulation control - %MW20-23, section 8.2
|
||||||
|
|
||||||
|
Section 2 calls this block "simulation build only". It is declared
|
||||||
|
here in both builds instead, because located variables may only be
|
||||||
|
declared in this file and splitting it would need a second file with
|
||||||
|
located-variable permission. In the field build these registers are
|
||||||
|
simply unused: nothing reads them, since PROGRAM SIMULATION is not
|
||||||
|
part of that build. register-map.csv marks them sim-only.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
VAR_GLOBAL
|
||||||
|
MW_SimInflow AT %MW20 : INT; (* manual inflow, L/s x 10 *)
|
||||||
|
MW_SimMode AT %MW21 : INT; (* 0 man, 1 diurnal, 2 wet, 3 ref *)
|
||||||
|
MW_SimReset AT %MW22 : INT; (* write 1 to reset; self-clears *)
|
||||||
|
MW_SimTimeScale AT %MW23 : INT; (* 1-120, default 1 *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Process image globals.
|
||||||
|
|
||||||
|
IO_MUX writes these from the located variables above; CONTROL reads
|
||||||
|
only these. In the pass 2 simulation build the same globals are
|
||||||
|
written from PROGRAM SIMULATION instead, and no control POU changes.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
VAR_GLOBAL
|
||||||
|
g_LevelRaw_mm : INT; (* unconditioned, for fault test *)
|
||||||
|
g_Level_mm : INT;
|
||||||
|
g_Level_m : REAL;
|
||||||
|
g_Inflow_Lps : REAL;
|
||||||
|
g_Disch_Lps : REAL;
|
||||||
|
g_ManifoldP_kPa : REAL;
|
||||||
|
g_PumpP_kPa : ARRAY[1..3] OF REAL;
|
||||||
|
g_Vib_mms : ARRAY[1..3] OF REAL;
|
||||||
|
|
||||||
|
g_LSHH : BOOL; (* TRUE = high high, wet *)
|
||||||
|
g_LSLL_Wet : BOOL; (* TRUE = wet; FALSE = dry *)
|
||||||
|
g_SpillDetected : BOOL;
|
||||||
|
g_ThermalOK : ARRAY[1..3] OF BOOL;
|
||||||
|
g_SealLeak : ARRAY[1..3] OF BOOL;
|
||||||
|
g_MainsOK : BOOL;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* Raw command / setpoint image, copied from %MW by IO_MUX.
|
||||||
|
CONTROL validates and clamps these before use. *)
|
||||||
|
VAR_GLOBAL
|
||||||
|
g_cmd_Mode : INT;
|
||||||
|
g_cmd_Word : INT;
|
||||||
|
g_cmd_Param : INT;
|
||||||
|
g_sp_Level : INT;
|
||||||
|
g_sp_StartDuty : INT;
|
||||||
|
g_sp_StartP2 : INT;
|
||||||
|
g_sp_StartP3 : INT;
|
||||||
|
g_sp_StopAll : INT;
|
||||||
|
g_sp_HighAlarm : INT;
|
||||||
|
g_sp_MinSpeed : INT;
|
||||||
|
g_sp_ServiceHrs : INT;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* Published values, written by CONTROL, copied to %QW / %QX by IO_MUX. *)
|
||||||
|
VAR_GLOBAL
|
||||||
|
g_o_RunCmd : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Running : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Available : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Tripped : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_InAuto : BOOL;
|
||||||
|
g_o_HighLevel : BOOL;
|
||||||
|
g_o_SpillActive : BOOL;
|
||||||
|
|
||||||
|
g_o_Level_mm : INT;
|
||||||
|
g_o_Inflow_x10 : INT;
|
||||||
|
g_o_Disch_x10 : INT;
|
||||||
|
g_o_PumpsRun : INT;
|
||||||
|
g_o_Speed_x10 : INT;
|
||||||
|
g_o_TimeToSpill : INT;
|
||||||
|
g_o_TimeToLSHH : INT;
|
||||||
|
g_o_NetAccum : INT;
|
||||||
|
g_o_RunHours : ARRAY[1..3] OF INT;
|
||||||
|
g_o_VolToSpill : INT;
|
||||||
|
g_o_StationState: INT;
|
||||||
|
g_o_PumpState : ARRAY[1..3] OF INT;
|
||||||
|
g_o_DutyPump : INT;
|
||||||
|
g_o_AlarmWord : INT;
|
||||||
|
g_o_CmdAck : INT;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Simulation image, section 8.
|
||||||
|
|
||||||
|
PROGRAM SIMULATION writes these; IO_MUX copies them into the process
|
||||||
|
image above when g_SimActive is TRUE, in place of %IW / %IX. In the
|
||||||
|
field build SIMULATION is absent, g_SimActive stays FALSE, and none
|
||||||
|
of these are ever written.
|
||||||
|
|
||||||
|
g_SimActive is set by SIMULATION every scan rather than declared
|
||||||
|
TRUE here, so the field build cannot accidentally mux in an
|
||||||
|
all-zero simulation image. In the simulation build SIMULATION is
|
||||||
|
scheduled before IO_MUX, so it is already TRUE - and the simulated
|
||||||
|
image already populated - on the first scan. That ordering is not
|
||||||
|
cosmetic: see 91_config_sim.st.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
VAR_GLOBAL
|
||||||
|
g_SimActive : BOOL; (* FALSE in the field build *)
|
||||||
|
g_sim_ClearReset : BOOL; (* SIMULATION -> IO_MUX, clears %MW22 *)
|
||||||
|
|
||||||
|
g_simcmd_Inflow : INT; (* copies of %MW20-23 for SIMULATION *)
|
||||||
|
g_simcmd_Mode : INT;
|
||||||
|
g_simcmd_Reset : INT;
|
||||||
|
g_simcmd_TimeScale : INT;
|
||||||
|
|
||||||
|
g_sim_Level_mm : INT;
|
||||||
|
g_sim_Inflow_x10 : INT;
|
||||||
|
g_sim_Disch_x10 : INT;
|
||||||
|
g_sim_ManifoldP : INT;
|
||||||
|
g_sim_PumpP : ARRAY[1..3] OF INT;
|
||||||
|
g_sim_Vib_x10 : ARRAY[1..3] OF INT;
|
||||||
|
|
||||||
|
g_sim_LSHH : BOOL;
|
||||||
|
g_sim_LSLL_Wet : BOOL;
|
||||||
|
g_sim_Spill : BOOL;
|
||||||
|
g_sim_ThermalOK : ARRAY[1..3] OF BOOL;
|
||||||
|
g_sim_SealLeak : ARRAY[1..3] OF BOOL;
|
||||||
|
g_sim_MainsOK : BOOL;
|
||||||
|
END_VAR
|
||||||
141
03-plc/src/20_fb_pump.st
Normal file
141
03-plc/src/20_fb_pump.st
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
(* =====================================================================
|
||||||
|
20_fb_pump.st - FB_PUMP, section 4.1
|
||||||
|
|
||||||
|
One instance per pump. Owns everything about a single unit:
|
||||||
|
trips, availability, minimum run/off timers, run hours.
|
||||||
|
|
||||||
|
Additions to the brief's input list, each required to produce a
|
||||||
|
specified output and documented in README.md:
|
||||||
|
MinOffBypass - LSHH override of the minimum-off timer (4.1)
|
||||||
|
ServiceInterval - %MW10, needed for ServiceDue (4.1)
|
||||||
|
ResetHours - command 5, "reset run hours / service done" (3.3)
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
FUNCTION_BLOCK FB_PUMP
|
||||||
|
VAR_INPUT
|
||||||
|
RunRequest : BOOL;
|
||||||
|
SpeedRef : REAL; (* Hz, common to all running units *)
|
||||||
|
ThermalOK : BOOL; (* TE-31x, TRUE = healthy *)
|
||||||
|
SealLeak : BOOL; (* MSE-31x, TRUE = leak *)
|
||||||
|
Vibration : REAL; (* VE-31x, mm/s *)
|
||||||
|
DischPressure : REAL; (* PIT-31x, kPa *)
|
||||||
|
ResetTrip : BOOL;
|
||||||
|
Lockout : BOOL;
|
||||||
|
MinOffBypass : BOOL;
|
||||||
|
ServiceInterval : REAL; (* hours *)
|
||||||
|
ResetHours : BOOL;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR_OUTPUT
|
||||||
|
RunCmd : BOOL;
|
||||||
|
Running : BOOL;
|
||||||
|
Available : BOOL;
|
||||||
|
Tripped : BOOL;
|
||||||
|
State : INT; (* enum, section 3.2 *)
|
||||||
|
RunHours : REAL;
|
||||||
|
ServiceDue : BOOL;
|
||||||
|
VibAlarm : BOOL; (* 7.1 mm/s, alarm only - bitmask 10-12 *)
|
||||||
|
SealAlarm : BOOL; (* does not affect availability *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR CONSTANT
|
||||||
|
SCAN_S : REAL := 0.1; (* must match TASK INTERVAL *)
|
||||||
|
VIB_ALARM : REAL := 7.1; (* mm/s *)
|
||||||
|
VIB_TRIP : REAL := 11.0; (* mm/s *)
|
||||||
|
NOFLOW_KPA : REAL := 150.0;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR
|
||||||
|
MinRunTmr : TON;
|
||||||
|
MinOffTmr : TON;
|
||||||
|
NoFlowTmr : TON;
|
||||||
|
HasRun : BOOL; (* pump has run at least once since restart *)
|
||||||
|
StartOK : BOOL;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* --- 1. Trip reset. Runs first so that a reset issued while the
|
||||||
|
initiating condition is still present re-trips immediately
|
||||||
|
rather than latching clear. --------------------------------- *)
|
||||||
|
IF ResetTrip THEN
|
||||||
|
Tripped := FALSE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* --- 2. Trip conditions. All latch; they clear only on reset. ---- *)
|
||||||
|
IF NOT ThermalOK THEN
|
||||||
|
Tripped := TRUE; (* TE-31x, section 5 *)
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
IF Vibration > VIB_TRIP THEN
|
||||||
|
Tripped := TRUE; (* VE-31x > 11.0 mm/s *)
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* No-flow: 20 s after RunCmd goes true, low discharge pressure trips
|
||||||
|
the unit. Monitored continuously once the window has elapsed, not
|
||||||
|
sampled once, so a loss of flow while running is also caught. *)
|
||||||
|
NoFlowTmr(IN := RunCmd, PT := T#20s);
|
||||||
|
IF NoFlowTmr.Q AND (DischPressure < NOFLOW_KPA) THEN
|
||||||
|
Tripped := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* --- 3. Alarms that do not affect availability, section 4.1 ------- *)
|
||||||
|
VibAlarm := Vibration > VIB_ALARM;
|
||||||
|
SealAlarm := SealLeak;
|
||||||
|
|
||||||
|
(* --- 4. Availability. A seal leak is deliberately absent here:
|
||||||
|
it raises an alarm only, per WRPS-PRO-001 5.5. --------------- *)
|
||||||
|
Available := ThermalOK AND NOT Tripped AND NOT Lockout;
|
||||||
|
|
||||||
|
(* --- 5. Minimum run / minimum off timers.
|
||||||
|
MinOff is gated on HasRun so that a cold start is not blocked
|
||||||
|
for 5 minutes after a runtime restart. ---------------------- *)
|
||||||
|
MinRunTmr(IN := RunCmd, PT := T#5m);
|
||||||
|
MinOffTmr(IN := (NOT RunCmd) AND HasRun, PT := T#5m);
|
||||||
|
|
||||||
|
StartOK := (NOT HasRun) OR MinOffTmr.Q OR MinOffBypass;
|
||||||
|
|
||||||
|
(* --- 6. Run command ---------------------------------------------- *)
|
||||||
|
IF Tripped OR Lockout OR NOT ThermalOK THEN
|
||||||
|
RunCmd := FALSE;
|
||||||
|
ELSIF RunCmd THEN
|
||||||
|
(* running: honour minimum run before accepting a stop *)
|
||||||
|
IF (NOT RunRequest) AND MinRunTmr.Q THEN
|
||||||
|
RunCmd := FALSE;
|
||||||
|
END_IF;
|
||||||
|
ELSE
|
||||||
|
IF RunRequest AND StartOK THEN
|
||||||
|
RunCmd := TRUE;
|
||||||
|
HasRun := TRUE;
|
||||||
|
END_IF;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
Running := RunCmd;
|
||||||
|
|
||||||
|
(* --- 7. Run hours. Scan-time increments while running only. ------ *)
|
||||||
|
IF ResetHours THEN
|
||||||
|
RunHours := 0.0;
|
||||||
|
END_IF;
|
||||||
|
IF Running THEN
|
||||||
|
RunHours := RunHours + SCAN_S / 3600.0;
|
||||||
|
END_IF;
|
||||||
|
ServiceDue := RunHours >= ServiceInterval;
|
||||||
|
|
||||||
|
(* --- 8. Published state, section 3.2 ------------------------------ *)
|
||||||
|
IF Tripped THEN
|
||||||
|
State := 6; (* Tripped *)
|
||||||
|
ELSIF Lockout THEN
|
||||||
|
State := 7; (* Maintenance lockout *)
|
||||||
|
ELSIF NOT ThermalOK THEN
|
||||||
|
State := 0; (* Unavailable *)
|
||||||
|
ELSIF Running AND NOT RunRequest THEN
|
||||||
|
State := 4; (* Min-run inhibit *)
|
||||||
|
ELSIF RunCmd AND NOT NoFlowTmr.Q THEN
|
||||||
|
State := 2; (* Start delay *)
|
||||||
|
ELSIF Running THEN
|
||||||
|
State := 3; (* Running *)
|
||||||
|
ELSIF HasRun AND NOT MinOffTmr.Q THEN
|
||||||
|
State := 5; (* Min-off inhibit *)
|
||||||
|
ELSE
|
||||||
|
State := 1; (* Available, stopped *)
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
END_FUNCTION_BLOCK
|
||||||
128
03-plc/src/21_fb_duty_selector.st
Normal file
128
03-plc/src/21_fb_duty_selector.st
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
(* =====================================================================
|
||||||
|
21_fb_duty_selector.st - FB_DUTY_SELECT, section 4.2
|
||||||
|
|
||||||
|
Decides WHICH units run, never HOW MANY.
|
||||||
|
|
||||||
|
Ranking, best first:
|
||||||
|
1. unavailable units excluded entirely
|
||||||
|
2. not-due-for-service ranks above due-for-service
|
||||||
|
3. within a group, lower run hours first
|
||||||
|
4. ties break by ascending pump number (deterministic for demos)
|
||||||
|
|
||||||
|
The two rules that outrank the ranking:
|
||||||
|
- never stop a running unit to start a better-ranked one
|
||||||
|
- service-due is a preference, never a veto
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
FUNCTION_BLOCK FB_DUTY_SELECT
|
||||||
|
VAR_INPUT
|
||||||
|
Available : ARRAY[1..3] OF BOOL;
|
||||||
|
RunHours : ARRAY[1..3] OF REAL;
|
||||||
|
ServiceDue : ARRAY[1..3] OF BOOL;
|
||||||
|
RunningNow : ARRAY[1..3] OF BOOL;
|
||||||
|
PumpsRequired : INT;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR_OUTPUT
|
||||||
|
RunRequest : ARRAY[1..3] OF BOOL;
|
||||||
|
DutyPump : INT; (* 0 = none, 1-3 *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR CONSTANT
|
||||||
|
(* Service-due penalty. Larger than any credible run-hours value,
|
||||||
|
so a due unit always ranks below every not-due unit while still
|
||||||
|
remaining in the list - rule 2 is a preference, not a veto. *)
|
||||||
|
SERVICE_PENALTY : REAL := 1000000.0;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR
|
||||||
|
Rank : ARRAY[1..3] OF INT; (* pump numbers, best first *)
|
||||||
|
Used : ARRAY[1..3] OF BOOL;
|
||||||
|
Sel : ARRAY[1..3] OF BOOL;
|
||||||
|
i : INT;
|
||||||
|
k : INT;
|
||||||
|
best : INT;
|
||||||
|
bestKey : REAL;
|
||||||
|
key : REAL;
|
||||||
|
nRanked : INT;
|
||||||
|
slots : INT;
|
||||||
|
cnt : INT;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* --- 1. Reset working state --------------------------------------- *)
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
Used[i] := FALSE;
|
||||||
|
Sel[i] := FALSE;
|
||||||
|
Rank[i] := 0;
|
||||||
|
END_FOR;
|
||||||
|
nRanked := 0;
|
||||||
|
|
||||||
|
(* --- 2. Build the ranked list by repeated selection.
|
||||||
|
Scanning i ascending with a strict "<" test means an equal key
|
||||||
|
never displaces an earlier pump, which is rule 4. ------------ *)
|
||||||
|
FOR k := 1 TO 3 DO
|
||||||
|
best := 0;
|
||||||
|
bestKey := 0.0;
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
IF Available[i] AND NOT Used[i] THEN
|
||||||
|
key := RunHours[i];
|
||||||
|
IF ServiceDue[i] THEN
|
||||||
|
key := key + SERVICE_PENALTY;
|
||||||
|
END_IF;
|
||||||
|
IF (best = 0) OR (key < bestKey) THEN
|
||||||
|
best := i;
|
||||||
|
bestKey := key;
|
||||||
|
END_IF;
|
||||||
|
END_IF;
|
||||||
|
END_FOR;
|
||||||
|
IF best > 0 THEN
|
||||||
|
nRanked := nRanked + 1;
|
||||||
|
Rank[nRanked] := best;
|
||||||
|
Used[best] := TRUE;
|
||||||
|
END_IF;
|
||||||
|
END_FOR;
|
||||||
|
|
||||||
|
(* --- 3. Clamp the demand ------------------------------------------ *)
|
||||||
|
slots := PumpsRequired;
|
||||||
|
IF slots < 0 THEN
|
||||||
|
slots := 0;
|
||||||
|
END_IF;
|
||||||
|
IF slots > 3 THEN
|
||||||
|
slots := 3;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* --- 4. Pass A: units already running keep their slots.
|
||||||
|
Walking in rank order means that if the demand has dropped it
|
||||||
|
is the worst-ranked running unit that loses its slot. -------- *)
|
||||||
|
cnt := 0;
|
||||||
|
FOR k := 1 TO nRanked DO
|
||||||
|
i := Rank[k];
|
||||||
|
IF RunningNow[i] AND (cnt < slots) THEN
|
||||||
|
Sel[i] := TRUE;
|
||||||
|
cnt := cnt + 1;
|
||||||
|
END_IF;
|
||||||
|
END_FOR;
|
||||||
|
|
||||||
|
(* --- 5. Pass B: fill the slots that remain, by rank --------------- *)
|
||||||
|
FOR k := 1 TO nRanked DO
|
||||||
|
i := Rank[k];
|
||||||
|
IF (NOT Sel[i]) AND (cnt < slots) THEN
|
||||||
|
Sel[i] := TRUE;
|
||||||
|
cnt := cnt + 1;
|
||||||
|
END_IF;
|
||||||
|
END_FOR;
|
||||||
|
|
||||||
|
(* --- 6. Publish. DutyPump is the best-ranked selected unit. ------- *)
|
||||||
|
DutyPump := 0;
|
||||||
|
FOR k := 1 TO nRanked DO
|
||||||
|
i := Rank[k];
|
||||||
|
IF Sel[i] AND (DutyPump = 0) THEN
|
||||||
|
DutyPump := i;
|
||||||
|
END_IF;
|
||||||
|
END_FOR;
|
||||||
|
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
RunRequest[i] := Sel[i];
|
||||||
|
END_FOR;
|
||||||
|
|
||||||
|
END_FUNCTION_BLOCK
|
||||||
74
03-plc/src/22_fb_level_control.st
Normal file
74
03-plc/src/22_fb_level_control.st
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
(* =====================================================================
|
||||||
|
22_fb_level_control.st - FB_LEVEL_CTRL, section 4.3
|
||||||
|
|
||||||
|
PI control, no derivative term. Output is the common drive speed
|
||||||
|
for every running unit.
|
||||||
|
|
||||||
|
Direct acting: level above setpoint raises speed.
|
||||||
|
|
||||||
|
The 38.0-50.0 Hz clamp is a hard physical limit, not a preference -
|
||||||
|
below 38 Hz the 22 m static lift means no delivery at all.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
FUNCTION_BLOCK FB_LEVEL_CTRL
|
||||||
|
VAR_INPUT
|
||||||
|
Level : REAL; (* m *)
|
||||||
|
Setpoint : REAL; (* m *)
|
||||||
|
Enable : BOOL;
|
||||||
|
MinSpeed : REAL; (* Hz, already clamped >= 38.0 by CONTROL *)
|
||||||
|
MaxSpeed : REAL; (* Hz *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR_OUTPUT
|
||||||
|
Speed : REAL; (* Hz *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR CONSTANT
|
||||||
|
SCAN_S : REAL := 0.1; (* must match TASK INTERVAL *)
|
||||||
|
KP : REAL := 12.0; (* Hz/m - starting gain, expect retune *)
|
||||||
|
TI : REAL := 120.0; (* s *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR
|
||||||
|
Integ : REAL := 38.0; (* integrator carries the bias term *)
|
||||||
|
Err : REAL;
|
||||||
|
Raw : REAL;
|
||||||
|
Integrate : BOOL;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
IF NOT Enable THEN
|
||||||
|
(* Hold at minimum and reset the integrator, so that a restart does
|
||||||
|
not inherit stale integral action. *)
|
||||||
|
Integ := MinSpeed;
|
||||||
|
Speed := MinSpeed;
|
||||||
|
ELSE
|
||||||
|
Err := Level - Setpoint;
|
||||||
|
Raw := KP * Err + Integ;
|
||||||
|
|
||||||
|
(* Anti-windup: freeze the integrator whenever the output is
|
||||||
|
clamped, except when the error would drive it back into range. *)
|
||||||
|
Integrate := FALSE;
|
||||||
|
IF (Raw > MinSpeed) AND (Raw < MaxSpeed) THEN
|
||||||
|
Integrate := TRUE;
|
||||||
|
ELSIF (Raw >= MaxSpeed) AND (Err < 0.0) THEN
|
||||||
|
Integrate := TRUE;
|
||||||
|
ELSIF (Raw <= MinSpeed) AND (Err > 0.0) THEN
|
||||||
|
Integrate := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
IF Integrate THEN
|
||||||
|
Integ := Integ + (KP / TI) * Err * SCAN_S;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
Raw := KP * Err + Integ;
|
||||||
|
|
||||||
|
IF Raw > MaxSpeed THEN
|
||||||
|
Speed := MaxSpeed;
|
||||||
|
ELSIF Raw < MinSpeed THEN
|
||||||
|
Speed := MinSpeed;
|
||||||
|
ELSE
|
||||||
|
Speed := Raw;
|
||||||
|
END_IF;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
END_FUNCTION_BLOCK
|
||||||
97
03-plc/src/23_fb_headroom.st
Normal file
97
03-plc/src/23_fb_headroom.st
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
(* =====================================================================
|
||||||
|
23_fb_headroom.st - FB_HEADROOM, section 4.4
|
||||||
|
|
||||||
|
Pure calculation. No control action, no alarms.
|
||||||
|
|
||||||
|
Reference condition, WRPS-PRO-001 7.3 and pass 1 test 14:
|
||||||
|
level 4.00 m, inflow 165 L/s, one pump at 120 L/s
|
||||||
|
NetInflow = 45.0 L/s -> %QW7 = 450
|
||||||
|
VolToSpill = (6.0 - 4.0) * 120 = 240 m3
|
||||||
|
TimeToSpill = 240 * 1000 / 45 = 5333.3 s -> %QW5 = 5333
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
FUNCTION_BLOCK FB_HEADROOM
|
||||||
|
VAR_INPUT
|
||||||
|
Level : REAL; (* m *)
|
||||||
|
Inflow : REAL; (* L/s, unfiltered *)
|
||||||
|
TotalDischarge : REAL; (* L/s *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR_OUTPUT
|
||||||
|
InflowFilt : REAL; (* L/s, 30 s lag *)
|
||||||
|
NetInflow : REAL; (* L/s *)
|
||||||
|
VolToSpill : REAL; (* m3 *)
|
||||||
|
VolToLSHH : REAL; (* m3 *)
|
||||||
|
TimeToSpill : INT; (* s, 32767 = drawing down *)
|
||||||
|
TimeToLSHH : INT; (* s, 32767 = drawing down *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR CONSTANT
|
||||||
|
SCAN_S : REAL := 0.1; (* must match TASK INTERVAL *)
|
||||||
|
TAU_S : REAL := 30.0; (* inflow filter time constant *)
|
||||||
|
AREA_M2 : REAL := 120.0;
|
||||||
|
SPILL_M : REAL := 6.000;
|
||||||
|
LSHH_M : REAL := 5.500;
|
||||||
|
MIN_NET : REAL := 0.5; (* L/s, below this the figure is
|
||||||
|
meaningless - drawing down *)
|
||||||
|
NO_TIME : INT := 32767;
|
||||||
|
MAX_TIME : REAL := 32767.0;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR
|
||||||
|
Primed : BOOL := FALSE;
|
||||||
|
t : REAL;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* --- Inflow filter. First-order lag, 30 s.
|
||||||
|
|
||||||
|
Primed on the first scan rather than ramping from zero: without
|
||||||
|
this the reference figure in test 14 would take ~2 minutes to
|
||||||
|
settle and would not reproduce exactly on demand. ---------------- *)
|
||||||
|
IF NOT Primed THEN
|
||||||
|
InflowFilt := Inflow;
|
||||||
|
Primed := TRUE;
|
||||||
|
ELSE
|
||||||
|
InflowFilt := InflowFilt + (Inflow - InflowFilt) * SCAN_S / TAU_S;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
NetInflow := InflowFilt - TotalDischarge;
|
||||||
|
|
||||||
|
VolToSpill := (SPILL_M - Level) * AREA_M2;
|
||||||
|
VolToLSHH := (LSHH_M - Level) * AREA_M2;
|
||||||
|
IF VolToSpill < 0.0 THEN
|
||||||
|
VolToSpill := 0.0;
|
||||||
|
END_IF;
|
||||||
|
IF VolToLSHH < 0.0 THEN
|
||||||
|
VolToLSHH := 0.0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* --- Time to spill weir ------------------------------------------- *)
|
||||||
|
IF NetInflow <= MIN_NET THEN
|
||||||
|
TimeToSpill := NO_TIME;
|
||||||
|
ELSE
|
||||||
|
t := VolToSpill * 1000.0 / NetInflow;
|
||||||
|
IF t >= MAX_TIME THEN
|
||||||
|
TimeToSpill := NO_TIME;
|
||||||
|
ELSIF t < 0.0 THEN
|
||||||
|
TimeToSpill := 0;
|
||||||
|
ELSE
|
||||||
|
TimeToSpill := REAL_TO_INT(t);
|
||||||
|
END_IF;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* --- Time to LSHH -------------------------------------------------- *)
|
||||||
|
IF NetInflow <= MIN_NET THEN
|
||||||
|
TimeToLSHH := NO_TIME;
|
||||||
|
ELSE
|
||||||
|
t := VolToLSHH * 1000.0 / NetInflow;
|
||||||
|
IF t >= MAX_TIME THEN
|
||||||
|
TimeToLSHH := NO_TIME;
|
||||||
|
ELSIF t < 0.0 THEN
|
||||||
|
TimeToLSHH := 0;
|
||||||
|
ELSE
|
||||||
|
TimeToLSHH := REAL_TO_INT(t);
|
||||||
|
END_IF;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
END_FUNCTION_BLOCK
|
||||||
595
03-plc/src/30_prog_control.st
Normal file
595
03-plc/src/30_prog_control.st
Normal file
|
|
@ -0,0 +1,595 @@
|
||||||
|
(* =====================================================================
|
||||||
|
30_prog_control.st - PROGRAM CONTROL, section 4.5
|
||||||
|
|
||||||
|
Contains no located variable reference of any kind. Every input
|
||||||
|
arrives through the process image globals written by IO_MUX, and
|
||||||
|
every output leaves the same way. This is what makes the pass 2
|
||||||
|
simulation a mux change rather than a control change.
|
||||||
|
|
||||||
|
Execution order below follows section 4.5 step for step.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
PROGRAM CONTROL
|
||||||
|
VAR_EXTERNAL
|
||||||
|
(* process image in *)
|
||||||
|
g_LevelRaw_mm : INT;
|
||||||
|
g_Level_mm : INT;
|
||||||
|
g_Level_m : REAL;
|
||||||
|
g_Inflow_Lps : REAL;
|
||||||
|
g_Disch_Lps : REAL;
|
||||||
|
g_PumpP_kPa : ARRAY[1..3] OF REAL;
|
||||||
|
g_Vib_mms : ARRAY[1..3] OF REAL;
|
||||||
|
g_LSHH : BOOL;
|
||||||
|
g_LSLL_Wet : BOOL;
|
||||||
|
g_SpillDetected : BOOL;
|
||||||
|
g_ThermalOK : ARRAY[1..3] OF BOOL;
|
||||||
|
g_SealLeak : ARRAY[1..3] OF BOOL;
|
||||||
|
g_MainsOK : BOOL;
|
||||||
|
|
||||||
|
(* commands and setpoints in, unvalidated *)
|
||||||
|
g_cmd_Mode : INT;
|
||||||
|
g_cmd_Word : INT;
|
||||||
|
g_cmd_Param : INT;
|
||||||
|
g_sp_Level : INT;
|
||||||
|
g_sp_StartDuty : INT;
|
||||||
|
g_sp_StartP2 : INT;
|
||||||
|
g_sp_StartP3 : INT;
|
||||||
|
g_sp_StopAll : INT;
|
||||||
|
g_sp_HighAlarm : INT;
|
||||||
|
g_sp_MinSpeed : INT;
|
||||||
|
g_sp_ServiceHrs : INT;
|
||||||
|
|
||||||
|
(* published out *)
|
||||||
|
g_o_RunCmd : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Running : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Available : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Tripped : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_InAuto : BOOL;
|
||||||
|
g_o_HighLevel : BOOL;
|
||||||
|
g_o_SpillActive : BOOL;
|
||||||
|
g_o_Level_mm : INT;
|
||||||
|
g_o_Inflow_x10 : INT;
|
||||||
|
g_o_Disch_x10 : INT;
|
||||||
|
g_o_PumpsRun : INT;
|
||||||
|
g_o_Speed_x10 : INT;
|
||||||
|
g_o_TimeToSpill : INT;
|
||||||
|
g_o_TimeToLSHH : INT;
|
||||||
|
g_o_NetAccum : INT;
|
||||||
|
g_o_RunHours : ARRAY[1..3] OF INT;
|
||||||
|
g_o_VolToSpill : INT;
|
||||||
|
g_o_StationState: INT;
|
||||||
|
g_o_PumpState : ARRAY[1..3] OF INT;
|
||||||
|
g_o_DutyPump : INT;
|
||||||
|
g_o_AlarmWord : INT;
|
||||||
|
g_o_CmdAck : INT;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR CONSTANT
|
||||||
|
SPILL_MM : INT := 6000; (* spill weir, mm *)
|
||||||
|
LEVEL_MAX_MM : INT := 7000; (* LIT-101 range top *)
|
||||||
|
HARD_MIN_HZ : REAL := 38.0;
|
||||||
|
HARD_MAX_HZ : REAL := 50.0;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR
|
||||||
|
(* --- function block instances. Three explicit pump instances
|
||||||
|
rather than an ARRAY OF FB_PUMP: arrays of function blocks
|
||||||
|
are the kind of construct section 9 warns about. --------- *)
|
||||||
|
Pump1 : FB_PUMP;
|
||||||
|
Pump2 : FB_PUMP;
|
||||||
|
Pump3 : FB_PUMP;
|
||||||
|
Duty : FB_DUTY_SELECT;
|
||||||
|
LvlCtl : FB_LEVEL_CTRL;
|
||||||
|
Head : FB_HEADROOM;
|
||||||
|
|
||||||
|
(* --- validated setpoints, seeded with the section 2.3 defaults
|
||||||
|
and held at the last good value on a bad write ---------- *)
|
||||||
|
v_Mode : INT := 1;
|
||||||
|
v_SpLevel : INT := 4200;
|
||||||
|
v_StartDuty : INT := 4000;
|
||||||
|
v_StartP2 : INT := 4500;
|
||||||
|
v_StartP3 : INT := 5000;
|
||||||
|
v_StopAll : INT := 1000;
|
||||||
|
v_HighAlarm : INT := 5200;
|
||||||
|
v_MinSpeed : INT := 380;
|
||||||
|
v_ServiceHrs : INT := 4000;
|
||||||
|
SpRejected : BOOL; (* latched, bit 15, cleared by cmd 6 *)
|
||||||
|
SpOK : BOOL;
|
||||||
|
|
||||||
|
(* --- command handshake ------------------------------------------ *)
|
||||||
|
CmdBusy : BOOL;
|
||||||
|
ResetTrip : ARRAY[1..3] OF BOOL;
|
||||||
|
ResetHours : ARRAY[1..3] OF BOOL;
|
||||||
|
Lockout : ARRAY[1..3] OF BOOL;
|
||||||
|
AckAlarms : BOOL;
|
||||||
|
p : INT;
|
||||||
|
|
||||||
|
(* --- staging ---------------------------------------------------- *)
|
||||||
|
PumpsRequired : INT; (* held across scans - the hysteresis *)
|
||||||
|
PumpsAllowed : INT; (* after start stagger *)
|
||||||
|
StaggerTmr : TON;
|
||||||
|
StaggerArm : BOOL;
|
||||||
|
|
||||||
|
(* --- interlocks -------------------------------------------------- *)
|
||||||
|
DryRun : BOOL;
|
||||||
|
DryLockout : BOOL;
|
||||||
|
LevelRangeFault : BOOL;
|
||||||
|
LevelFrozen : BOOL;
|
||||||
|
LevelFault : BOOL;
|
||||||
|
LevelRef : INT;
|
||||||
|
LevelMoved : BOOL;
|
||||||
|
FrozenTmr : TON;
|
||||||
|
AnyRunning : BOOL;
|
||||||
|
|
||||||
|
(* --- duty selector interface ------------------------------------ *)
|
||||||
|
Avail : ARRAY[1..3] OF BOOL;
|
||||||
|
Hours : ARRAY[1..3] OF REAL;
|
||||||
|
SvcDue : ARRAY[1..3] OF BOOL;
|
||||||
|
RunNow : ARRAY[1..3] OF BOOL;
|
||||||
|
Req : ARRAY[1..3] OF BOOL;
|
||||||
|
|
||||||
|
(* --- misc -------------------------------------------------------- *)
|
||||||
|
Speed : REAL;
|
||||||
|
MinSpeedHz : REAL;
|
||||||
|
SpLevel_m : REAL;
|
||||||
|
HighLevel : BOOL;
|
||||||
|
PumpsRun : INT;
|
||||||
|
Alarm : DINT;
|
||||||
|
i : INT;
|
||||||
|
r : REAL;
|
||||||
|
Primed : BOOL := FALSE;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 1 - read and clamp setpoints, section 2.3
|
||||||
|
|
||||||
|
Every setpoint is validated as a set, not individually: the start
|
||||||
|
levels only make sense in order. A rejected write holds the last
|
||||||
|
good value and raises bit 15 rather than acting on it.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
(* Mode *)
|
||||||
|
IF (g_cmd_Mode = 1) OR (g_cmd_Mode = 2) THEN
|
||||||
|
v_Mode := g_cmd_Mode;
|
||||||
|
ELSE
|
||||||
|
SpRejected := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* Level setpoints. A start level at or above the spill weir must
|
||||||
|
never be accepted, section 2.3. *)
|
||||||
|
SpOK := TRUE;
|
||||||
|
IF (g_sp_StopAll < 0) OR (g_sp_StopAll >= g_sp_StartDuty) THEN
|
||||||
|
SpOK := FALSE;
|
||||||
|
END_IF;
|
||||||
|
IF (g_sp_StartDuty >= g_sp_StartP2) OR (g_sp_StartDuty >= SPILL_MM) THEN
|
||||||
|
SpOK := FALSE;
|
||||||
|
END_IF;
|
||||||
|
IF (g_sp_StartP2 >= g_sp_StartP3) OR (g_sp_StartP2 >= SPILL_MM) THEN
|
||||||
|
SpOK := FALSE;
|
||||||
|
END_IF;
|
||||||
|
IF (g_sp_StartP3 >= SPILL_MM) THEN
|
||||||
|
SpOK := FALSE;
|
||||||
|
END_IF;
|
||||||
|
IF (g_sp_Level <= g_sp_StopAll) OR (g_sp_Level >= SPILL_MM) THEN
|
||||||
|
SpOK := FALSE;
|
||||||
|
END_IF;
|
||||||
|
IF (g_sp_HighAlarm <= 0) OR (g_sp_HighAlarm > SPILL_MM) THEN
|
||||||
|
SpOK := FALSE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
IF SpOK THEN
|
||||||
|
v_SpLevel := g_sp_Level;
|
||||||
|
v_StartDuty := g_sp_StartDuty;
|
||||||
|
v_StartP2 := g_sp_StartP2;
|
||||||
|
v_StartP3 := g_sp_StartP3;
|
||||||
|
v_StopAll := g_sp_StopAll;
|
||||||
|
v_HighAlarm := g_sp_HighAlarm;
|
||||||
|
ELSE
|
||||||
|
SpRejected := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* Minimum drive speed, Hz x 10, bounded by the hard physical limits *)
|
||||||
|
IF (g_sp_MinSpeed >= 380) AND (g_sp_MinSpeed <= 500) THEN
|
||||||
|
v_MinSpeed := g_sp_MinSpeed;
|
||||||
|
ELSE
|
||||||
|
SpRejected := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* Service interval *)
|
||||||
|
IF g_sp_ServiceHrs > 0 THEN
|
||||||
|
v_ServiceHrs := g_sp_ServiceHrs;
|
||||||
|
ELSE
|
||||||
|
SpRejected := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
MinSpeedHz := INT_TO_REAL(v_MinSpeed) / 10.0;
|
||||||
|
IF MinSpeedHz < HARD_MIN_HZ THEN
|
||||||
|
MinSpeedHz := HARD_MIN_HZ;
|
||||||
|
END_IF;
|
||||||
|
SpLevel_m := INT_TO_REAL(v_SpLevel) / 1000.0;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 2 - command word and acknowledge, section 3.3
|
||||||
|
|
||||||
|
Executes on the rising edge of a non-zero %MW1, echoes the value to
|
||||||
|
%QW20, then takes no further action until %MW1 returns to 0.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
(* one-shot pulses, consumed by the FB_PUMP calls later this scan *)
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
ResetTrip[i] := FALSE;
|
||||||
|
ResetHours[i] := FALSE;
|
||||||
|
END_FOR;
|
||||||
|
AckAlarms := FALSE;
|
||||||
|
|
||||||
|
IF (g_cmd_Word <> 0) AND NOT CmdBusy THEN
|
||||||
|
CmdBusy := TRUE;
|
||||||
|
p := g_cmd_Param;
|
||||||
|
|
||||||
|
CASE g_cmd_Word OF
|
||||||
|
1: (* reset all trips *)
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
ResetTrip[i] := TRUE;
|
||||||
|
END_FOR;
|
||||||
|
(* the dry run lockout is manual-reset and only clears once
|
||||||
|
the level has actually recovered, section 5 *)
|
||||||
|
IF g_Level_mm > v_StopAll THEN
|
||||||
|
DryLockout := FALSE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
2: (* reset trip on pump in %MW2 *)
|
||||||
|
IF (p >= 1) AND (p <= 3) THEN
|
||||||
|
ResetTrip[p] := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
3: (* lock out pump in %MW2 *)
|
||||||
|
IF (p >= 1) AND (p <= 3) THEN
|
||||||
|
Lockout[p] := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
4: (* release lockout on pump in %MW2 *)
|
||||||
|
IF (p >= 1) AND (p <= 3) THEN
|
||||||
|
Lockout[p] := FALSE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
5: (* reset run hours on pump in %MW2 - service done *)
|
||||||
|
IF (p >= 1) AND (p <= 3) THEN
|
||||||
|
ResetHours[p] := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
6: (* acknowledge alarms *)
|
||||||
|
AckAlarms := TRUE;
|
||||||
|
SpRejected := FALSE;
|
||||||
|
END_CASE;
|
||||||
|
|
||||||
|
g_o_CmdAck := g_cmd_Word;
|
||||||
|
|
||||||
|
ELSIF g_cmd_Word = 0 THEN
|
||||||
|
CmdBusy := FALSE;
|
||||||
|
g_o_CmdAck := 0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Level signal integrity, section 5
|
||||||
|
|
||||||
|
A frozen transmitter reading a plausible value is the failure that
|
||||||
|
actually causes spills, and a range check alone cannot see it.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
LevelRangeFault := (g_LevelRaw_mm < 0) OR (g_LevelRaw_mm > LEVEL_MAX_MM);
|
||||||
|
|
||||||
|
IF NOT Primed THEN
|
||||||
|
LevelRef := g_LevelRaw_mm;
|
||||||
|
Primed := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
IF ABS(g_LevelRaw_mm - LevelRef) > 1 THEN
|
||||||
|
LevelRef := g_LevelRaw_mm;
|
||||||
|
LevelMoved := TRUE;
|
||||||
|
ELSE
|
||||||
|
LevelMoved := FALSE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
AnyRunning := Pump1.Running OR Pump2.Running OR Pump3.Running;
|
||||||
|
FrozenTmr(IN := AnyRunning AND NOT LevelMoved, PT := T#10m);
|
||||||
|
LevelFrozen := FrozenTmr.Q;
|
||||||
|
|
||||||
|
LevelFault := LevelRangeFault OR LevelFrozen;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 3 - determine PumpsRequired from level
|
||||||
|
|
||||||
|
The band between StopAll and StartDuty holds the previous value.
|
||||||
|
That hysteresis is the whole point; it is never recomputed from
|
||||||
|
scratch.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
IF NOT LevelFault THEN
|
||||||
|
IF g_Level_mm >= v_StartP3 THEN
|
||||||
|
PumpsRequired := 3;
|
||||||
|
ELSIF g_Level_mm >= v_StartP2 THEN
|
||||||
|
PumpsRequired := 2;
|
||||||
|
ELSIF g_Level_mm >= v_StartDuty THEN
|
||||||
|
PumpsRequired := 1;
|
||||||
|
ELSIF g_Level_mm <= v_StopAll THEN
|
||||||
|
PumpsRequired := 0;
|
||||||
|
END_IF;
|
||||||
|
(* otherwise: hold *)
|
||||||
|
ELSE
|
||||||
|
(* Fall back to discrete level control, section 5. LSHH and LSLL
|
||||||
|
are independent instruments and remain trustworthy. *)
|
||||||
|
IF g_LSHH THEN
|
||||||
|
PumpsRequired := 3;
|
||||||
|
END_IF;
|
||||||
|
(* otherwise: hold, and let the LSLL override below stop the
|
||||||
|
station if the well is actually dry *)
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 4 - LSHH override. Start all available, bypass min-off.
|
||||||
|
===================================================================== *)
|
||||||
|
IF g_LSHH THEN
|
||||||
|
PumpsRequired := 3;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 5 - LSLL override. Fail-safe: the instrument reads TRUE when
|
||||||
|
wet, so a broken wire reads dry and stops the station.
|
||||||
|
===================================================================== *)
|
||||||
|
DryRun := NOT g_LSLL_Wet;
|
||||||
|
IF DryRun THEN
|
||||||
|
PumpsRequired := 0;
|
||||||
|
DryLockout := TRUE; (* latched, manual reset via command 1 *)
|
||||||
|
END_IF;
|
||||||
|
IF DryLockout THEN
|
||||||
|
PumpsRequired := 0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 6 - station mode off
|
||||||
|
===================================================================== *)
|
||||||
|
IF v_Mode = 2 THEN
|
||||||
|
PumpsRequired := 0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 11 (applied here, before selection) - stagger starts
|
||||||
|
|
||||||
|
Held second and third starts by 30 s each, to limit inrush and the
|
||||||
|
hydraulic transient. Applied before FB_DUTY_SELECT because it
|
||||||
|
limits how many units may start, which is an input to selection,
|
||||||
|
not a correction applied afterwards. Stops are never staggered.
|
||||||
|
|
||||||
|
LSHH bypasses the stagger as well as the min-off timers, so that
|
||||||
|
the emergency response is immediate.
|
||||||
|
===================================================================== *)
|
||||||
|
IF g_LSHH THEN
|
||||||
|
PumpsAllowed := PumpsRequired;
|
||||||
|
StaggerArm := FALSE;
|
||||||
|
ELSE
|
||||||
|
StaggerTmr(IN := StaggerArm, PT := T#30s);
|
||||||
|
IF PumpsAllowed < PumpsRequired THEN
|
||||||
|
IF PumpsAllowed = 0 THEN
|
||||||
|
PumpsAllowed := 1; (* first unit starts at once *)
|
||||||
|
StaggerArm := FALSE;
|
||||||
|
ELSIF StaggerTmr.Q THEN
|
||||||
|
PumpsAllowed := PumpsAllowed + 1;
|
||||||
|
StaggerArm := FALSE;
|
||||||
|
ELSE
|
||||||
|
StaggerArm := TRUE;
|
||||||
|
END_IF;
|
||||||
|
ELSE
|
||||||
|
IF PumpsAllowed > PumpsRequired THEN
|
||||||
|
PumpsAllowed := PumpsRequired;
|
||||||
|
END_IF;
|
||||||
|
StaggerArm := FALSE;
|
||||||
|
END_IF;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 7 - duty selection
|
||||||
|
===================================================================== *)
|
||||||
|
Avail[1] := Pump1.Available; Avail[2] := Pump2.Available; Avail[3] := Pump3.Available;
|
||||||
|
Hours[1] := Pump1.RunHours; Hours[2] := Pump2.RunHours; Hours[3] := Pump3.RunHours;
|
||||||
|
SvcDue[1] := Pump1.ServiceDue; SvcDue[2] := Pump2.ServiceDue; SvcDue[3] := Pump3.ServiceDue;
|
||||||
|
RunNow[1] := Pump1.Running; RunNow[2] := Pump2.Running; RunNow[3] := Pump3.Running;
|
||||||
|
|
||||||
|
Duty(Available := Avail,
|
||||||
|
RunHours := Hours,
|
||||||
|
ServiceDue := SvcDue,
|
||||||
|
RunningNow := RunNow,
|
||||||
|
PumpsRequired := PumpsAllowed);
|
||||||
|
|
||||||
|
Req[1] := Duty.RunRequest[1];
|
||||||
|
Req[2] := Duty.RunRequest[2];
|
||||||
|
Req[3] := Duty.RunRequest[3];
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 8 - level control. On LSHH force 50.0 Hz.
|
||||||
|
===================================================================== *)
|
||||||
|
LvlCtl(Level := g_Level_m,
|
||||||
|
Setpoint := SpLevel_m,
|
||||||
|
Enable := (PumpsAllowed > 0),
|
||||||
|
MinSpeed := MinSpeedHz,
|
||||||
|
MaxSpeed := HARD_MAX_HZ);
|
||||||
|
|
||||||
|
Speed := LvlCtl.Speed;
|
||||||
|
IF g_LSHH THEN
|
||||||
|
Speed := HARD_MAX_HZ;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 9 - the pumps
|
||||||
|
===================================================================== *)
|
||||||
|
Pump1(RunRequest := Req[1],
|
||||||
|
SpeedRef := Speed,
|
||||||
|
ThermalOK := g_ThermalOK[1],
|
||||||
|
SealLeak := g_SealLeak[1],
|
||||||
|
Vibration := g_Vib_mms[1],
|
||||||
|
DischPressure := g_PumpP_kPa[1],
|
||||||
|
ResetTrip := ResetTrip[1],
|
||||||
|
Lockout := Lockout[1],
|
||||||
|
MinOffBypass := g_LSHH,
|
||||||
|
ServiceInterval := INT_TO_REAL(v_ServiceHrs),
|
||||||
|
ResetHours := ResetHours[1]);
|
||||||
|
|
||||||
|
Pump2(RunRequest := Req[2],
|
||||||
|
SpeedRef := Speed,
|
||||||
|
ThermalOK := g_ThermalOK[2],
|
||||||
|
SealLeak := g_SealLeak[2],
|
||||||
|
Vibration := g_Vib_mms[2],
|
||||||
|
DischPressure := g_PumpP_kPa[2],
|
||||||
|
ResetTrip := ResetTrip[2],
|
||||||
|
Lockout := Lockout[2],
|
||||||
|
MinOffBypass := g_LSHH,
|
||||||
|
ServiceInterval := INT_TO_REAL(v_ServiceHrs),
|
||||||
|
ResetHours := ResetHours[2]);
|
||||||
|
|
||||||
|
Pump3(RunRequest := Req[3],
|
||||||
|
SpeedRef := Speed,
|
||||||
|
ThermalOK := g_ThermalOK[3],
|
||||||
|
SealLeak := g_SealLeak[3],
|
||||||
|
Vibration := g_Vib_mms[3],
|
||||||
|
DischPressure := g_PumpP_kPa[3],
|
||||||
|
ResetTrip := ResetTrip[3],
|
||||||
|
Lockout := Lockout[3],
|
||||||
|
MinOffBypass := g_LSHH,
|
||||||
|
ServiceInterval := INT_TO_REAL(v_ServiceHrs),
|
||||||
|
ResetHours := ResetHours[3]);
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 10 - headroom
|
||||||
|
===================================================================== *)
|
||||||
|
Head(Level := g_Level_m,
|
||||||
|
Inflow := g_Inflow_Lps,
|
||||||
|
TotalDischarge := g_Disch_Lps);
|
||||||
|
|
||||||
|
|
||||||
|
(* =====================================================================
|
||||||
|
Step 12 - publish
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
PumpsRun := 0;
|
||||||
|
IF Pump1.Running THEN PumpsRun := PumpsRun + 1; END_IF;
|
||||||
|
IF Pump2.Running THEN PumpsRun := PumpsRun + 1; END_IF;
|
||||||
|
IF Pump3.Running THEN PumpsRun := PumpsRun + 1; END_IF;
|
||||||
|
|
||||||
|
HighLevel := g_Level_mm >= v_HighAlarm;
|
||||||
|
|
||||||
|
g_o_RunCmd[1] := Pump1.RunCmd;
|
||||||
|
g_o_RunCmd[2] := Pump2.RunCmd;
|
||||||
|
g_o_RunCmd[3] := Pump3.RunCmd;
|
||||||
|
g_o_Running[1] := Pump1.Running;
|
||||||
|
g_o_Running[2] := Pump2.Running;
|
||||||
|
g_o_Running[3] := Pump3.Running;
|
||||||
|
g_o_Available[1] := Pump1.Available;
|
||||||
|
g_o_Available[2] := Pump2.Available;
|
||||||
|
g_o_Available[3] := Pump3.Available;
|
||||||
|
g_o_Tripped[1] := Pump1.Tripped;
|
||||||
|
g_o_Tripped[2] := Pump2.Tripped;
|
||||||
|
g_o_Tripped[3] := Pump3.Tripped;
|
||||||
|
|
||||||
|
g_o_PumpState[1] := Pump1.State;
|
||||||
|
g_o_PumpState[2] := Pump2.State;
|
||||||
|
g_o_PumpState[3] := Pump3.State;
|
||||||
|
|
||||||
|
g_o_InAuto := (v_Mode = 1);
|
||||||
|
g_o_HighLevel := HighLevel;
|
||||||
|
g_o_SpillActive := g_SpillDetected;
|
||||||
|
|
||||||
|
g_o_Level_mm := g_Level_mm;
|
||||||
|
g_o_PumpsRun := PumpsRun;
|
||||||
|
g_o_DutyPump := Duty.DutyPump;
|
||||||
|
|
||||||
|
(* scaled analogues, clamped into 16-bit signed range *)
|
||||||
|
r := g_Inflow_Lps * 10.0;
|
||||||
|
IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
|
||||||
|
g_o_Inflow_x10 := REAL_TO_INT(r);
|
||||||
|
|
||||||
|
r := g_Disch_Lps * 10.0;
|
||||||
|
IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
|
||||||
|
g_o_Disch_x10 := REAL_TO_INT(r);
|
||||||
|
|
||||||
|
r := Speed * 10.0;
|
||||||
|
IF r > 32767.0 THEN r := 32767.0; ELSIF r < 0.0 THEN r := 0.0; END_IF;
|
||||||
|
g_o_Speed_x10 := REAL_TO_INT(r);
|
||||||
|
|
||||||
|
r := Head.NetInflow * 10.0;
|
||||||
|
IF r > 32767.0 THEN r := 32767.0; ELSIF r < -32768.0 THEN r := -32768.0; END_IF;
|
||||||
|
g_o_NetAccum := REAL_TO_INT(r);
|
||||||
|
|
||||||
|
r := Head.VolToSpill;
|
||||||
|
IF r > 32767.0 THEN r := 32767.0; ELSIF r < 0.0 THEN r := 0.0; END_IF;
|
||||||
|
g_o_VolToSpill := REAL_TO_INT(r);
|
||||||
|
|
||||||
|
g_o_TimeToSpill := Head.TimeToSpill;
|
||||||
|
g_o_TimeToLSHH := Head.TimeToLSHH;
|
||||||
|
|
||||||
|
r := Pump1.RunHours;
|
||||||
|
IF r > 32767.0 THEN r := 32767.0; END_IF;
|
||||||
|
g_o_RunHours[1] := REAL_TO_INT(r);
|
||||||
|
r := Pump2.RunHours;
|
||||||
|
IF r > 32767.0 THEN r := 32767.0; END_IF;
|
||||||
|
g_o_RunHours[2] := REAL_TO_INT(r);
|
||||||
|
r := Pump3.RunHours;
|
||||||
|
IF r > 32767.0 THEN r := 32767.0; END_IF;
|
||||||
|
g_o_RunHours[3] := REAL_TO_INT(r);
|
||||||
|
|
||||||
|
(* --- station state, section 3.1 ---------------------------------- *)
|
||||||
|
IF v_Mode = 2 THEN
|
||||||
|
g_o_StationState := 0; (* Off *)
|
||||||
|
ELSIF g_LSHH THEN
|
||||||
|
g_o_StationState := 4; (* Emergency *)
|
||||||
|
ELSIF DryLockout THEN
|
||||||
|
g_o_StationState := 5; (* Dry run lockout *)
|
||||||
|
ELSIF LevelFault THEN
|
||||||
|
g_o_StationState := 6; (* Fault *)
|
||||||
|
ELSIF HighLevel THEN
|
||||||
|
g_o_StationState := 3; (* High level *)
|
||||||
|
ELSIF PumpsRun > 0 THEN
|
||||||
|
g_o_StationState := 2; (* Pumping *)
|
||||||
|
ELSE
|
||||||
|
g_o_StationState := 1; (* Idle *)
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* --- alarm bitmask, section 6.
|
||||||
|
|
||||||
|
Accumulated in a DINT because bit 15 does not fit a signed INT.
|
||||||
|
Values at or above 32768 are folded into the negative half of the
|
||||||
|
16-bit word; CI Server must read %QW17 as UNSIGNED. ------------- *)
|
||||||
|
Alarm := 0;
|
||||||
|
IF HighLevel THEN Alarm := Alarm + 1; END_IF; (* bit0 *)
|
||||||
|
IF g_LSHH THEN Alarm := Alarm + 2; END_IF; (* bit1 *)
|
||||||
|
IF DryRun OR DryLockout THEN Alarm := Alarm + 4; END_IF; (* bit2 *)
|
||||||
|
IF g_SpillDetected THEN Alarm := Alarm + 8; END_IF; (* bit3 *)
|
||||||
|
IF Pump1.Tripped THEN Alarm := Alarm + 16; END_IF; (* bit4 *)
|
||||||
|
IF Pump2.Tripped THEN Alarm := Alarm + 32; END_IF; (* bit5 *)
|
||||||
|
IF Pump3.Tripped THEN Alarm := Alarm + 64; END_IF; (* bit6 *)
|
||||||
|
IF Pump1.SealAlarm THEN Alarm := Alarm + 128; END_IF; (* bit7 *)
|
||||||
|
IF Pump2.SealAlarm THEN Alarm := Alarm + 256; END_IF; (* bit8 *)
|
||||||
|
IF Pump3.SealAlarm THEN Alarm := Alarm + 512; END_IF; (* bit9 *)
|
||||||
|
IF Pump1.VibAlarm THEN Alarm := Alarm + 1024; END_IF; (* bit10 *)
|
||||||
|
IF Pump2.VibAlarm THEN Alarm := Alarm + 2048; END_IF; (* bit11 *)
|
||||||
|
IF Pump3.VibAlarm THEN Alarm := Alarm + 4096; END_IF; (* bit12 *)
|
||||||
|
IF LevelFault THEN Alarm := Alarm + 8192; END_IF; (* bit13 *)
|
||||||
|
IF NOT g_MainsOK THEN Alarm := Alarm + 16384; END_IF; (* bit14 *)
|
||||||
|
IF SpRejected THEN Alarm := Alarm + 32768; END_IF; (* bit15 *)
|
||||||
|
|
||||||
|
IF Alarm >= 32768 THEN
|
||||||
|
g_o_AlarmWord := DINT_TO_INT(Alarm - 65536);
|
||||||
|
ELSE
|
||||||
|
g_o_AlarmWord := DINT_TO_INT(Alarm);
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
END_PROGRAM
|
||||||
308
03-plc/src/40_prog_simulation.st
Normal file
308
03-plc/src/40_prog_simulation.st
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
(* =====================================================================
|
||||||
|
40_prog_simulation.st - PROGRAM SIMULATION, section 8.2
|
||||||
|
|
||||||
|
Pass 2 only. Excluded from the field build by build.py.
|
||||||
|
|
||||||
|
Runs on the same 100 ms task, FIRST - before IO_MUX and CONTROL - so
|
||||||
|
that a simulated image exists on the very first scan. See the header
|
||||||
|
of 91_config_sim.st for why that matters: with this program last, the
|
||||||
|
first scan latches trips on all three pumps from an all-zero field
|
||||||
|
image. The cost is that it acts on the run commands and speed
|
||||||
|
CONTROL published on the previous scan, 100 ms earlier.
|
||||||
|
|
||||||
|
It writes only g_sim_* globals; IO_MUX feeds those into the process
|
||||||
|
image while g_SimActive is TRUE. This program contains no located
|
||||||
|
variable and no control logic.
|
||||||
|
|
||||||
|
Two time bases, deliberately:
|
||||||
|
|
||||||
|
simulated time - level integration, inflow scenarios, the sim
|
||||||
|
clock. Multiplied by the time scale (%MW23).
|
||||||
|
real time - the per-pump start delay only. CONTROL's timers
|
||||||
|
are in real seconds (FB_PUMP's no-flow TON is
|
||||||
|
T#20s), so a start delay scaled by 60 would be
|
||||||
|
invisible to it and the no-flow trip could never
|
||||||
|
be demonstrated. Section 8.2 asks for the delay
|
||||||
|
precisely so that trip has something to detect,
|
||||||
|
so the delay stays real.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
PROGRAM SIMULATION
|
||||||
|
VAR_EXTERNAL
|
||||||
|
g_simcmd_Inflow : INT;
|
||||||
|
g_simcmd_Mode : INT;
|
||||||
|
g_simcmd_Reset : INT;
|
||||||
|
g_simcmd_TimeScale : INT;
|
||||||
|
|
||||||
|
g_o_RunCmd : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Speed_x10 : INT;
|
||||||
|
|
||||||
|
g_SimActive : BOOL;
|
||||||
|
g_sim_ClearReset : BOOL;
|
||||||
|
|
||||||
|
g_sim_Level_mm : INT;
|
||||||
|
g_sim_Inflow_x10 : INT;
|
||||||
|
g_sim_Disch_x10 : INT;
|
||||||
|
g_sim_ManifoldP : INT;
|
||||||
|
g_sim_PumpP : ARRAY[1..3] OF INT;
|
||||||
|
g_sim_Vib_x10 : ARRAY[1..3] OF INT;
|
||||||
|
g_sim_LSHH : BOOL;
|
||||||
|
g_sim_LSLL_Wet : BOOL;
|
||||||
|
g_sim_Spill : BOOL;
|
||||||
|
g_sim_ThermalOK : ARRAY[1..3] OF BOOL;
|
||||||
|
g_sim_SealLeak : ARRAY[1..3] OF BOOL;
|
||||||
|
g_sim_MainsOK : BOOL;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR CONSTANT
|
||||||
|
SCAN_S : REAL := 0.1; (* task interval, matches 91_config_sim *)
|
||||||
|
AREA_M2 : REAL := 120.0; (* wet well plan area *)
|
||||||
|
SPILL_M : REAL := 6.0; (* spill weir *)
|
||||||
|
LSHH_M : REAL := 5.5; (* high high float *)
|
||||||
|
LSLL_M : REAL := 0.30; (* low low float, below = dry *)
|
||||||
|
START_DLY_S : REAL := 3.0; (* real seconds, command to flow *)
|
||||||
|
|
||||||
|
HZ_LO : REAL := 38.0; (* static lift cutoff *)
|
||||||
|
HZ_HI : REAL := 50.0;
|
||||||
|
Q_LO : REAL := 65.0; (* L/s at HZ_LO *)
|
||||||
|
Q_HI : REAL := 120.0; (* L/s at HZ_HI *)
|
||||||
|
|
||||||
|
P_IDLE : REAL := 80.0; (* kPa, not delivering *)
|
||||||
|
P_BASE : REAL := 220.0; (* kPa at zero flow while delivering *)
|
||||||
|
P_PER_LPS : REAL := 1.4;
|
||||||
|
|
||||||
|
VIB_IDLE : REAL := 0.2; (* mm/s, stopped *)
|
||||||
|
VIB_BASE : REAL := 1.5; (* mm/s at zero flow *)
|
||||||
|
VIB_PER_LPS : REAL := 0.02; (* stays well under FB_PUMP's 7.1 alarm *)
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR
|
||||||
|
Init : BOOL := FALSE;
|
||||||
|
|
||||||
|
Volume_m3 : REAL;
|
||||||
|
Level_m : REAL;
|
||||||
|
Inflow_Lps : REAL;
|
||||||
|
SumFlow_Lps : REAL;
|
||||||
|
SimClock_s : REAL; (* simulated seconds since reset *)
|
||||||
|
|
||||||
|
StartDly_s : ARRAY[1..3] OF REAL;
|
||||||
|
Delivering : ARRAY[1..3] OF BOOL;
|
||||||
|
Flow_Lps : ARRAY[1..3] OF REAL;
|
||||||
|
Press_kPa : ARRAY[1..3] OF REAL;
|
||||||
|
Vib_mms : ARRAY[1..3] OF REAL;
|
||||||
|
|
||||||
|
TimeScale : REAL;
|
||||||
|
dt_s : REAL; (* simulated seconds this scan *)
|
||||||
|
Speed_Hz : REAL;
|
||||||
|
UnitQ_Lps : REAL; (* per-unit flow before derating *)
|
||||||
|
Derate : REAL;
|
||||||
|
nDelivering : INT;
|
||||||
|
i : INT;
|
||||||
|
|
||||||
|
Rnd : DINT := 12345; (* LCG state, section 8.3 *)
|
||||||
|
Noise : REAL;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Reset / first scan.
|
||||||
|
|
||||||
|
%MW22 = 1 restores the initial conditions of the selected mode
|
||||||
|
(section 8.2). The acknowledgement is a global; IO_MUX clears %MW22,
|
||||||
|
because this program may not touch located variables.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
g_sim_ClearReset := FALSE;
|
||||||
|
|
||||||
|
IF (NOT Init) OR (g_simcmd_Reset = 1) THEN
|
||||||
|
Init := TRUE;
|
||||||
|
g_sim_ClearReset := TRUE;
|
||||||
|
|
||||||
|
SimClock_s := 0.0;
|
||||||
|
|
||||||
|
IF g_simcmd_Mode = 3 THEN
|
||||||
|
Level_m := 4.000; (* section 7.3 reference condition *)
|
||||||
|
ELSE
|
||||||
|
Level_m := 3.500; (* below start duty, station idle *)
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
Volume_m3 := Level_m * AREA_M2;
|
||||||
|
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
StartDly_s[i] := 0.0;
|
||||||
|
Delivering[i] := FALSE;
|
||||||
|
Flow_Lps[i] := 0.0;
|
||||||
|
Press_kPa[i] := P_IDLE;
|
||||||
|
Vib_mms[i] := VIB_IDLE;
|
||||||
|
END_FOR;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Time base. %MW23 = 1..120, anything outside that is treated as 1.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
IF (g_simcmd_TimeScale >= 1) AND (g_simcmd_TimeScale <= 120) THEN
|
||||||
|
TimeScale := INT_TO_REAL(g_simcmd_TimeScale);
|
||||||
|
ELSE
|
||||||
|
TimeScale := 1.0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
dt_s := SCAN_S * TimeScale;
|
||||||
|
SimClock_s := SimClock_s + dt_s;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Inflow generator, section 8.2. %MW21 selects the mode.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
CASE g_simcmd_Mode OF
|
||||||
|
1: (* diurnal dry weather: 40-110 L/s over a 24 h simulated period *)
|
||||||
|
Inflow_Lps := 75.0 + 35.0 * SIN(6.283185 * SimClock_s / 86400.0);
|
||||||
|
|
||||||
|
2: (* wet weather: ramp 20 min to 300, hold 40 min, decay over 90 min *)
|
||||||
|
IF SimClock_s < 1200.0 THEN
|
||||||
|
Inflow_Lps := 75.0 + (300.0 - 75.0) * SimClock_s / 1200.0;
|
||||||
|
ELSIF SimClock_s < 3600.0 THEN
|
||||||
|
Inflow_Lps := 300.0;
|
||||||
|
ELSIF SimClock_s < 9000.0 THEN
|
||||||
|
Inflow_Lps := 300.0 - (300.0 - 75.0) * (SimClock_s - 3600.0) / 5400.0;
|
||||||
|
ELSE
|
||||||
|
Inflow_Lps := 75.0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
3: (* demo reference, section 7.3: held at exactly 165 L/s *)
|
||||||
|
Inflow_Lps := 165.0;
|
||||||
|
|
||||||
|
ELSE (* 0 and anything unrecognised: manual, %MW20 in L/s x 10 *)
|
||||||
|
Inflow_Lps := INT_TO_REAL(g_simcmd_Inflow) / 10.0;
|
||||||
|
END_CASE;
|
||||||
|
|
||||||
|
IF Inflow_Lps < 0.0 THEN
|
||||||
|
Inflow_Lps := 0.0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Per-pump flow model, section 8.2.
|
||||||
|
|
||||||
|
Speed comes from CONTROL's published common drive speed, which is
|
||||||
|
this scan's value because SIMULATION runs after CONTROL.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
Speed_Hz := INT_TO_REAL(g_o_Speed_x10) / 10.0;
|
||||||
|
|
||||||
|
IF Speed_Hz < HZ_LO THEN
|
||||||
|
UnitQ_Lps := 0.0; (* static lift cutoff *)
|
||||||
|
ELSE
|
||||||
|
IF Speed_Hz > HZ_HI THEN
|
||||||
|
Speed_Hz := HZ_HI;
|
||||||
|
END_IF;
|
||||||
|
UnitQ_Lps := Q_LO + (Speed_Hz - HZ_LO) * (Q_HI - Q_LO) / (HZ_HI - HZ_LO);
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* Start delay on REAL time - see the header comment. *)
|
||||||
|
nDelivering := 0;
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
IF g_o_RunCmd[i] THEN
|
||||||
|
IF StartDly_s[i] < START_DLY_S THEN
|
||||||
|
StartDly_s[i] := StartDly_s[i] + SCAN_S;
|
||||||
|
END_IF;
|
||||||
|
Delivering[i] := (StartDly_s[i] >= START_DLY_S) AND (UnitQ_Lps > 0.0);
|
||||||
|
ELSE
|
||||||
|
StartDly_s[i] := 0.0;
|
||||||
|
Delivering[i] := FALSE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
IF Delivering[i] THEN
|
||||||
|
nDelivering := nDelivering + 1;
|
||||||
|
END_IF;
|
||||||
|
END_FOR;
|
||||||
|
|
||||||
|
(* Parallel derating: 3 units give ~360 L/s, not a naive 3 x 120. *)
|
||||||
|
CASE nDelivering OF
|
||||||
|
1: Derate := 1.00;
|
||||||
|
2: Derate := 0.94;
|
||||||
|
3: Derate := 0.88;
|
||||||
|
ELSE Derate := 1.00;
|
||||||
|
END_CASE;
|
||||||
|
|
||||||
|
SumFlow_Lps := 0.0;
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
IF Delivering[i] THEN
|
||||||
|
Flow_Lps[i] := UnitQ_Lps * Derate;
|
||||||
|
Press_kPa[i] := P_BASE + P_PER_LPS * Flow_Lps[i];
|
||||||
|
Vib_mms[i] := VIB_BASE + VIB_PER_LPS * Flow_Lps[i];
|
||||||
|
SumFlow_Lps := SumFlow_Lps + Flow_Lps[i];
|
||||||
|
ELSE
|
||||||
|
Flow_Lps[i] := 0.0;
|
||||||
|
Press_kPa[i] := P_IDLE;
|
||||||
|
IF g_o_RunCmd[i] THEN
|
||||||
|
Vib_mms[i] := VIB_BASE; (* spinning up, no flow yet *)
|
||||||
|
ELSE
|
||||||
|
Vib_mms[i] := VIB_IDLE;
|
||||||
|
END_IF;
|
||||||
|
END_IF;
|
||||||
|
END_FOR;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Wet well integration, section 8.2.
|
||||||
|
|
||||||
|
On reaching the weir the level holds at 6.00 m and the excess is
|
||||||
|
discarded - the station is seen to spill rather than running the
|
||||||
|
level off scale.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
Volume_m3 := Volume_m3 + (Inflow_Lps - SumFlow_Lps) * dt_s / 1000.0;
|
||||||
|
|
||||||
|
IF Volume_m3 < 0.0 THEN
|
||||||
|
Volume_m3 := 0.0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
IF Volume_m3 > SPILL_M * AREA_M2 THEN
|
||||||
|
Volume_m3 := SPILL_M * AREA_M2;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
Level_m := Volume_m3 / AREA_M2;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Publish, section 8.3: small noise so the trends are not perfectly
|
||||||
|
smooth and FB_HEADROOM's inflow filter has something to filter.
|
||||||
|
LCG kept small enough that the DINT multiply cannot overflow.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
Rnd := (Rnd * 75 + 74) MOD 65537;
|
||||||
|
Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.01; (* +/-0.5% *)
|
||||||
|
g_sim_Level_mm := REAL_TO_INT(Level_m * 1000.0 * (1.0 + Noise));
|
||||||
|
|
||||||
|
Rnd := (Rnd * 75 + 74) MOD 65537;
|
||||||
|
Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.04; (* +/-2% *)
|
||||||
|
g_sim_Inflow_x10 := REAL_TO_INT(Inflow_Lps * 10.0 * (1.0 + Noise));
|
||||||
|
|
||||||
|
Rnd := (Rnd * 75 + 74) MOD 65537;
|
||||||
|
Noise := (DINT_TO_REAL(Rnd) / 65536.0 - 0.5) * 0.04;
|
||||||
|
g_sim_Disch_x10 := REAL_TO_INT(SumFlow_Lps * 10.0 * (1.0 + Noise));
|
||||||
|
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
g_sim_PumpP[i] := REAL_TO_INT(Press_kPa[i]);
|
||||||
|
g_sim_Vib_x10[i] := REAL_TO_INT(Vib_mms[i] * 10.0);
|
||||||
|
END_FOR;
|
||||||
|
|
||||||
|
(* Manifold: the highest delivering unit's pressure, idle if none. *)
|
||||||
|
g_sim_ManifoldP := REAL_TO_INT(P_IDLE);
|
||||||
|
IF nDelivering > 0 THEN
|
||||||
|
g_sim_ManifoldP := REAL_TO_INT(P_BASE + P_PER_LPS * UnitQ_Lps * Derate);
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* --- level switches. Sense conventions are section 2.1's. --------- *)
|
||||||
|
g_sim_LSHH := Level_m >= LSHH_M;
|
||||||
|
g_sim_LSLL_Wet := Level_m > LSLL_M; (* FALSE = dry *)
|
||||||
|
g_sim_Spill := Level_m >= (SPILL_M - 0.001);
|
||||||
|
|
||||||
|
(* --- plant health. Healthy unless a fault is injected; injection is
|
||||||
|
not modelled yet, so these are constant. ---------------------- *)
|
||||||
|
FOR i := 1 TO 3 DO
|
||||||
|
g_sim_ThermalOK[i] := TRUE;
|
||||||
|
g_sim_SealLeak[i] := FALSE;
|
||||||
|
END_FOR;
|
||||||
|
g_sim_MainsOK := TRUE;
|
||||||
|
|
||||||
|
(* Tell IO_MUX to take its process image from here, not from %IW/%IX. *)
|
||||||
|
g_SimActive := TRUE;
|
||||||
|
|
||||||
|
END_PROGRAM
|
||||||
292
03-plc/src/50_prog_io_mux.st
Normal file
292
03-plc/src/50_prog_io_mux.st
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
(* =====================================================================
|
||||||
|
50_prog_io_mux.st - PROGRAM IO_MUX, section 8.1
|
||||||
|
|
||||||
|
The only POU besides 10_globals.st permitted to touch located
|
||||||
|
variables. Runs BEFORE CONTROL in the task list.
|
||||||
|
|
||||||
|
Field build: %IW / %IX -> process image globals
|
||||||
|
Simulation build: PROGRAM SIMULATION outputs -> the same globals
|
||||||
|
|
||||||
|
Because IO_MUX runs before CONTROL, the %QW / %QX values it
|
||||||
|
publishes are the ones CONTROL computed on the PREVIOUS scan. At a
|
||||||
|
100 ms task that is a 100 ms publication lag on outputs only; it
|
||||||
|
does not affect any control decision. Noted in README.md.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
PROGRAM IO_MUX
|
||||||
|
VAR_EXTERNAL
|
||||||
|
(* located - field inputs *)
|
||||||
|
IW_LIT101 : INT; IW_FIT201 : INT; IW_FIT301 : INT; IW_PIT302 : INT;
|
||||||
|
IW_PIT311 : INT; IW_PIT321 : INT; IW_PIT331 : INT;
|
||||||
|
IW_VE314 : INT; IW_VE324 : INT; IW_VE334 : INT;
|
||||||
|
|
||||||
|
IX_LSHH102 : BOOL; IX_LSLL103 : BOOL; IX_LSH104 : BOOL;
|
||||||
|
IX_TE312 : BOOL; IX_TE322 : BOOL; IX_TE332 : BOOL;
|
||||||
|
IX_MSE313 : BOOL; IX_MSE323 : BOOL; IX_MSE333 : BOOL;
|
||||||
|
IX_XA502 : BOOL;
|
||||||
|
|
||||||
|
(* located - outputs *)
|
||||||
|
QX_RunCmd1 : BOOL; QX_RunCmd2 : BOOL; QX_RunCmd3 : BOOL;
|
||||||
|
QX_Running1 : BOOL; QX_Running2 : BOOL; QX_Running3 : BOOL;
|
||||||
|
QX_Avail1 : BOOL; QX_Avail2 : BOOL; QX_Avail3 : BOOL;
|
||||||
|
QX_InAuto : BOOL; QX_HighLevel : BOOL; QX_SpillActive : BOOL;
|
||||||
|
QX_Tripped1 : BOOL; QX_Tripped2 : BOOL; QX_Tripped3 : BOOL;
|
||||||
|
|
||||||
|
QW_Level : INT; QW_Inflow : INT; QW_Discharge : INT;
|
||||||
|
QW_PumpsRunning : INT; QW_Speed : INT;
|
||||||
|
QW_TimeToSpill : INT; QW_TimeToLSHH : INT; QW_NetAccum : INT;
|
||||||
|
QW_RunHours1 : INT; QW_RunHours2 : INT; QW_RunHours3 : INT;
|
||||||
|
QW_VolToSpill : INT; QW_StationState : INT;
|
||||||
|
QW_PumpState1 : INT; QW_PumpState2 : INT; QW_PumpState3 : INT;
|
||||||
|
QW_DutyPump : INT; QW_AlarmWord : INT; QW_CmdAck : INT;
|
||||||
|
|
||||||
|
(* located - commands and setpoints *)
|
||||||
|
MW_Mode : INT; MW_CmdWord : INT; MW_CmdParam : INT;
|
||||||
|
MW_SpLevel : INT; MW_StartDuty : INT; MW_StartP2 : INT;
|
||||||
|
MW_StartP3 : INT; MW_StopAll : INT; MW_HighAlarm : INT;
|
||||||
|
MW_MinSpeed : INT; MW_ServiceHrs : INT;
|
||||||
|
|
||||||
|
(* located - simulation control, section 8.2 *)
|
||||||
|
MW_SimInflow : INT; MW_SimMode : INT;
|
||||||
|
MW_SimReset : INT; MW_SimTimeScale : INT;
|
||||||
|
|
||||||
|
(* process image *)
|
||||||
|
g_LevelRaw_mm : INT; g_Level_mm : INT; g_Level_m : REAL;
|
||||||
|
g_Inflow_Lps : REAL; g_Disch_Lps : REAL; g_ManifoldP_kPa : REAL;
|
||||||
|
g_PumpP_kPa : ARRAY[1..3] OF REAL;
|
||||||
|
g_Vib_mms : ARRAY[1..3] OF REAL;
|
||||||
|
g_LSHH : BOOL; g_LSLL_Wet : BOOL; g_SpillDetected : BOOL;
|
||||||
|
g_ThermalOK : ARRAY[1..3] OF BOOL;
|
||||||
|
g_SealLeak : ARRAY[1..3] OF BOOL;
|
||||||
|
g_MainsOK : BOOL;
|
||||||
|
|
||||||
|
g_cmd_Mode : INT; g_cmd_Word : INT; g_cmd_Param : INT;
|
||||||
|
g_sp_Level : INT; g_sp_StartDuty : INT; g_sp_StartP2 : INT;
|
||||||
|
g_sp_StartP3 : INT; g_sp_StopAll : INT; g_sp_HighAlarm : INT;
|
||||||
|
g_sp_MinSpeed : INT; g_sp_ServiceHrs : INT;
|
||||||
|
|
||||||
|
g_o_RunCmd : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Running : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Available : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_Tripped : ARRAY[1..3] OF BOOL;
|
||||||
|
g_o_InAuto : BOOL; g_o_HighLevel : BOOL; g_o_SpillActive : BOOL;
|
||||||
|
g_o_Level_mm : INT; g_o_Inflow_x10 : INT; g_o_Disch_x10 : INT;
|
||||||
|
g_o_PumpsRun : INT; g_o_Speed_x10 : INT;
|
||||||
|
g_o_TimeToSpill : INT; g_o_TimeToLSHH : INT; g_o_NetAccum : INT;
|
||||||
|
g_o_RunHours : ARRAY[1..3] OF INT;
|
||||||
|
g_o_VolToSpill : INT; g_o_StationState : INT;
|
||||||
|
g_o_PumpState : ARRAY[1..3] OF INT;
|
||||||
|
g_o_DutyPump : INT; g_o_AlarmWord : INT; g_o_CmdAck : INT;
|
||||||
|
|
||||||
|
(* defaults *)
|
||||||
|
DEF_MODE : INT; DEF_SP_LEVEL : INT; DEF_START_DUTY : INT;
|
||||||
|
DEF_START_P2 : INT; DEF_START_P3 : INT; DEF_STOP_ALL : INT;
|
||||||
|
DEF_HIGH_ALARM : INT; DEF_MIN_SPEED : INT; DEF_SERVICE_HRS : INT;
|
||||||
|
|
||||||
|
(* simulation image and control, section 8 *)
|
||||||
|
g_SimActive : BOOL; g_sim_ClearReset : BOOL;
|
||||||
|
g_simcmd_Inflow : INT; g_simcmd_Mode : INT;
|
||||||
|
g_simcmd_Reset : INT; g_simcmd_TimeScale : INT;
|
||||||
|
g_sim_Level_mm : INT; g_sim_Inflow_x10 : INT; g_sim_Disch_x10 : INT;
|
||||||
|
g_sim_ManifoldP : INT;
|
||||||
|
g_sim_PumpP : ARRAY[1..3] OF INT;
|
||||||
|
g_sim_Vib_x10 : ARRAY[1..3] OF INT;
|
||||||
|
g_sim_LSHH : BOOL; g_sim_LSLL_Wet : BOOL; g_sim_Spill : BOOL;
|
||||||
|
g_sim_ThermalOK : ARRAY[1..3] OF BOOL;
|
||||||
|
g_sim_SealLeak : ARRAY[1..3] OF BOOL;
|
||||||
|
g_sim_MainsOK : BOOL;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
VAR
|
||||||
|
Seeded : BOOL := FALSE;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Seed the %MW setpoint defaults once, at first scan.
|
||||||
|
|
||||||
|
Section 9 forbids relying on retained variables, so after a runtime
|
||||||
|
restart every %MW reads 0. Writing the defaults once here means
|
||||||
|
CI Server sees real values rather than zeros, and CONTROL's
|
||||||
|
validation does not reject an all-zero image on every scan.
|
||||||
|
|
||||||
|
This is a one-shot write, not a per-scan overwrite: everything
|
||||||
|
CI Server writes afterwards survives, per section 2.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
IF NOT Seeded THEN
|
||||||
|
Seeded := TRUE;
|
||||||
|
MW_Mode := DEF_MODE;
|
||||||
|
MW_CmdWord := 0;
|
||||||
|
MW_CmdParam := 0;
|
||||||
|
MW_SpLevel := DEF_SP_LEVEL;
|
||||||
|
MW_StartDuty := DEF_START_DUTY;
|
||||||
|
MW_StartP2 := DEF_START_P2;
|
||||||
|
MW_StartP3 := DEF_START_P3;
|
||||||
|
MW_StopAll := DEF_STOP_ALL;
|
||||||
|
MW_HighAlarm := DEF_HIGH_ALARM;
|
||||||
|
MW_MinSpeed := DEF_MIN_SPEED;
|
||||||
|
MW_ServiceHrs := DEF_SERVICE_HRS;
|
||||||
|
|
||||||
|
(* Simulation defaults, section 8.2: manual inflow at the dry
|
||||||
|
weather average, time scale 1. Unused in the field build. *)
|
||||||
|
MW_SimInflow := 750;
|
||||||
|
MW_SimMode := 0;
|
||||||
|
MW_SimReset := 0;
|
||||||
|
MW_SimTimeScale := 1;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Publish the previous scan's results to %QW / %QX
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
QX_RunCmd1 := g_o_RunCmd[1];
|
||||||
|
QX_RunCmd2 := g_o_RunCmd[2];
|
||||||
|
QX_RunCmd3 := g_o_RunCmd[3];
|
||||||
|
QX_Running1 := g_o_Running[1];
|
||||||
|
QX_Running2 := g_o_Running[2];
|
||||||
|
QX_Running3 := g_o_Running[3];
|
||||||
|
QX_Avail1 := g_o_Available[1];
|
||||||
|
QX_Avail2 := g_o_Available[2];
|
||||||
|
QX_Avail3 := g_o_Available[3];
|
||||||
|
QX_InAuto := g_o_InAuto;
|
||||||
|
QX_HighLevel := g_o_HighLevel;
|
||||||
|
QX_SpillActive := g_o_SpillActive;
|
||||||
|
QX_Tripped1 := g_o_Tripped[1];
|
||||||
|
QX_Tripped2 := g_o_Tripped[2];
|
||||||
|
QX_Tripped3 := g_o_Tripped[3];
|
||||||
|
|
||||||
|
QW_Level := g_o_Level_mm;
|
||||||
|
QW_Inflow := g_o_Inflow_x10;
|
||||||
|
QW_Discharge := g_o_Disch_x10;
|
||||||
|
QW_PumpsRunning := g_o_PumpsRun;
|
||||||
|
QW_Speed := g_o_Speed_x10;
|
||||||
|
QW_TimeToSpill := g_o_TimeToSpill;
|
||||||
|
QW_TimeToLSHH := g_o_TimeToLSHH;
|
||||||
|
QW_NetAccum := g_o_NetAccum;
|
||||||
|
QW_RunHours1 := g_o_RunHours[1];
|
||||||
|
QW_RunHours2 := g_o_RunHours[2];
|
||||||
|
QW_RunHours3 := g_o_RunHours[3];
|
||||||
|
QW_VolToSpill := g_o_VolToSpill;
|
||||||
|
QW_StationState := g_o_StationState;
|
||||||
|
QW_PumpState1 := g_o_PumpState[1];
|
||||||
|
QW_PumpState2 := g_o_PumpState[2];
|
||||||
|
QW_PumpState3 := g_o_PumpState[3];
|
||||||
|
QW_DutyPump := g_o_DutyPump;
|
||||||
|
QW_AlarmWord := g_o_AlarmWord;
|
||||||
|
QW_CmdAck := g_o_CmdAck;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Commands and setpoints in
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
g_cmd_Mode := MW_Mode;
|
||||||
|
g_cmd_Word := MW_CmdWord;
|
||||||
|
g_cmd_Param := MW_CmdParam;
|
||||||
|
g_sp_Level := MW_SpLevel;
|
||||||
|
g_sp_StartDuty := MW_StartDuty;
|
||||||
|
g_sp_StartP2 := MW_StartP2;
|
||||||
|
g_sp_StartP3 := MW_StartP3;
|
||||||
|
g_sp_StopAll := MW_StopAll;
|
||||||
|
g_sp_HighAlarm := MW_HighAlarm;
|
||||||
|
g_sp_MinSpeed := MW_MinSpeed;
|
||||||
|
g_sp_ServiceHrs := MW_ServiceHrs;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
Simulation control out, and the reset acknowledgement.
|
||||||
|
|
||||||
|
SIMULATION may not touch located variables, so clearing %MW22 after
|
||||||
|
a reset happens here.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
g_simcmd_Inflow := MW_SimInflow;
|
||||||
|
g_simcmd_Mode := MW_SimMode;
|
||||||
|
g_simcmd_Reset := MW_SimReset;
|
||||||
|
g_simcmd_TimeScale := MW_SimTimeScale;
|
||||||
|
|
||||||
|
IF g_sim_ClearReset THEN
|
||||||
|
MW_SimReset := 0;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
THE MUX, section 8.1.
|
||||||
|
|
||||||
|
Field build: g_SimActive is FALSE (SIMULATION is not compiled
|
||||||
|
in and nothing ever sets it), so the located
|
||||||
|
inputs are used.
|
||||||
|
Simulation build: SIMULATION sets it TRUE every scan and the
|
||||||
|
process image comes from g_sim_* instead.
|
||||||
|
|
||||||
|
CONTROL sees identical globals either way and cannot tell which
|
||||||
|
source it is running on - that is the point of section 8.1.
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
IF g_SimActive THEN
|
||||||
|
|
||||||
|
g_LevelRaw_mm := g_sim_Level_mm;
|
||||||
|
g_Level_mm := g_sim_Level_mm;
|
||||||
|
g_Level_m := INT_TO_REAL(g_sim_Level_mm) / 1000.0;
|
||||||
|
g_Inflow_Lps := INT_TO_REAL(g_sim_Inflow_x10) / 10.0;
|
||||||
|
g_Disch_Lps := INT_TO_REAL(g_sim_Disch_x10) / 10.0;
|
||||||
|
g_ManifoldP_kPa := INT_TO_REAL(g_sim_ManifoldP);
|
||||||
|
|
||||||
|
g_PumpP_kPa[1] := INT_TO_REAL(g_sim_PumpP[1]);
|
||||||
|
g_PumpP_kPa[2] := INT_TO_REAL(g_sim_PumpP[2]);
|
||||||
|
g_PumpP_kPa[3] := INT_TO_REAL(g_sim_PumpP[3]);
|
||||||
|
|
||||||
|
g_Vib_mms[1] := INT_TO_REAL(g_sim_Vib_x10[1]) / 10.0;
|
||||||
|
g_Vib_mms[2] := INT_TO_REAL(g_sim_Vib_x10[2]) / 10.0;
|
||||||
|
g_Vib_mms[3] := INT_TO_REAL(g_sim_Vib_x10[3]) / 10.0;
|
||||||
|
|
||||||
|
g_LSHH := g_sim_LSHH;
|
||||||
|
g_LSLL_Wet := g_sim_LSLL_Wet;
|
||||||
|
g_SpillDetected := g_sim_Spill;
|
||||||
|
|
||||||
|
g_ThermalOK[1] := g_sim_ThermalOK[1];
|
||||||
|
g_ThermalOK[2] := g_sim_ThermalOK[2];
|
||||||
|
g_ThermalOK[3] := g_sim_ThermalOK[3];
|
||||||
|
|
||||||
|
g_SealLeak[1] := g_sim_SealLeak[1];
|
||||||
|
g_SealLeak[2] := g_sim_SealLeak[2];
|
||||||
|
g_SealLeak[3] := g_sim_SealLeak[3];
|
||||||
|
|
||||||
|
g_MainsOK := g_sim_MainsOK;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
|
||||||
|
(* ---------------------------------------------------------------------
|
||||||
|
FIELD SOURCE - analogue inputs, scaled to engineering units
|
||||||
|
--------------------------------------------------------------------- *)
|
||||||
|
g_LevelRaw_mm := IW_LIT101;
|
||||||
|
g_Level_mm := IW_LIT101;
|
||||||
|
g_Level_m := INT_TO_REAL(IW_LIT101) / 1000.0;
|
||||||
|
g_Inflow_Lps := INT_TO_REAL(IW_FIT201) / 10.0;
|
||||||
|
g_Disch_Lps := INT_TO_REAL(IW_FIT301) / 10.0;
|
||||||
|
g_ManifoldP_kPa := INT_TO_REAL(IW_PIT302);
|
||||||
|
|
||||||
|
g_PumpP_kPa[1] := INT_TO_REAL(IW_PIT311);
|
||||||
|
g_PumpP_kPa[2] := INT_TO_REAL(IW_PIT321);
|
||||||
|
g_PumpP_kPa[3] := INT_TO_REAL(IW_PIT331);
|
||||||
|
|
||||||
|
g_Vib_mms[1] := INT_TO_REAL(IW_VE314) / 10.0;
|
||||||
|
g_Vib_mms[2] := INT_TO_REAL(IW_VE324) / 10.0;
|
||||||
|
g_Vib_mms[3] := INT_TO_REAL(IW_VE334) / 10.0;
|
||||||
|
|
||||||
|
(* --- discrete inputs. Sense conventions are section 2.1's, and are
|
||||||
|
applied here so that CONTROL never has to know them. --------- *)
|
||||||
|
g_LSHH := IX_LSHH102; (* TRUE = wet *)
|
||||||
|
g_LSLL_Wet := IX_LSLL103; (* TRUE = wet; FALSE = dry, so a
|
||||||
|
broken wire stops the station *)
|
||||||
|
g_SpillDetected := IX_LSH104;
|
||||||
|
|
||||||
|
g_ThermalOK[1] := IX_TE312; (* TRUE = healthy *)
|
||||||
|
g_ThermalOK[2] := IX_TE322;
|
||||||
|
g_ThermalOK[3] := IX_TE332;
|
||||||
|
|
||||||
|
g_SealLeak[1] := IX_MSE313; (* TRUE = leak *)
|
||||||
|
g_SealLeak[2] := IX_MSE323;
|
||||||
|
g_SealLeak[3] := IX_MSE333;
|
||||||
|
|
||||||
|
g_MainsOK := IX_XA502; (* TRUE = healthy *)
|
||||||
|
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
END_PROGRAM
|
||||||
23
03-plc/src/90_config_field.st
Normal file
23
03-plc/src/90_config_field.st
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
(* =====================================================================
|
||||||
|
90_config_field.st - CONFIGURATION, field build
|
||||||
|
|
||||||
|
Scan task: 100 ms cyclic, section 4.
|
||||||
|
|
||||||
|
Declaration order inside the resource sets execution order within
|
||||||
|
the task: IO_MUX first, then CONTROL. Section 8.1 requires the mux
|
||||||
|
to run before the control logic.
|
||||||
|
|
||||||
|
The pass 2 simulation build replaces this file with 91_config_sim.st,
|
||||||
|
which adds PROGRAM SIMULATION after CONTROL.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
CONFIGURATION Config0
|
||||||
|
|
||||||
|
RESOURCE Res0 ON PLC
|
||||||
|
TASK plc_task(INTERVAL := T#100ms, PRIORITY := 0);
|
||||||
|
|
||||||
|
PROGRAM inst_mux WITH plc_task : IO_MUX;
|
||||||
|
PROGRAM inst_ctl WITH plc_task : CONTROL;
|
||||||
|
END_RESOURCE
|
||||||
|
|
||||||
|
END_CONFIGURATION
|
||||||
45
03-plc/src/91_config_sim.st
Normal file
45
03-plc/src/91_config_sim.st
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
(* =====================================================================
|
||||||
|
91_config_sim.st - CONFIGURATION, simulation build
|
||||||
|
|
||||||
|
Replaces 90_config_field.st in `--mode sim`. Identical to it except
|
||||||
|
for the third program instance.
|
||||||
|
|
||||||
|
Scan task: 100 ms cyclic, section 4. The interval must stay at
|
||||||
|
100 ms: FB_PUMP, FB_LEVEL_CTRL and FB_HEADROOM all integrate against
|
||||||
|
a hardcoded SCAN_S := 0.1, as does PROGRAM SIMULATION.
|
||||||
|
|
||||||
|
Declaration order sets execution order within the task:
|
||||||
|
|
||||||
|
SIMULATION first - models the plant, using the run commands and
|
||||||
|
speed CONTROL published on the previous scan
|
||||||
|
IO_MUX second - simulated image in, previous scan's outputs out
|
||||||
|
CONTROL third - reads only the process image
|
||||||
|
|
||||||
|
Section 8.2 says SIMULATION runs AFTER CONTROL. It runs first here,
|
||||||
|
deliberately. With it last, the first scan after every start has no
|
||||||
|
simulated image yet, so IO_MUX falls back to the (all zero) field
|
||||||
|
inputs - and an all-zero field means ThermalOK FALSE and LSLL dry,
|
||||||
|
which makes FB_PUMP latch a trip on all three units and the station
|
||||||
|
enter dry-run lockout. Those latch until a reset command, so every
|
||||||
|
demo would have to begin by clearing trips that never happened.
|
||||||
|
|
||||||
|
The cost of running it first is that SIMULATION reacts to run
|
||||||
|
commands one scan late - 100 ms at 1x. That is the same lag IO_MUX
|
||||||
|
already has on published outputs, and it is invisible next to a
|
||||||
|
3 s pump start delay.
|
||||||
|
|
||||||
|
Simulation time scaling (%MW23) applies only inside SIMULATION.
|
||||||
|
CONTROL's timers stay in real seconds; see README.md.
|
||||||
|
===================================================================== *)
|
||||||
|
|
||||||
|
CONFIGURATION Config0
|
||||||
|
|
||||||
|
RESOURCE Res0 ON PLC
|
||||||
|
TASK plc_task(INTERVAL := T#100ms, PRIORITY := 0);
|
||||||
|
|
||||||
|
PROGRAM inst_sim WITH plc_task : SIMULATION;
|
||||||
|
PROGRAM inst_mux WITH plc_task : IO_MUX;
|
||||||
|
PROGRAM inst_ctl WITH plc_task : CONTROL;
|
||||||
|
END_RESOURCE
|
||||||
|
|
||||||
|
END_CONFIGURATION
|
||||||
Loading…
Add table
Reference in a new issue