feat(scada): CI Server tag database, historian, displays

The old 05-scada/, restructured around the distinction its README never
drew: configuration is deployed with dssqld, displays are deployed by
file copy. Conflating the two is what made the folder confusing.

  modbus_points/          the tag database - named for the protocol,
                          since CI Server configures others differently
  modbus_points/historian/  3 groups, 49 bindings - HAND-MADE, no
                          generator, and drifted from the server
  hmi/                    displays and their generator
  ciserver-backup-2026-08/  outdated exports, evidence only, never import
  QUICKLOAD.md            dssqld export/import, the 5 classes, the import
                          order, and why an item import kills every display
  README.md               the chain end to end, and the not-updating triage

Verified during the move - the whole chain is reproducible:
  scada-points.csv and all three .qli regenerate byte-identically
  all six displays build clean

Removed the K offset machinery from build_display.py, item-ids.meta.json
and DEPLOY.md. K was a consistency check on measured ids, not a source of
them, and diagnosing a dead screen by arithmetic is wasted effort when
validating the display in CI Server's Editor Module fixes it outright.
The guidance now leads with that one action.

Recorded, not fixed: the repo's historian config disagrees with the
2026-08 CI Server export - WRPS_THIRTY_SEC and a FIVE_SECONDS group exist
on the server and not here. cicore1 was unreachable during this audit, so
which is correct is unknown.

Not carried across: __pycache__, out/*.xml, and the top-level
WRPS_Overview.xml that was tracked despite .gitignore declaring the
display XMLs to be build output.
This commit is contained in:
Clio Liu 2026-09-02 17:16:18 +10:00
parent 0bbff348f8
commit 947f632d7f
27 changed files with 11169 additions and 0 deletions

4
.gitignore vendored
View file

@ -38,3 +38,7 @@ venv/
# Generated by 03-plc/gen_project.py - the OpenPLC Editor v4 project, rebuilt # Generated by 03-plc/gen_project.py - the OpenPLC Editor v4 project, rebuilt
# from src/. Regenerate it; never hand-edit it, never commit it. # from src/. Regenerate it; never hand-edit it, never commit it.
03-plc/editor-project/ 03-plc/editor-project/
# Generated by 04-scada/hmi/build_display.py - rebuild, don't commit.
# (build_display.py is the source; the display XMLs are its output.)
04-scada/hmi/out/

119
04-scada/QUICKLOAD.md Normal file
View file

@ -0,0 +1,119 @@
# Quickload — moving configuration in and out of CI Server
`dssqld` is CI Server's quickload utility. It exports and imports **configuration
datasets** as `.qli` files: sections, items, Modbus points, historian groups.
> [!IMPORTANT]
> **Quickload is for configuration only. It is not how displays are deployed.**
> HMI displays are plain file copy/replace into the deployment's `displays`
> directory — see `hmi/DEPLOY.md`. Do not look for a `dssqld` command for them;
> there isn't one.
## The two commands
**Export** — writes every record of one class to a file:
```bash
dssqld -d MODBUS_POINT_DF -e modbus-points.qli
```
`-d` names the class, `-e` the output file.
**Import** — loads a file back in:
```bash
dssqld -i modbus-points.qli
```
The class is read from the file's own `@` header, so import takes no `-d`.
> Other classes may need different flags. These two forms are the ones proven on
> this project. **If a command fails, ask** — do not guess at flags against a live
> configuration.
## The classes this project uses
| Class | File | Holds | Generated? |
|---|---|---|---|
| `SECTION_DF` | `modbus_points/wrps_section_df.qli` | the 6 `AID.WRPS.*` sections | ✅ `gen_ciserver_qli.py` |
| `MODBUS_POINT_DF` | `modbus_points/wrps_modbus_point_df.qli` | 49 Modbus point definitions | ✅ |
| `ITEM_DF` | `modbus_points/wrps_item_df.qli` | 49 items, bound to those points | ✅ |
| `HIS_GROUP_DF` | `modbus_points/historian/his_group.qli` | 3 collection groups | ❌ hand-made |
| `ITEM_HIS_DF` | `modbus_points/historian/item_his.qli` | 49 item→group bindings | ❌ hand-made |
## Import order — it matters
```bash
dssqld -i wrps_section_df.qli # 1. sections
dssqld -i wrps_modbus_point_df.qli # 2. points
dssqld -i wrps_item_df.qli # 3. items
dssqld -i historian/his_group.qli # 4. collection groups
dssqld -i historian/item_his.qli # 5. item -> group bindings
```
Each step depends on the one before it:
- **Sections first.** CI Server derives its hierarchy from the dots in a name, and
a section must exist before an item can be created inside it.
- **Points before items**, because an item binds to a point.
- **Groups before bindings**, because `ITEM_HIS_DF` names both an item and a group
and needs both to exist.
## ⚠️ After importing items, every display goes dead
**An item import renumbers every item.** Proven on 2026-08-14: items were
re-imported to change engineering units, keeping every `NAME`, `NSID` and
`ID_NUMBER` byte-identical — and every itemId still moved. CI Server recreates
items on import rather than updating them in place.
Displays bind by **itemId** at runtime, so every value on every screen dies at
once.
**The fix is one action, and it is not a rebuild:**
> **Open each display in CI Server's Editor Module and validate it.**
Validation resolves every connection by name and rewrites the ids. Then re-harvest
so the next build ships the current ids:
```bash
python hmi/build_display.py --harvest <the saved display>.xml
```
`WRPS_TagTest` binds every project item, so harvesting that one display measures
all 49 in a single pass.
**Moving displays to a different CI Server** needs no rebuild and no re-harvest:
importing a display into the target resolves its connections by name on the way
in. **Import, then validate.** The files built for one server are the files you
ship to the next — provided the item names are identical, which means `ROOT`,
`INSTALL` and `STATION` in `gen_ciserver_qli.py` must match on both.
## Exporting, to check what the server actually holds
The repo cannot see `cicore1`, so the only way to confirm the server agrees with
this folder is to export and diff:
```bash
dssqld -d SECTION_DF -e check_section_df.qli
dssqld -d MODBUS_POINT_DF -e check_modbus_point_df.qli
dssqld -d ITEM_DF -e check_item_df.qli
dssqld -d HIS_GROUP_DF -e check_his_group.qli
dssqld -d ITEM_HIS_DF -e check_item_his_df.qli
```
An export contains **every** record of that class, not just this project's — the
2026-08 export in `ciserver-backup-2026-08/` holds 147 WRPS item records among
others. Filter on `AID.WRPS` when comparing.
**This has already caught a real drift.** The 2026-08 export shows historian
groups (`WRPS_THIRTY_SEC`, `FIVE_SECONDS`) that
`modbus_points/historian/his_group.qli` does not define. One of the two is stale
and nobody currently knows which.
## Format reference
CI Server publishes no documentation for the `.qli` format. Ten real exports are
kept in `99-reference/ciserver-qli-exports/` — every field layout and constant in
`gen_ciserver_qli.py` was copied from them. If you need a class this project does
not use, export one record of it and read the header.

115
04-scada/README.md Normal file
View file

@ -0,0 +1,115 @@
# 04-scada — the CI Server side
Everything that lives on `yau-poc-cicore1` (`10.0.0.21`): the tag database that
reads the PLC, and the displays an operator looks at.
## Two halves, two deployment mechanisms
This is the distinction to get straight before anything else. Conflating them is
what makes this folder confusing.
| | **Configuration** | **Displays** |
|---|---|---|
| What | sections, items, Modbus points, historian groups | the `.xml` screens |
| Lives in | `modbus_points/` | `hmi/` |
| **Deployed by** | **`dssqld`** — the quickload utility | **file copy** into the displays directory |
| Generated from | `03-plc/register-map.csv` | `build_display.py` |
| Binds by | `NSID` / `ID_NUMBER` | **itemId** — healed by name on import |
`dssqld` is for configuration only. It has nothing to do with displays. See
`QUICKLOAD.md`.
## The chain
Everything derives from the PLC. The PLC side leads, always.
```
03-plc/src/*.st
│ build.py
03-plc/register-map.csv the contract - 69 points
│ gen_scada_points.py
modbus_points/scada-points.csv the SCADA view - 49 points, poll groups, units
│ gen_ciserver_qli.py
modbus_points/*.qli 6 sections · 49 Modbus points · 49 items
│ dssqld -i
CI SERVER ◄──── Modbus TCP ──── the PLC on 10.0.0.17:502
│ file copy
hmi/out/*.xml 6 displays
▲ build_display.py
hmi/item-ids.csv 49 itemIds, MEASURED from CI Server
```
**Nothing in this chain is hand-edited.** Change a register in `03-plc/build.py`,
rebuild, then re-run both generators. If CI Server needs a field the generators do
not emit, it goes in the generator.
**One exception:** the historian has no generator. See
`modbus_points/historian/README.md`.
## Layout
```
QUICKLOAD.md dssqld - export and import configuration
modbus_points/ the tag database (named for the protocol: CI Server
configures other protocols differently)
historian/ collection groups and item bindings - HAND-MADE
hmi/ displays, and the generator that builds them
ciserver-backup-2026-08/ ⚠️ outdated exports, evidence only - never import
```
## ⚠️ "The values on my display are not updating"
**First, and before any other investigation:**
> **Open the display in CI Server's Editor Module and validate it.
> Then report what happened.**
Validation resolves every connection by name and repairs the file. It fixes the
overwhelmingly common cause in seconds — an itemId that no longer matches, which
happens **every time the items are re-imported**. CI Server recreates items on
import rather than updating them in place, so every id in every display moves at
once, and every value goes dead together.
That simultaneity is the signature: everything dying at the same moment is ids,
not communications.
**Only once validation has been ruled out**, work down these:
| Symptom | Cause |
|---|---|
| Some values dead, others fine | Those items were never measured. Harvest the ids — see `hmi/DEPLOY.md`. |
| Every analogue reads 0, always | You are polling `%IW`/`%IX` (FC04/FC02). Those are **field** inputs; in the simulation build nothing writes them. Live values are in the `%QW` block (FC03). |
| Values frozen at a plausible number | Communications stopped. Is the container up? Is a **program running**? The runtime opens its Modbus slave only while a program runs — a stopped program means port 502 refuses. |
| Values moving but wrong | Addressing. `%MW` starts at holding register **1024**, not 0. A wrong-by-1024 read succeeds and returns plausible nonsense. |
| Alarm word goes negative | `%QW17` must be read **unsigned**. Bit 15 does not fit a signed INT. |
**What this repo cannot tell you:** whether CI Server's own poll groups are
actually scanning. That lives on `cicore1`. If the PLC polls correctly with
`05-tests/verify_modbus.py` and the ids are good, that is the remaining place to
look.
## Status
| | |
|---|---|
| Points and items | **Imported and reading live PLC values** (2026-08-14), verified against a direct Modbus read |
| Generators | Verified reproducible 2026-09-02 — `scada-points.csv` and all three `.qli` regenerate byte-identically |
| Displays | Six built; `build_display.py` runs clean |
| Historian | 3 groups, 49 bindings — **hand-made, and drifted from the server** |
| CI Server state | ⚠️ **Unverified.** No access to `cicore1` during this audit. Everything here is what the repo believes, not what the server holds. |
### Two things left for you
- **Alarm and trend limits.** Every item imports with alarming off and limits at 0.
Which points alarm, at what threshold and priority, is engineering judgement
about the plant — not something to derive from a register map.
- **Re-export the configuration from `cicore1`** and diff it against this folder.
Until then, `ciserver-backup-2026-08/` is the only record of the server's own
state, and it is known to disagree with `modbus_points/historian/`.

View file

@ -0,0 +1,42 @@
# ciserver-backup-2026-08 — ⚠️ OUTDATED. Evidence only.
> [!CAUTION]
> **Do not import anything in this folder.** These are exports taken *out of*
> CI Server around **2026-08-20**. They are a record of what the server held at
> that moment, not source, and they are known to be superseded.
## Why they are kept
The CI Server side of this project has no other record. `cicore1` was not
reachable during the 2026-09 audit, so nothing here could be checked against the
live system. These exports are the only evidence of what was actually configured,
as opposed to what the repo believes was configured.
| File | Class | Contains |
|---|---|---|
| `export_items.qli` | `ITEM_DF` | 147 records mentioning `AID.WRPS` |
| `export_his_group.qli` | `HIS_GROUP_DF` | historian collection groups |
| `export_item_his_df.qli` | `ITEM_HIS_DF` | item → group bindings |
An export holds **every** record of its class, not only this project's. Filter on
`AID.WRPS` when comparing.
## The drift they revealed
Comparing these against `../modbus_points/historian/` shows the two do not agree:
| In this export | In the repo's source |
|---|---|
| `WRPS_EVENT`, `WRPS_ONE_SEC` | ✅ both |
| `WRPS_THIRTY_SEC` | ❌ absent |
| `FIVE_SECONDS` (binding `STN.LEVEL`, `STN.INFLOW`) | ❌ absent — not a WRPS group |
| — | `WRPS_ONE_MIN`, absent from the export |
That is the whole reason this folder exists rather than being deleted.
## Replacing it
When `cicore1` is reachable, re-export the five classes (`../../QUICKLOAD.md`),
diff them against `../modbus_points/`, resolve the differences, and **replace this
folder** with a dated export that has been verified. Do not accumulate backups —
one known-good snapshot is more useful than three of uncertain age.

View file

@ -0,0 +1,373 @@
@LANGUAGE
ENGLISH
@VERSION
1.03.00
!==================================================================================================================================
@FIELDS
NAME,DESCRIPTION,START_TIME,STOP_TIME,FIRST_ROLLOVER
LIFE_TIME,WARN_TIME,ROLLOVER_INT,FORCE_ROLLOVER,FILL_OLD,STORE_QUALITY
SAVE_TIME,SCAN_INTERVAL,CORRECT_DAYLIGHT,EXCLUDE_ARCHIVE,DATA_COMP,AGG_PERIOD_HOUR
AGG_PERIOD_SHIFT,AGG_PERIOD_DAY,AGG_PERIOD_WEEK,AGG_PERIOD_MONTH,AGG_PERIOD_YEAR,AGG_PERIOD_30MIN
AGG_CORRECT_DAY_DST,AGG_CORRECT_SHIFT_DST,NUMBER,NODE,NODE_NAME,NEXT_UNIT_SEQ
NEXT_DATA_SEQ,AVERAGING_INTERVAL,PRIORITY,SAMPLES_RECORD,TYPE,AVERAGING_METHOD
COL_STOR_TYPE,AGG_SHIFT_LENGTH,AGG_WEEK_START,AGG_SHIFT_START,AGG_DAY_START,AGG_COMP_NOT_ITEM
@HIS_GROUP_DF
"ADT","","04-11-2025 14:43:17","","20-08-2026 14:43:17",\
"52 weeks","","1 days",0,0,0,\
0,0,1,0,0,0,\
0,0,0,0,0,0,\
0,0,2,1,"",68,\
2,0,0,0,"Audit","",\
"0","0","Thursday","00:00","00:00",""
"ALARM_COUNT","","04-11-2025 14:44:36","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,5,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ALARM_CURRENT","","04-11-2025 14:44:36","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,6,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ALARM_EXT_CURR","","04-11-2025 14:44:36","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,7,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ALARM_HISTORY","","04-11-2025 14:44:36","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,8,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ALARM_SHELVE","","04-11-2025 14:44:36","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,9,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ALH","","04-11-2025 14:43:19","","20-08-2026 14:43:19",\
"52 weeks","","1 days",0,0,0,\
0,0,1,0,0,0,\
0,0,0,0,0,0,\
0,0,3,1,"",68,\
2,0,0,0,"Alarm","",\
"0","0","Thursday","00:00","00:00",""
"ALM_ATTRS_CUR","","04-11-2025 14:44:36","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,10,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ALM_ATTRS_HIS","","04-11-2025 14:44:36","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,11,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"AUDIT_TRAIL","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,12,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"AUTH_GROUP","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,13,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"EVENT","","","","21-08-2026 00:00:00",\
"1 weeks","","1 days",0,0,1,\
0,0,0,0,0,1,\
0,0,0,0,0,0,\
0,0,33,1,"",71,\
2,0,11,100,"Item","",\
"Event/Item","8 hours","Monday","00:00","00:00",""
"FIVE_MINUTES","","","","21-08-2026 00:00:00",\
"1 weeks","","1 days",0,0,1,\
0,300,1,0,0,1,\
0,0,0,0,0,0,\
0,0,34,1,"",68,\
2,0,3,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"FIVE_SECONDS","","","","20-08-2026 10:00:00",\
"1 days","","1 hours",0,0,1,\
0,5,0,0,0,1,\
0,0,0,0,0,0,\
0,0,35,1,"",70,\
2,0,8,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"ITEM_ACK","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,14,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_ALM","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,15,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_EQP","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,16,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_FO","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,17,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_HISTORY","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,18,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_HISTORY_AG","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,19,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_ID","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,20,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_ITM","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,21,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_OTH","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,22,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_TYPE","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,23,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITEM_VAL_STA","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,24,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ITM_VAL_CSV","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,25,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"ONE_HOUR","","","","21-08-2026 00:00:00",\
"52 weeks","","1 days",0,0,1,\
0,60,1,0,0,1,\
0,0,0,0,0,0,\
0,0,36,1,"",69,\
2,3600,1,0,"Item","Normal",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"ONE_MINUTE","","","","20-08-2026 12:00:00",\
"2 days","","12 hours",0,0,1,\
0,60,0,0,0,1,\
0,0,0,0,0,0,\
0,0,37,1,"",68,\
2,0,5,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"STATION","","04-11-2025 14:44:37","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,26,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"STATION_STAT","","04-11-2025 14:43:21","","20-08-2026 14:43:21",\
"31 days","","1 days",0,0,0,\
0,0,1,1,0,0,\
0,0,0,0,0,0,\
0,0,4,1,"",68,\
2,0,0,0,"Station I/O","",\
"0","0","Thursday","00:00","00:00",""
"STATION_STATS","","04-11-2025 14:44:38","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,27,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"SYSTEM_LOG","","04-11-2025 14:44:38","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,28,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"SYSTEM_LOG_HTML","","04-11-2025 14:44:38","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,29,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"SYS_LOG","","04-11-2025 14:43:16","","20-08-2026 14:43:16",\
"31 days","","1 days",0,0,0,\
0,0,1,0,0,0,\
0,0,0,0,0,0,\
0,0,1,1,"",68,\
2,0,0,0,"System Log","",\
"0","0","Thursday","00:00","00:00",""
"TEN_SECONDS","","","","20-08-2026 10:00:00",\
"1 days","","1 hours",0,0,1,\
0,10,0,0,0,1,\
0,0,0,0,0,0,\
0,0,38,1,"",68,\
2,0,7,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"THIRTY_MINUTES","","","","21-08-2026 00:00:00",\
"30 days","","1 days",0,0,1,\
0,30,1,0,0,1,\
0,0,0,0,0,0,\
0,0,39,1,"",68,\
2,1800,2,0,"Item","Normal",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"THIRTY_SECONDS","","","","20-08-2026 12:00:00",\
"2 days","","6 hours",0,0,1,\
0,30,0,0,0,1,\
0,0,0,0,0,0,\
0,0,40,1,"",68,\
2,0,6,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"TWO_MINUTES","","","","21-08-2026 00:00:00",\
"1 weeks","","1 days",0,0,1,\
0,120,0,0,0,1,\
0,0,0,0,0,0,\
0,0,41,1,"",68,\
2,0,4,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"UA_SERVER_AUDIT","","04-11-2025 14:44:38","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,30,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"USER","","04-11-2025 14:44:39","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,31,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"USER_VAL","","04-11-2025 14:44:39","","",\
"1 weeks","","",0,0,0,\
0,0,0,0,0,0,\
0,0,0,0,0,0,\
0,0,32,1,"",69,\
2,0,0,0,"Report","",\
"0","0","Thursday","00:00","00:00",""
"WRPS_EVENT","","","","21-08-2026 00:00:00",\
"1 weeks","","1 days",0,0,1,\
0,0,0,0,0,1,\
0,0,0,0,0,0,\
0,0,42,1,"",1,\
2,0,11,100,"Item","",\
"Event/Item","8 hours","Monday","00:00","00:00",""
"WRPS_ONE_SEC","","","","20-08-2026 10:00:00",\
"1 weeks","","1 hours",0,0,1,\
0,5,0,0,0,1,\
0,0,0,0,0,0,\
0,0,43,1,"",1,\
2,0,8,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"WRPS_THIRTY_SEC","","","","20-08-2026 12:00:00",\
"1 weeks","","6 hours",0,0,1,\
0,30,0,0,0,1,\
0,0,0,0,0,0,\
0,0,44,1,"",1,\
2,0,6,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""

View file

@ -0,0 +1,61 @@
@LANGUAGE
ENGLISH
@VERSION
1.03.00
!==================================================================================================================================
@FIELDS
NAME,ITEM_NAME,GROUP_NAME,ON_EACH_UPDATE,ON_FIRST_UPDATE
ON_OPTION_CHANGE,ON_PASS_PIT,ON_QUALITY_CHANGE,ON_STATUS_CHANGE,ON_VALUE_CHANGE,STORE_DEADBAND
STORE_HIGH_HIGH_LIMIT,STORE_HIGH_LIMIT,STORE_LOW_LIMIT,STORE_LOW_LOW_LIMIT,STORE_VALUE,STORE_AGG_MAX
STORE_AGG_MIN,STORE_AGG_AVG,STORE_AGG_INTG,STORE_AGG_STDV,STORE_AGG_CNT,STORE_AGG_DIFF_SUM
AGG_DIFF_SUM_RANGE,AGG_DIFF_SUM_PERC,AGG_INTG_PERIOD
@ITEM_HIS_DF
"EVENT:ESDS32DCD.HeTwTwLocksClos","ESDS32DCD.HeTwTwLocksClos","EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"EVENT:ESDS32DCD.HeTwTwLocksClosTest","ESDS32DCD.HeTwTwLocksClosTest","EVENT",0,0,\
0,0,0,1,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"FIVE_SECONDS:AID.WRPS.STN.INFLOW","AID.WRPS.STN.INFLOW","FIVE_SECONDS",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"FIVE_SECONDS:AID.WRPS.STN.LEVEL","AID.WRPS.STN.LEVEL","FIVE_SECONDS",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"ONE_HOUR:Site1.Comms_Status","Site1.Comms_Status","ONE_HOUR",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.STN.CMD_ACK","AID.WRPS.STN.CMD_ACK","WRPS_EVENT",1,1,\
1,0,1,1,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_SEC:AID.WRPS.STN.LEVEL","AID.WRPS.STN.LEVEL","WRPS_ONE_SEC",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"

File diff suppressed because it is too large Load diff

356
04-scada/hmi/DEPLOY.md Normal file
View file

@ -0,0 +1,356 @@
# Deploying the WRPS displays
Six displays are generated into `04-scada/hmi/out/` by `build_display.py`, plus
`WRPS_TagTest.xml` by `build_tagtest.py`:
| File | What | Size | Components |
|---|---|---|---|
| `WRPS_Overview.xml` | The operator display | 1920 × 1080 | 374 |
| `WRPS_Tank.xml` | Wet well detail; carries the setpoint ladder, opened from the WELL | 1920 × 1080 | 180 |
| `WRPS_FP_PU301.xml` | PU-301 pump faceplate | 760 × 700 | 109 |
| `WRPS_FP_PU302.xml` | PU-302 pump faceplate | 760 × 700 | 109 |
| `WRPS_FP_PU303.xml` | PU-303 pump faceplate | 760 × 700 | 109 |
| `WRPS_FP_Setpoints.xml` | Station setpoint faceplate | 900 × 700 | 51 |
| `WRPS_TagTest.xml` | Diagnostic list — every project item, plain vs masked | — | 49 items, 98 bindings |
Sizes and counts are what the build printed on 2026-08-20. It prints them on
every run — trust that over this table.
> `04-scada/hmi/WRPS_Overview.xml` — at the **root**, not in `out/` — is not one
> of these. It is the copy CI View itself saved during the id harvest, kept as
> evidence for the `<format>` finding below (it carries exactly one `<format>`,
> the `99.99` set by hand). Do not deploy it and do not rebuild over it.
Regenerate with:
```bash
python 04-scada/hmi/build_display.py && python 04-scada/hmi/build_tagtest.py
```
> [!IMPORTANT]
> **After any `.qli` import, re-harvest the item ids before rebuilding** — an
> import moves every id and the screens go dead without one visible symptom.
> The build now refuses to run when it cannot prove the ids are current; see
> **Item ids** below for the four-line ritual.
>
> This applies to a `.qli` import on **the same** server. Moving a display to a
> **different** CI Server instance is a different case and needs none of it —
> see *Moving a display to another CI Server instance*.
> **Never edit the XML by hand and never edit anything under
> `C:\Users\Public\Yokogawa\tls`.** Change `build_display.py` and rebuild —
> otherwise the next build silently discards the edit.
## WHERE / WHAT / WHAT FOR
**WHERE:** the CI Server machine (`PXiSEDev`).
**WHAT:** copy the files into the displays directory of the deployment —
alongside the existing `Straddle_*.xml`, i.e. the same folder the copy in
`99-reference/ciserver-hmi-deployment/displays/` came from. Then
open `WRPS_Overview` in the CI View editor.
**WHAT FOR:** CI Server loads displays from that directory by file name;
`WRPS_Overview` is what `actionActivateDisplay` targets from the faceplates, and
the faceplate names are what the pump symbols and the SETPOINTS… button target.
## Import order
The items must exist before the displays can bind to them:
1. `04-scada/modbus_points/wrps_section_df.qli` — sections
2. `04-scada/modbus_points/wrps_modbus_point_df.qli` — Modbus points
3. `04-scada/modbus_points/wrps_item_df.qli` — items
4. these displays
## What is live, and what is not
**Live on first open:**
- every number, bound to its item through `number.value``ItemValue`
- every button that writes — station mode, reset trips, reset hours, all four
simulation scenarios, time scale, reset scenario — via `actionSetItemAttribute`
with the value carried in the filter
- navigation — pump symbols open `WRPS_FP_Pump`, SETPOINTS… opens
`WRPS_FP_Setpoints`, both faceplates return to `WRPS_Overview`
- **the wet well fill**, a `dataBar` bound to `STN.LEVEL`, 0100%
- **every setpoint is editable** — a `numberField` on the eight boxes of
`WRPS_FP_Setpoints` and on SETPOINT in the overview rail
**Not yet, and deliberately:**
- **State colouring is static.** The pump bodies and status boxes are drawn in
their normal colours. Driving a *colour* from a value still needs item alarm
limits (which you deferred along with trends and alarms) or a threshold
configuration I could not confirm from the deployment, and guessing would have
produced silently wrong colours. The layout has the right shapes in the right
places, so this is a bind, not a redraw.
The level *fill* is no longer in this category: a `dataBar` carries its own
`lowLimit`/`highLimit`, so it needs no item limits at all. That was a wrong
assumption here, corrected after you added one by hand.
- **The alarm banner is not in this build**, for the same reason — it exists in
the preview and in `component-kit.md`, and follows once alarm limits are set.
- **Blink** likewise: the display sets `blinkEnabled` and `blinkDelay` 700 ms, so
the mechanism is ready, but nothing drives it yet.
## Item ids — the one thing that makes a screen lie
A connection needs **both** `itemName` and `itemId`. With the name alone every
value reads 0, and the screen looks perfectly healthy while meaning nothing.
This has bitten twice. It is now enforced by the build rather than by memory.
### If a display's values are not updating, do this first
> **Open the display in CI Server's Editor Module and validate it.**
Validation resolves every connection by name and rewrites the ids in the file. It
fixes the common case in seconds. Investigate nothing else until it has been ruled
out.
### Every item import kills every display
On 2026-08-14 the items were re-imported to change engineering units, keeping
every `NAME`, `NSID` and `ID_NUMBER` byte-identical. **Every itemId still moved.**
CI Server recreates items on import rather than updating them in place. Every
value on every screen went dead at once — that simultaneity is the signature.
So the sequence after any `dssqld -i` of items is always: **import → validate the
displays → re-harvest**, so the next build ships the current ids.
### Nothing is inferred — every id is measured
An id comes from CI Server or the build stops. There is no rule, no default and
no fallback. An id we invent that happens to be wrong produces the failure that
is hardest to see: a screen full of live-looking zeroes.
`WRPS_TagTest` binds **every** project item, which makes it the measuring
instrument: save it once in CI View and all 49 ids come back at once.
### CI View repairs ids on save — that is what makes this workable
Confirmed 2026-08-14. `WRPS_TagTest` was deployed with 25 of its 49 ids as
placeholders; those 25 rows read **0** while the other 24 read correctly. One
binding was then added by hand in CI View and the file saved — and CI View
rewrote **every** connection in the file with the right id, resolving them by
name. All 49 rows went live, and the saved file harvested all 49 in one pass.
Two things follow, and they are not in tension:
- **At runtime the id is what binds.** A wrong or placeholder id reads 0, name
notwithstanding. That is why the build refuses to emit an unmeasured id.
- **In the editor the name is authoritative.** So a display can always be healed
by opening and saving it, and that same save is the measurement.
This is why the bootstrap works at all: ship placeholders, let CI View resolve
them, harvest the result.
### Moving a display to another CI Server instance — import, then validate
Observed 2026-08-20, migrating to node `CORE_0001`.
The ids in `out/*.xml` are `PXiSEDev`'s. A different instance is a different
import, so a different `K`, so every one of them is wrong on the target. That
looks like it needs the full harvest ritual below. **It does not.** Importing
the display into the target resolves every connection by name — the same repair
described above, applied on the way in. The ids are healed by the import itself.
So moving displays between instances is **import, then validate.** No
re-harvest, no rebuild, no `.qli` round-trip, no regenerated XML. The files
built for one server are the files you ship to the next.
Validate after importing — the screen reading plausible live values rather than
a field of `0` is the signal. For the rigorous check, save the imported display
in CI View on the target and run `--verify` against it.
> [!WARNING]
> `--verify` compares against `item-ids.csv`, which still holds the **source**
> server's numbering, so expect it to flag every item on a freshly migrated
> target. That is the id file being stale for this server, not the display being
> broken. `--harvest` the saved file to re-pin it — but note `item-ids.csv`
> describes exactly one server at a time, so harvesting against `CORE_0001`
> makes it wrong for `PXiSEDev`. Branch the file per instance before doing this
> if both servers still matter.
Two things this depends on:
- **Item names must be identical.** The binding survives the move because the
name does. `AID.WRPS.*` is built from `ROOT`, `INSTALL` and `STATION` in
`gen_ciserver_qli.py:53-54` — if the target imported under different values,
the names differ and nothing resolves.
- **The node name is not in any display.** `FRONT_END_NODE` (`UNLICENCED`
`CORE_0001`) is a `.qli` field only, `gen_ciserver_qli.py:289`. No display
carries a node reference, so a node rename needs no edit to `out/*.xml`.
### The ritual — after ANY .qli import
```bash
python build_tagtest.py # the measuring display
# deploy WRPS_TagTest.xml, open it in CI View, link one value, save
python build_display.py --harvest <the saved WRPS_TagTest.xml> # all 49 measured
python build_display.py && python build_tagtest.py # now the real screens
# deploy out/*.xml, open WRPS_Overview in CI View, save
python build_display.py --verify <that saved display> # prove it
```
`build_tagtest.py` is the one build allowed to run with unmeasured ids — it has
to be, or there would be no way to measure the first one. It prints exactly how
many are placeholders, and those read 0 by design until harvested. Every other
display refuses to build until all of its items are measured.
`--verify` is the part that makes a dead screen impossible to miss. CI View
resolves every connection **by name** when it saves, so the ids in a saved file
are CI Server's own truth. The check compares them against what the build emits
and names any item that would read 0, exiting non-zero. Nobody has to look at a
screen and decide whether a `0` is a real zero.
### The guards, all hard failures
| Guard | Catches |
|---|---|
| **Every bound item must have a measured id**`check_ids()` lists all gaps at once | an inferred id, which is a guess about someone else's numbering |
| `item-ids.meta.json` records the SHA of the `wrps_item_df.qli` harvested against | items regenerated, so an import is coming or has happened |
| No measured ids at all → build fails | an uncalibrated build, which is a dead screen |
| `--verify` mismatch → exit 1 | ids that are already wrong, item by item |
None is a warning. Each error names the same one-command cure.
## `<format>` is a digit mask — the reason values read 0
`<format>` on a `number` is a **digit mask**, not a Java `DecimalFormat`
pattern:
```xml
<format>99.99</format> <!-- two integer digits, always two decimals -->
```
Each `9` is one digit position; the positions after the point are the decimals
always shown. This is what CI View itself wrote when the FIT-201 INLET value was
set to two decimals by hand, and it matches the `VALUE_FORMAT` masks in the item
exports (`"99999"` on our items, `"99.99"` on the reference analog IO).
The first build emitted `<format>0</format>` and `<format>0.0</format>`
`DecimalFormat` patterns, which read as masks **one digit wide**. Nothing wider
than a single digit had anywhere to render, so every level, flow and timer came
up empty. That is the whole bug.
**A mask must therefore be wide enough for the point's full engineering range.**
The masks live in `point_format.py`, one per item, sized from the register map
and shared between points of the same kind so the screens stay consistent:
| Kind | Mask | Why |
|---|---|---|
| level & level setpoints (%) | `999.9` | % of the 6.000 m spill weir |
| flows, net accumulation (m³/h) | `9999.9` | three pumps reach ~1300 |
| drive speed, min speed (%) | `999.9` | % of 50 Hz |
| durations (s), hour counters (h) | `99999` | 32767 = "drawing down" sentinel |
| volume to spill (m³) | `9999` | |
| alarm bitmask | `99999` | unsigned 16-bit |
| enums, counts, modes, booleans | `9` | single digit by definition |
Decimals follow each point's real resolution rather than a fixed two. Level in %
of the spill weir resolves to 0.017% because the register underneath is mm, and
a flow in m³/h to 0.1 — so both get one decimal, and a second would be invented
precision. Integer registers get none. `point_format.check()` fails the build if
a new item has no mask, and the masks are cross-checked against `format_mask` in
`04-scada/modbus_points/scada-points.csv`, which is what CI Server imports as
`VALUE_FORMAT`.
Element order matters to match what CI View saves: `<htmlId>`, `<format>`,
`<font>`, …, `<data>`. A masked number carries no `<value>`.
### Two earlier conclusions here were wrong
- **"Actions must be shared objects."** Not true. `WRPS_TagTest` round 1 bound
all 49 items twice — once as `<data ref>` in `globalSection`, once inline as
`<data type="data">` — and both columns updated identically. The generator
still emits shared objects because that is what CI View writes on save and it
de-duplicates, but it is a preference.
- **"A bound number needs `<value>` and must not have `<format>`."** Also not
true, and it only looked true because every `<format>` seen up to that point
was a one-digit DecimalFormat pattern. A correctly sized mask works, and
`<value>` is just the placeholder shown before the first update.
What round 1 *did* establish and still holds: the itemIds are right, including
all 23 that were only ever derived from the creation-index rule, and the CI
Server side — items, sections, Modbus points — is fine.
## Fixed after the first deploy
Three things the screenshot showed, all now corrected in the generator:
- **Popups opened full-screen.** `actionActivateDisplay` needs
`<type value="ActivateSpecific"/>` plus `<layout>FixedPopup</layout>` and
`<layoutFrame>default</layoutFrame>`; without them it replaces the current
display. Faceplate CLOSE buttons now use `actionExitDisplay`, which closes the
popup rather than activating another display over it.
- **Grey blobs over the pump symbols.** I had used a real `button` as the click
target, so it drew its own face. Replaced with a fully transparent rectangle
(`a="0"` paint) carrying the action — which is how the Straddle screens do
invisible hit areas.
- **Value boxes sitting on the pipes.** The per-pump boxes were at fixed
positions that crossed the discharge lines. Pump data now sits in its own
column at x=1380, the ladder labels moved outside the well, and the mimic was
re-spaced around a wider well.
## Other things I could not verify
1. **Text placement is approximate.** `keepOriginalSize` is set and the width is
estimated from character count, so a label may sit a few pixels off. The editor
will normalise on save.
3. **Pipes are rectangles, not `line` components.** The `line` component positions
itself with `curvePoints`/`curveSegments`, and the single example in the
deployment did not make its anchor convention unambiguous. Filled rectangles
are exact and are what the site's own screens use. Cost: no diagonals, which a
P&ID does not need.
4. **`number.format`** is emitted as a Java `DecimalFormat` pattern (`0`, `0.0`).
If the editor shows a different precision, that is the property to adjust.
## If the display opens empty or unbound
Deploy `WRPS_TagTest.xml` and open it — it takes graphics, layout and every
other display out of the picture, and shows all 49 items twice: unmasked on the
left, masked on the right. A blank on the right beside a live value on the left
is a mask that is too narrow. If it is live
and another display is not, the fault is in that display, not in the items or
the Modbus chain.
Otherwise check in this order: the `<format>` mask width (above) →
items imported (step 3 above) → item names match `AID.WRPS.*` exactly → the
`itemId` note above. `build_display.py` refuses to
build if any display binds to an item that is not in `wrps_item_df.qli`, so a
name mismatch cannot be the cause unless the items were imported under a
different root.
## Editable fields and bars — the two components CI View taught us
Both were added by hand in CI View on 2026-08-14 and copied out of the saved
file, rather than guessed from the component definitions:
| | `numberField` | `dataBar` |
|---|---|---|
| Property | `numberField.value` | `dataBar.value` |
| Geometry | **element attributes**, not child elements | same |
| Connection | `<itemAttribute>ItemValue</itemAttribute>`, **no** `<direction>` | ordinary read connection |
| Range | n/a | `<lowLimit>` / `<highLimit>` **on the component** |
| Format | none — a mask fights the editing | n/a |
The geometry difference is the trap: every other component here stores `x`, `y`,
`top`, `bottom`, `left`, `right` as child elements, and these two store them as
attributes on the opening tag.
`dataBar` needing no item configuration is what makes the live well fill
possible — `lowLimit` 0, `highLimit` 100 against a level that now reads in % of
the spill weir, so the bar maps onto the well with no scaling anywhere.
`Straddle_Detail.xml` in the reference deployment does the same for a fuel level.
## Layout is audited, not eyeballed
```bash
python check_layout.py
```
Estimates each text run's drawn extent and reports collisions. It caught the
wet well caption running into the top ladder label after the units relabel —
the 100% mark *is* the well top, so the caption and that label share a line.
Approximate by construction (it estimates glyph widths), so it is a net for
gross collisions, not a substitute for looking at the screen.

146
04-scada/hmi/README.md Normal file
View file

@ -0,0 +1,146 @@
# hmi — what CI Server can draw, and the kit we build with
Study of the live CI Server deployment copied to
`99-reference/ciserver-hmi-deployment/`: 53 components, 3130
symbols (177 of them the AOG standard set), 74 displays, 2 layouts.
> **`C:\Users\Public\Yokogawa\tls` is reference only.** Never edit it. Everything
> is authored in this repo and handed over for the user to deploy.
## 1. Components — the primitives a display is made of
| Group | Components |
|---|---|
| Drawing | `Rectangle` `Ellipse` `Arc` `Line` `Polygon` `Spline` `Text` `Icon` |
| Structure | `Display` `GraphicSet` `Symbol` `SymbolInstance` `VisibilityGroup` `VisualizationLayer` `VisualizationParameter` `Link` |
| Data display | `Number` `DataBar` `CircularScale` `RectangularScale` `Radial` `MicroTrend` `Chart` `DtsChart` |
| Input | `JButton` `JToggleButton` `JCheckBox` `JRadioButton` `JComboBox` `JSlider` `JSpinner` `JFormattedNumberField` `JFormattedTextField` `JTextArea` `JDateTimeField` `JList` `JNavigationTree` |
| System | `AlarmOverview` `ShelvedOverview` `BlockedItems` `AlarmSound` `Playback` `Reporting` `MapViewer` `PDFViewer` `DatasetTable` `DatasetForm` `ScriptedFunction` |
So the earlier claim that CI View was "rectangles and text only" was wrong — that
was an artefact of the *Straddle* screens, which happen to use little else.
Ellipses, lines, polygons, trends, gauges and real input widgets are all available.
## 2. AOG — the standard symbol library
`symbols/AOG_*.xml` is Yokogawa's **Advanced Operating Graphics** set: a
high-performance HMI standard in the ISA-101 tradition. Relevant here:
| Purpose | Symbol |
|---|---|
| Analog value | `AOG_ProcessIndicator`, `AOG_NumberIndicator` |
| Typed instrument | `AOG_IndicatorLevel` `AOG_IndicatorFlow` `AOG_IndicatorPressure` `AOG_IndicatorTemperature` |
| Bar / needle | `AOG_VerticalBarIndicator` `AOG_HorizontalBarIndicator` `AOG_*NeedleIndicator` `AOG_RadialNeedleIndicator` |
| Digital state | `AOG_OnOffIndicator` `AOG_AlarmIndicator` `AOG_TextIndicator` `AOG_ManualIndicator` |
| Equipment | `AOG_Pump` `AOG_PumpSimple` `AOG_PumpWithOutlet` `AOG_Motor` `AOG_Tank` `AOG_Vessel` `AOG_AtmosphericTank` |
| Valves | `AOG_CheckValve` `AOG_Horizontal2WayValve` `AOG_VerticalOnOffValve` `AOG_ButterflyValve` `AOG_BlockValveStation` |
| Instrument bubble | `AOG_Instrument` `AOG_Transmitter` |
| Faceplate | `AOG_Faceplate` `AOG_FP_Frame` `AOG_FP_PVValue` `AOG_FP_SVValue` `AOG_FP_Mode` `AOG_FP_Status` `AOG_FP_BarIndicator` |
| Trend | `AOG_Trend` `AOG_TrendIndicator` |
| Screen furniture | `AOG_DisplayTitle` `AOG_SelectionBox` `AOG_NavigationSelectionBox` `AOG_TagNameComponent` |
An `AOG_O*` variant exists for most (`AOG_OPump`, `AOG_ONumberIndicator` …).
### The parameter contract — this is what standardisation means here
Every dynamic AOG symbol takes the same external parameters:
```
item the SCADA item to display (AID.WRPS.STN.LEVEL)
setPointItem optional companion item
tagName label, with alwaysShowTagName / neverShowTagName
format numeric format
showBackground draw the indicator box
showAlarmIndicator built-in alarm annunciation
selectable can the operator click it
displayToActivate display opened on click <- this is how faceplates work
```
Two consequences worth stating plainly:
1. **Analog values are consistent by construction.** Every one is an
`AOG_ProcessIndicator`; nothing is hand-drawn, so nothing can drift.
2. **Faceplates are separate displays**, not overlays. A symbol names one in
`displayToActivate`. `AOG_ItemFaceplate` and `AOG_ObjectFacePlate` already
exist and can be used as-is, or copied and specialised for a pump.
### Ready-made displays
`displays/AOG_displays/` includes `AOG_DisplayTemplate` (start a new display from
it), `AOG_Style` (the colour tokens), `AOG_ItemFaceplate`, `AOG_ItemTuningPanel`,
`AOG_AlarmBanner`, `AOG_AlarmCurrent`, `AOG_MenuMain`, `AOG_SetNumberPV/SV/SH/SL`
(the standard numeric-entry popups — use these for setpoint writes rather than
building an entry field).
## 3. Two styles exist on this system — pick one
| | AOG standard | Site style (Straddle screens) |
|---|---|---|
| Ground | `#E3E3E3` light grey | `#171B22` near-black |
| Panels | `#F0F0F0` / `#F2F2F2` | `#262D38` / `#333B47` |
| Text | `#404040` / `#6D6D6D` | `#C8D0DA` |
| Accent | none — colour is reserved | `#5FB8D0` cyan |
| Alarm | `#FF0000` `#FFA500` `#FFFF00` | `#D65B5B` |
| Built from | AOG symbols | hand-drawn rectangles + text |
The AOG palette follows the high-performance HMI convention: a grey plant, and
**colour only where something is abnormal**. The site's own Straddle screens are a
custom dark theme that does not use the AOG library at all — no display in the
deployment instantiates a single `AOG_*` symbol.
**Recommendation: AOG.** It is the product standard, it brings the faceplate
machinery and alarm annunciation for free, and its parameter contract is what
keeps every indicator identical. The cost is that the demo will not match the
Straddle screens an operator may already know.
## 4. Decision: our own kit, AOG as reference only
Settled 2026-08-14 with the user:
- **Build our own components**, not AOG instances — only what this project needs.
AOG's *parameter contract* is the thing worth copying, not its artwork.
- **Light theme, but not AOG's.** The customer reads AOG as dated; the cause is
the bevelled 3D symbols and mid-grey ground, so the kit is flat, square, hairline
bordered, white on cool neutral.
- **16:9**, and **operators may write** setpoint, command and simulation items.
The kit is specified in `component-kit.md`. The table below is superseded by it
and kept only to show which AOG symbol each of our components was modelled on.
## 5. AOG reference for each component we built
What each element of the display is built from, so the same thing always looks the
same:
| Element | Component | Bound to |
|---|---|---|
| Title bar | `AOG_DisplayTitle` | — |
| Wet well | `AOG_Tank` + `AOG_VerticalBarIndicator` | `STN.LEVEL` |
| Level, flows, pressures, speed | `AOG_ProcessIndicator` | `STN.*`, `PU30x.*` |
| Level switches, run/avail/trip | `AOG_OnOffIndicator` | `PU30x.*`, `STN.*` |
| Pumps | `AOG_Pump`, `displayToActivate` = pump faceplate | `PU30x.RUNNING` |
| Check / isolation valves | `AOG_CheckValve`, `AOG_Horizontal2WayValve` | static |
| Instrument bubbles | `AOG_Instrument` | tag text |
| Setpoint entry | `AOG_SetNumberSV` popup | `SP.*` |
| Pump faceplate | copy of `AOG_ItemFaceplate` | `PU30x.*` |
| Buttons (mode, commands, simulation) | `AOG_SelectionBox` / `JButton` | `SP.MODE`, `SP.CMD_WORD`, `SIM.*` |
## Scripts in this directory
| Command | What it does |
|---|---|
| `python build_display.py` | Builds the three displays into `out/`. **Refuses to build if the item ids cannot be proven current.** |
| `python build_display.py --harvest <saved.xml>` | Re-calibrates the item ids from a display CI View saved, and records provenance |
| `python build_display.py --verify <saved.xml>` | Compares CI Server's own ids against what the build emits; exit 1 on any mismatch |
| `python build_tagtest.py` | Builds the diagnostic display that lists every item |
| `python check_layout.py` | Reports text that collides with other text; exit 1 on any collision |
The two `--` modes exist because of the failure that keeps recurring: an item
id that no longer matches CI Server binds silently to nothing, and the screen
looks fine. `DEPLOY.md` has the ritual.
## Status
Study complete; nothing built yet. The layout is under review as a mockup — see
the preview link in the conversation, and `04-scada/hmi/` will hold the display
XML once the style question above is settled.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Build WRPS_TagTest.xml - a bare diagnostic display, no graphics.
Why this exists
---------------
Round 1 (shared <data ref> vs inline <data type="data">) came back with both
columns live for all 49 items, which ruled out the binding form, the derived
itemIds and the CI Server side. The remaining difference was <format>: the
generated displays carried DecimalFormat patterns ("0", "0.0"), and CI View
writes <format> as a DIGIT MASK ("99.99"). A one-digit-wide mask has nowhere
to render a four-digit level.
Round 2, this build, tests the masks in point_format.py before they go
anywhere near the operator screens:
* every item in the project, one row each, numbered - nothing else on
the screen, so nothing else can be blamed
* each item is bound TWICE, side by side:
PLAIN - no <format> at all. The control: this is exactly what was
live in round 1, so it must stay live.
MASKED - the same binding plus <format> from point_format.py, written
where CI View puts it (straight after <htmlId>, no <value>)
* so each row answers, independently: does the mask render the value, is
it wide enough for the real range, and does it round to the right
number of decimals
Read it as: PLAIN live + MASKED blank -> that mask is too narrow or wrong.
both live, MASKED rounded -> the mask is good.
both blank -> that item, not the mask.
python 04-scada/hmi/build_tagtest.py
"""
import datetime
import re
from pathlib import Path
import point_format as pf
HERE = Path(__file__).resolve().parent
OUT = HERE / "out" / "WRPS_TagTest.xml"
ITEM_FILE = HERE.parents[1] / "04-scada" / "modbus_points" / "wrps_item_df.qli"
IDS_FILE = HERE / "item-ids.csv"
W, H = 1920, 1080
# This display is the BOOTSTRAP: it binds every project item, so saving it
# once in CI View measures all 49 ids in a single pass. It is therefore
# the one display allowed to carry ids that are not measured yet - it has
# to be buildable before anything has been measured, or there is no way to
# measure anything. CI View resolves each connection by NAME on save, so
# a placeholder id is enough for the harvest to work.
#
# It used to carry its own copy of the id rule, with the base hardcoded at
# 136 - which silently went a whole generation stale when the items were
# re-imported. Ids now come from build_display, the single source, and
# anything unmeasured is an obvious placeholder rather than a plausible
# wrong number.
PLACEHOLDER = "1.1.0.0.0.1.1.%d"
# ------------------------------------------------------------------ items
def items():
"""Project items in creation order, with their itemId.
(index, name, representation, itemId, measured?)
"""
import build_display as bd
measured = bd.measured_ids()
reps = bd.item_reps()
q = ITEM_FILE.read_text(encoding="utf-8")
order, seen = [], set()
for m in re.finditer(r'^"(AID\.WRPS\.[A-Z0-9_.]+)"', q, re.M):
if m.group(1) not in seen:
seen.add(m.group(1))
order.append(m.group(1))
out = []
for k, name in enumerate(order):
rep = reps.get(name, "Real")
iid = measured.get(name, PLACEHOLDER % (1 if rep == "Boolean" else 7))
out.append((k, name, rep, iid, name in measured))
return out
# ------------------------------------------------------------------ layout
ROWS_PER_COL = 25
ROW_PITCH = 38
TOP = 92
COL_X = (40, 990) # left edge of each page column
W_IDX, W_NAME, W_REF = 34, 470, 150
LAB = "Segoe UI"
VAL = "Consolas"
def esc(s):
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
def half_w(text, size, font):
"""Half the rendered width. Approximate - the editor normalises on save."""
per = 0.60 if font == VAL else 0.52
return round(len(text) * size * per / 2.0, 4)
class Doc:
def __init__(self):
self.refs = [] # (id, xml) in globalSection
self.parts = [] # component xml
self._next_ref = 10
self._next_html = 100
self._cache = {}
def ref(self, key, xml):
if key in self._cache:
return self._cache[key]
rid = self._next_ref
self._next_ref += 1
self.refs.append((rid, xml))
self._cache[key] = rid
return rid
def hid(self):
self._next_html += 1
return self._next_html
def paint(self, rgb):
return self.ref(("paint", rgb),
'<sharedObject type="paint">\n'
' <paint type="colorSet" r="%d" g="%d" b="%d"/>\n'
' </sharedObject>' % rgb)
def filt(self, value="0.0"):
return self.ref(("filter", value),
'<sharedObject type="filter">\n'
' <value>%s</value>\n'
' </sharedObject>' % value)
# --- components ----------------------------------------------------
def text(self, x, y, value, size=14, colour=(14, 22, 33), bold=False,
font=LAB):
"""x is the LEFT edge; CI View stores centre + half extents."""
hw = half_w(value, size, font)
hh = round(size * 0.62 / 2.0 + 2.0, 4)
self.parts.append(
' <text type="componentData">\n'
' <htmlId>%d</htmlId>\n'
' <value>%s</value>\n'
' <font type="font" name="%s"%s size="%d" underline="false" '
'strikethrough="false"/>\n'
' <fillPaint ref="%d"/>\n'
' <stroke type="stroke" width="1.0"/>\n'
' <keepOriginalSize>true</keepOriginalSize>\n'
' <x>%s</x>\n <y>%s</y>\n'
' <top>%s</top>\n <bottom>%s</bottom>\n'
' <left>%s</left>\n <right>%s</right>\n'
' <rotation>0.0</rotation>\n <shear>0.0</shear>\n'
' </text>'
% (self.hid(), esc(value), font, ' bold="true"' if bold else '',
size, self.paint(colour), round(x + hw, 4), float(y),
hh, hh, hw, hw))
def action(self, item, iid, filter_ref):
"""The actionConnectTo block, byte-for-byte as CI View saved it."""
return ('<action type="actionConnectTo">\n'
' <property type="property" name="number.value"/>\n'
' <filter ref="%d"/>\n'
' <connection type="connection">\n'
' <direction>1</direction>\n'
' <itemName>%s</itemName>\n'
' <itemId>%s</itemId>\n'
' </connection>\n'
' </action>' % (filter_ref, item, iid))
def number(self, x, y, item, iid, mask=None, size=14,
colour=(14, 22, 33)):
"""A bound number. mask=None -> no <format> (the control).
Element order copies what CI View wrote when the FIT-201 INLET value
was formatted by hand: <htmlId>, <format>, <font>, ... , <data>.
A masked number carries no <value>.
"""
rid = self.ref(("data", item),
'<sharedObject type="data">\n %s\n '
'</sharedObject>' % self.action(item, iid, self.filt("0.0")))
if mask:
head = ' <format>%s</format>\n' % mask
sizer = mask
else:
head = ' <value>0</value>\n'
sizer = "0000000"
hw = half_w(sizer, size, VAL)
hh = round(size * 0.62 / 2.0 + 2.0, 4)
self.parts.append(
' <number type="componentData">\n'
' <htmlId>%d</htmlId>\n'
'%s'
' <font type="font" name="%s" size="%d" underline="false" '
'strikethrough="false"/>\n'
' <fillPaint ref="%d"/>\n'
' <stroke type="stroke" width="1.0"/>\n'
' <keepOriginalSize>true</keepOriginalSize>\n'
' <x>%s</x>\n <y>%s</y>\n'
' <top>%s</top>\n <bottom>%s</bottom>\n'
' <left>%s</left>\n <right>%s</right>\n'
' <rotation>0.0</rotation>\n <shear>0.0</shear>\n'
' <data ref="%d"/>\n'
' </number>'
% (self.hid(), head, VAL, size, self.paint(colour),
round(x + hw, 4), float(y), hh, hh, hw, hw, rid))
# --- serialise ------------------------------------------------------
def xml(self, title):
when = datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S.0 AEST")
g = "\n".join(' <intRef id="%d">\n %s\n </intRef>'
% (rid, x) for rid, x in self.refs)
vis = "".join(
' <visibilityGroup type="componentData">\n'
' <htmlId>%d</htmlId>\n <name>%s</name>\n'
' <description>%s</description>\n'
' <minimumZoomEnabled>true</minimumZoomEnabled>\n'
' <minimumZoomFactor>%s</minimumZoomFactor>\n'
' </visibilityGroup>\n'
% (h, n, d, z) for h, n, d, z in (
(2, "Overview", "Always shown", "10.0"),
(3, "Rough", "Shown when viewing a large area", "25.0"),
(4, "Standard", "Shown when using the default view setting", "100.0"),
(5, "Detailed", "Shown when zoomed in", "200.0"),
(6, "Fine", "Shown when zoomed in closely", "400.0"),
))
return (
'<?xml version="1.0" encoding="utf-8" ?>\n'
'<visualization protocolVersion="1.3.0.0">\n'
' <globalSection>\n%s\n </globalSection>\n'
' <coreObjectDefinition type="displayDefinition">\n'
' <version type="version" value="1.3.0.0"/>\n'
' <width>%d</width>\n <height>%d</height>\n'
' <referenceCheck>5</referenceCheck>\n'
' <defaultBgColor type="colorSet" r="255" g="255" b="255"/>\n'
' <defaultFgColor type="colorSet" r="14" g="22" b="33"/>\n'
' <defaultFont type="font" name="Segoe UI" size="14" '
'underline="false" strikethrough="false"/>\n'
' <defaultStroke type="stroke" width="1.0"/>\n'
' <grid type="grid" gridVisible="true" snappingActive="true" '
'verticalSnapInterval="4" horizontalSnapInterval="4" onTop="false">\n'
' <color type="colorSet" r="0" g="0" b="0"/>\n'
' </grid>\n'
' <revisionHistory type="revisionHistory">\n'
' <revision type="revision" who="ADMIN" when="%s" '
'what="%s" where="PXiSEDev"/>\n'
' </revisionHistory>\n'
' <blinkDelay>700</blinkDelay>\n'
' <blinkEnabled>true</blinkEnabled>\n'
' <mousePassThrough>false</mousePassThrough>\n'
'%s'
' <visualizationLayer type="componentData">\n'
' <htmlId>1</htmlId>\n <name>Layer1</name>\n'
' </visualizationLayer>\n'
' <componentCountHint>%d</componentCountHint>\n'
'%s\n'
' </coreObjectDefinition>\n'
'</visualization>\n'
% (g, W, H, when, esc(title), vis,
len(self.parts) + 6, "\n".join(self.parts)))
# ------------------------------------------------------------------ build
def build():
d = Doc()
rows = items()
grey = (70, 84, 104)
d.text(40, 30, "WRPS TAG TEST 2 - format masks, every project item",
size=20, bold=True)
d.text(40, 56, "left number = PLAIN, no <format> (this was live last time, "
"so it must stay live) | right number = the same binding "
"MASKED with the mask printed beside it | masked blank "
"while plain is live means that mask is wrong",
size=12, colour=grey)
for k, name, rep, iid, measured in rows:
col, row = divmod(k, ROWS_PER_COL)
x = COL_X[col]
y = TOP + row * ROW_PITCH
short = name[len("AID.WRPS."):]
mask = pf.mask(name)
d.text(x, y, "%02d" % (k + 1), size=13, colour=grey, font=VAL)
d.text(x + W_IDX, y, short, size=13, font=VAL)
d.text(x + W_IDX + 210, y, "%s%s" % (rep[0], "" if measured else " *"),
size=11, colour=grey, font=VAL)
nx = x + W_IDX + 250
d.number(nx, y, name, iid, mask=None)
d.text(nx + 120, y, mask.rjust(5), size=11, colour=grey, font=VAL)
d.number(nx + 190, y, name, iid, mask=mask)
unmeasured = [r for r in rows if not r[4]]
d.text(40, 1050,
"* itemId NOT measured - placeholder. Save this display in CI View "
"and harvest it to measure all %d. "
"%d items, %d bindings, %d distinct masks."
% (len(rows), len(rows), len(rows) * 2, len(set(pf.MASKS.values()))),
size=12, colour=grey)
OUT.parent.mkdir(exist_ok=True)
OUT.write_text(d.xml("Generated by build_tagtest.py"), encoding="utf-8")
print("wrote %s (%d items, %d numbers, %d shared objects)"
% (OUT, len(rows), len(rows) * 2, len(d.refs)))
if unmeasured:
print(" %d of %d ids are PLACEHOLDERS and will read 0: %s"
% (len(unmeasured), len(rows),
", ".join(r[1].replace("AID.WRPS.", "") for r in unmeasured[:6])
+ (" ..." if len(unmeasured) > 6 else "")))
print(" That is expected before a harvest - this display exists to "
"measure them.\n"
" Deploy it, open it in CI View, link one value, save, then:\n"
" python build_display.py --harvest <the saved file>")
else:
print(" all %d ids measured" % len(rows))
if __name__ == "__main__":
build()

View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Overlap audit for the generated displays.
python check_layout.py [display.xml ...]
Reads the built XML and reports text that collides with other text, or
that escapes its panel. Written after a units relabel pushed the wet
well caption into the top ladder label and nobody noticed until the
screens were on a monitor - the generator places components by
arithmetic, so a collision is arithmetic too, and worth catching here
rather than in CI View.
Text width is estimated, not measured: the value font is Consolas, which
is monospaced at ~0.55 em, and the label font is close enough at this
size. So the check is approximate by construction - it is a net for
gross collisions, not a typesetter.
Some components no longer HAVE one position. A level mark bound to its
setpoint through `<kind>.y` sits wherever the operator last typed, so
checking it where the generator happened to draw it checks the one
arrangement that is guaranteed not to be the problem. Those are swept
across their whole input range instead - see `sweep`.
"""
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
CHAR_W = 0.55 # em, Consolas advance width
PAD = 2.0 # px of slack before two boxes count as colliding
def components(xml):
"""(kind, x1, y1, x2, y2, value, size) for everything positioned."""
out = []
for kind, body in re.findall(
r'<(rectangle|text|number|ellipse) type="componentData">(.*?)</\1>',
xml, re.S):
def num(tag):
m = re.search(r"<%s>(-?[\d.]+)</%s>" % (tag, tag), body)
return float(m.group(1)) if m else None
cx, cy = num("x"), num("y")
top, bot, left, right = num("top"), num("bottom"), num("left"), num("right")
if None in (cx, cy, top, bot, left, right):
continue
val = re.search(r"<value>(.*?)</value>", body)
size = re.search(r'size="(\d+)"', body)
out.append((kind, cx - left, cy - top, cx + right, cy + bot,
val.group(1) if val else "", int(size.group(1)) if size else 0,
"<data ref=" in body, round(cx, 1), round(cy, 1)))
return out
def stacked(a, b):
"""True if two texts are alternative states of one thing.
A state stack - OFF / IDLE / PUMPING - is deliberately drawn at ONE
position with one member visible at a time. That is not a collision,
and reporting it as one buries the real ones. The signature is:
both carry a visibility binding, and both sit at the same anchor.
"""
return (a[7] and b[7]
and round(a[1], 0) == round(b[1], 0) # same left edge
and round(a[9], 0) == round(b[9], 0)) # same centre line
def text_box(c):
"""A text component's *drawn* extent, which is not its stored box.
The stored left/right on a text component is its anchor box, not the
glyph run - a right-anchored label stores a wide box and draws inside
the right edge of it. Estimate the run instead.
"""
kind, x1, y1, x2, y2, val, size = c[:7]
w = len(val) * size * CHAR_W
# right-anchored labels store the anchor at their right edge
if x2 - x1 > w * 1.5:
x1 = x2 - w
else:
x2 = x1 + w
return x1, y1, x2, y2
def overlaps(a, b):
ax1, ay1, ax2, ay2 = text_box(a)
bx1, by1, bx2, by2 = text_box(b)
return not (ax2 - PAD <= bx1 or bx2 - PAD <= ax1
or ay2 - PAD <= by1 or by2 - PAD <= ay1)
def line_boxes(xml):
"""Process lines as thin boxes - the obstacles text must not sit on.
THE GAP THIS CLOSES. The first version of this checker compared text
against text only, so it passed a screen whose top row of values was
sitting on the inlet pipe and whose data column ran across the
discharge manifold. It measured what was easy, not what was wrong.
"""
out = []
for body in re.findall(r'<line type="componentData">(.*?)</line>', xml, re.S):
pts = [float(v) for v in re.findall(r"<(?:x|y)>(-?[\d.]+)</(?:x|y)>", body)]
if len(pts) < 2:
continue
cx, cy = pts[0], pts[1]
def num(tag):
m = re.search(r"<%s>(-?[\d.]+)</%s>" % (tag, tag), body)
return float(m.group(1)) if m else 0.0
l, r_, t, b = num("left"), num("right"), num("top"), num("bottom")
out.append(("line", cx - l, cy - t, cx + r_, cy + b, "", 0, False, 0, 0))
return out
def shared_data(xml):
"""globalSection data objects by id - where a component's actions live."""
return dict(re.findall(
r'<intRef id="(\d+)"><sharedObject type="data">(.*?)</sharedObject></intRef>',
xml, re.S))
def movers(xml):
"""Components whose y is bound to an item, with the law that moves them.
Yields (component, y_at_input_0, y_at_input_end, input_end, item). The
component tuple is the one `components()` produces, so it drops straight
into the same overlap test.
"""
shared = shared_data(xml)
out = []
for kind, body in re.findall(
r'<(rectangle|text|number) type="componentData">(.*?)</\1>', xml, re.S):
ref = re.search(r'<data ref="(\d+)"/>', body)
if not ref or ref.group(1) not in shared:
continue
m = re.search(r'name="%s\.y"/>.*?<inputValueEnd>([\d.]+)</inputValueEnd>'
r"<outputValueStart>(-?[\d.]+)</outputValueStart>"
r"<outputValueEnd>(-?[\d.]+)</outputValueEnd>.*?"
r"<itemName>([^<]+)</itemName>" % kind,
shared[ref.group(1)], re.S)
if not m:
continue
end, y0, y1 = float(m.group(1)), float(m.group(2)), float(m.group(3))
# a number carries no <value>; its mask is what it will be as wide as
val = re.search(r"<value>(.*?)</value>", body)
fmt = re.search(r"<format>([^<]*)</format>", body)
text = val.group(1) if val else (fmt.group(1) if fmt else "")
for c in components("<%s type=\"componentData\">%s</%s>" % (kind, body, kind)):
out.append((c[:5] + (text,) + c[6:], y0, y1, end, m.group(4)))
return out
def sweep(xml, statics, step=0.5):
"""Drive every moving component across its range against what stays put.
The invariant being enforced is that a mark which can be moved and a
mark which cannot never share a column - because a mark that moves will
eventually be driven to the height of one that does not. Checking the
positions the generator drew proves nothing about that; only the sweep
does.
Moving-against-moving is deliberately NOT reported. Two setpoints set
close together really do put their marks close together, and that is the
screen telling the truth about the thresholds, not a layout fault.
"""
hits = {}
for c, y0, y1, end, item in movers(xml):
cy = (c[2] + c[4]) / 2.0
v = 0.0
while v <= end + 1e-9:
dy = (y0 + (y1 - y0) * v / end) - cy
moved = (c[0], c[1], c[2] + dy, c[3], c[4] + dy) + c[5:]
for s in statics:
if overlaps(moved, s):
key = (item, c[5] or c[0], s[5] or s[0])
lo, hi = hits.get(key, (v, v))
hits[key] = (min(lo, v), max(hi, v))
v += step
return hits
def audit(path):
xml = path.read_text(encoding="utf-8")
comps = components(xml)
texts = [c for c in comps if c[0] == "text" and c[5].strip()]
lines = line_boxes(xml)
hits = []
for i, a in enumerate(texts):
for b in texts[i + 1:]:
if overlaps(a, b) and not stacked(a, b):
hits.append((a[5], b[5]))
for a in texts:
for b in lines:
ax1, ay1, ax2, ay2 = text_box(a)
if not (ax2 <= b[1] or b[3] <= ax1 or ay2 <= b[2] or b[4] <= ay1):
hits.append((a[5], "<process line at %d,%d>" % (b[1], b[2])))
# things that stay put, which is what a moving mark must never reach
moving_at = {(round(c[1], 1), round(c[2], 1)) for c, _, _, _, _ in movers(xml)}
statics = [t for t in texts if (round(t[1], 1), round(t[2], 1)) not in moving_at]
swept = sweep(xml, statics + lines)
n = len(hits) + len(swept)
print("%-28s %3d components, %3d text, %2d moving"
% (path.name, len(comps), len(texts), len(moving_at)), end="")
if n:
print(" -> %d COLLISIONS" % n)
for a, b in hits:
print(" %-40s x %s" % (a[:40], b[:40]))
for (item, a, b), (lo, hi) in sorted(swept.items()):
print(" %-22s %-16s x %-22s at %g-%g%%"
% (item.replace("AID.WRPS.", ""), a[:16], b[:22], lo, hi))
else:
print(" -> clear")
return n
def main():
args = sys.argv[1:]
files = [Path(a) for a in args] if args else sorted((HERE / "out").glob("WRPS_*.xml"))
bad = sum(audit(f) for f in files if f.is_file())
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,104 @@
# The WRPS component kit
The house style for this project's displays. AOG is the **reference** for how a
component is parameterised — it is not used directly, and only the components
this project needs exist.
Applies to this display and every future display in the project. A new
requirement adds a component here; it never adds a second style.
## Tokens
Named like AOG's `AOG_C*` so they map onto CI Server global parameters. A restyle
is an edit to this table and nothing else.
| Token | Value | Use |
|---|---|---|
| `WRPS_CBG` | `#EDF0F4` | display ground |
| `WRPS_CSF` | `#FFFFFF` | panel / surface |
| `WRPS_CSF2` | `#F5F8FB` | recessed surface, inside a value box |
| `WRPS_CBD` | `#C3CCD8` | border — heavier than a desktop design, for distance |
| `WRPS_CBD2` | `#DFE5EC` | hairline, internal dividers |
| `WRPS_CT1` | `#0E1621` | primary ink |
| `WRPS_CT2` | `#465468` | labels, secondary ink |
| `WRPS_CAC` | `#0B7DA8` | **interaction only** — selected, focus, faceplate rule |
| `WRPS_CAC2` | `#12A5D9` | accent bright — focus ring |
| `WRPS_CRUN` / bg | `#0F8A45` / `#E4F6EA` | running / on |
| `WRPS_CWRN` / bg | `#A85B00` / `#FFEFD2` | attention |
| `WRPS_CALM` / border | `#D32029` / `#96131A` | **alarm — always a filled block** |
| `WRPS_CPRC` | `#2B3F52` | pipe and vessel outline |
| `WRPS_CWTR` | `#9FCFE6` | liquid fill |
**Colour policy.** The plant is drawn in ink on white. Cyan means *you can act on
this*; green, amber and red mean *the plant is telling you something*. Nothing
else is coloured, so an abnormal state is the only colour on the screen.
**Abnormal is a filled block, never coloured text.** At distance, red text on
white reads as slightly darker grey; a filled red block with white text does not.
One fault propagates to every place it is relevant — banner, symbol, instrument
bubble, value box, faceplate badge — so it cannot be missed from any part of the
screen.
**Type.** Labels **Segoe UI**, values and tag names **Consolas**. Both ship with
Windows, so nothing depends on a font that might not be installed — but Segoe is
the modern face and Tahoma is what dates a screen. Consolas is monospaced, so
digits keep their column as values update at 100 ms, and using it for tag names
gives the display its technical read.
**Sized for a TV.** Nothing on the display is smaller than ~1.35% of screen
height (about 15 px at 1080p); the wet well level is the largest thing at 4.6%,
because it is the number a room actually watches. Ink is near-black on white and
borders are heavier than a desktop design would use — contrast is what survives a
bright room and a projector.
**Why it does not look like AOG.** What dates AOG is the bevelled 3D artwork and
mid-grey ground, not the grey itself. This kit is flat, square-cornered, hairline
bordered, white on cool neutral.
## Components
Every one takes the AOG-shaped parameter set where it applies: `item`,
`setPointItem`, `tagName`, `format`, `showAlarmIndicator`, `selectable`,
`displayToActivate`.
| Component | Purpose | Key parameters | States |
|---|---|---|---|
| `WRPS_Display` | The 16:9 shell: ground, title bar, body, simulation band | — | — |
| `WRPS_Title` | Display header — name, drawing ref, station states right | `title`, `subtitle` | — |
| `WRPS_Panel` | Every framed region. Panels never nest | `header` | — |
| `WRPS_Value` | **Every** analog value | `item`, `tagName`, `format`, `units`, `size`, `displayToActivate` | normal · large · warn · alarm |
| `WRPS_State` | **Every** discrete status | `item`, `onText`, `offText`, `inverse`, `severity` | off · on · warn · alarm |
| `WRPS_Button` | **Every** command and mode selection | `label`, `item`, `writeValue`, `pressed`, `variant` | normal · pressed · destructive |
| `WRPS_Pump` | Pump symbol, circle with impeller cross | `item`, `tagName`, `displayToActivate` | stopped · running · tripped |
| `WRPS_Valve` | Swing check and manual isolation, from the P&ID legend | `kind` | static |
| `WRPS_Instrument` | ISA bubble, function letters over loop number | `letters`, `loop`, `item` | static · with value |
| `WRPS_Vessel` | Wet well: outline, liquid fill, setpoint ladder at true heights | `levelItem`, `setpointItems`, `range` | — |
| `WRPS_Faceplate` | Popup display frame — cyan rule, kit components inside | `title`, `subtitle`, `item` | — |
| `WRPS_AlarmBanner` | Present **only** when something is abnormal; pushes the display down | `alarmList`, `count` | hidden · active · acknowledged |
`WRPS_SimBand` is **not** another component: it is `WRPS_Button` and `WRPS_Value`
inside a fenced region with a cyan rule and a standing label, so the simulation
controls can never be mistaken for plant control.
## Rules
1. **No hand-drawn values or buttons.** If it shows a number it is `WRPS_Value`;
if it is clickable it is `WRPS_Button`. This is what stops displays drifting.
2. **Faceplates are separate displays** opened via `displayToActivate`, matching
how AOG works — not overlay groups on the parent display.
3. **Setpoint edits go through a numeric entry popup**, not an inline field.
4. **State is never colour alone.** `WRPS_State` carries a square marker and its
own text, so it survives a projector or a colour-blind operator.
5. **Simulation controls stay in the band.** They never appear beside plant
controls.
6. **An abnormal condition propagates everywhere it is relevant** — banner, plant
symbol, instrument bubble, value box, faceplate badge. One fault, five places.
7. **Blink is reserved for unacknowledged alarms**, and only on the tag name, not
whole regions. CI Server supports this natively (`blinkEnabled`, `blinkDelay`,
700 ms in the reference screens).
8. **Green is only for genuinely-running equipment.** If normal operation is
colourful, red has competition and stops being an alarm.
## Geometry
16:9. Laid out on the editor's 4 px snap grid; 1920 × 1080 unless told otherwise.

50
04-scada/hmi/item-ids.csv Normal file
View file

@ -0,0 +1,50 @@
item,itemId
AID.WRPS.PU301.AVAILABLE,1.1.291.0.0.1.1.1
AID.WRPS.PU301.RUNNING,1.1.288.0.0.1.1.1
AID.WRPS.PU301.RUN_CMD,1.1.285.0.0.1.1.1
AID.WRPS.PU301.RUN_HOURS,1.1.308.0.0.1.1.7
AID.WRPS.PU301.STATE,1.1.313.0.0.1.1.7
AID.WRPS.PU301.TRIPPED,1.1.297.0.0.1.1.1
AID.WRPS.PU302.AVAILABLE,1.1.292.0.0.1.1.1
AID.WRPS.PU302.RUNNING,1.1.289.0.0.1.1.1
AID.WRPS.PU302.RUN_CMD,1.1.286.0.0.1.1.1
AID.WRPS.PU302.RUN_HOURS,1.1.309.0.0.1.1.7
AID.WRPS.PU302.STATE,1.1.314.0.0.1.1.7
AID.WRPS.PU302.TRIPPED,1.1.298.0.0.1.1.1
AID.WRPS.PU303.AVAILABLE,1.1.293.0.0.1.1.1
AID.WRPS.PU303.RUNNING,1.1.290.0.0.1.1.1
AID.WRPS.PU303.RUN_CMD,1.1.287.0.0.1.1.1
AID.WRPS.PU303.RUN_HOURS,1.1.310.0.0.1.1.7
AID.WRPS.PU303.STATE,1.1.315.0.0.1.1.7
AID.WRPS.PU303.TRIPPED,1.1.299.0.0.1.1.1
AID.WRPS.SIM.INFLOW,1.1.330.0.0.1.1.7
AID.WRPS.SIM.RESET,1.1.332.0.0.1.1.7
AID.WRPS.SIM.SCENARIO,1.1.331.0.0.1.1.7
AID.WRPS.SIM.TIME_SCALE,1.1.333.0.0.1.1.7
AID.WRPS.SP.CMD_PARAM,1.1.321.0.0.1.1.7
AID.WRPS.SP.CMD_WORD,1.1.320.0.0.1.1.7
AID.WRPS.SP.HIGH_ALARM,1.1.327.0.0.1.1.7
AID.WRPS.SP.LEVEL_SP,1.1.322.0.0.1.1.7
AID.WRPS.SP.MIN_SPEED,1.1.328.0.0.1.1.7
AID.WRPS.SP.MODE,1.1.319.0.0.1.1.7
AID.WRPS.SP.SERVICE_HRS,1.1.329.0.0.1.1.7
AID.WRPS.SP.START_DUTY,1.1.323.0.0.1.1.7
AID.WRPS.SP.START_P2,1.1.324.0.0.1.1.7
AID.WRPS.SP.START_P3,1.1.325.0.0.1.1.7
AID.WRPS.SP.STOP_ALL,1.1.326.0.0.1.1.7
AID.WRPS.STN.ALARM_WORD,1.1.317.0.0.1.1.7
AID.WRPS.STN.CMD_ACK,1.1.318.0.0.1.1.7
AID.WRPS.STN.DISCHARGE,1.1.302.0.0.1.1.7
AID.WRPS.STN.DUTY_PUMP,1.1.316.0.0.1.1.7
AID.WRPS.STN.HIGH_LEVEL,1.1.295.0.0.1.1.1
AID.WRPS.STN.INFLOW,1.1.301.0.0.1.1.7
AID.WRPS.STN.IN_AUTO,1.1.294.0.0.1.1.1
AID.WRPS.STN.LEVEL,1.1.300.0.0.1.1.7
AID.WRPS.STN.NET_ACCUM,1.1.307.0.0.1.1.7
AID.WRPS.STN.PUMPS_RUNNING,1.1.303.0.0.1.1.7
AID.WRPS.STN.SPEED,1.1.304.0.0.1.1.7
AID.WRPS.STN.SPILL_ACTIVE,1.1.296.0.0.1.1.1
AID.WRPS.STN.STATE,1.1.312.0.0.1.1.7
AID.WRPS.STN.TIME_TO_LSHH,1.1.306.0.0.1.1.7
AID.WRPS.STN.TIME_TO_SPILL,1.1.305.0.0.1.1.7
AID.WRPS.STN.VOL_TO_SPILL,1.1.311.0.0.1.1.7
1 item itemId
2 AID.WRPS.PU301.AVAILABLE 1.1.291.0.0.1.1.1
3 AID.WRPS.PU301.RUNNING 1.1.288.0.0.1.1.1
4 AID.WRPS.PU301.RUN_CMD 1.1.285.0.0.1.1.1
5 AID.WRPS.PU301.RUN_HOURS 1.1.308.0.0.1.1.7
6 AID.WRPS.PU301.STATE 1.1.313.0.0.1.1.7
7 AID.WRPS.PU301.TRIPPED 1.1.297.0.0.1.1.1
8 AID.WRPS.PU302.AVAILABLE 1.1.292.0.0.1.1.1
9 AID.WRPS.PU302.RUNNING 1.1.289.0.0.1.1.1
10 AID.WRPS.PU302.RUN_CMD 1.1.286.0.0.1.1.1
11 AID.WRPS.PU302.RUN_HOURS 1.1.309.0.0.1.1.7
12 AID.WRPS.PU302.STATE 1.1.314.0.0.1.1.7
13 AID.WRPS.PU302.TRIPPED 1.1.298.0.0.1.1.1
14 AID.WRPS.PU303.AVAILABLE 1.1.293.0.0.1.1.1
15 AID.WRPS.PU303.RUNNING 1.1.290.0.0.1.1.1
16 AID.WRPS.PU303.RUN_CMD 1.1.287.0.0.1.1.1
17 AID.WRPS.PU303.RUN_HOURS 1.1.310.0.0.1.1.7
18 AID.WRPS.PU303.STATE 1.1.315.0.0.1.1.7
19 AID.WRPS.PU303.TRIPPED 1.1.299.0.0.1.1.1
20 AID.WRPS.SIM.INFLOW 1.1.330.0.0.1.1.7
21 AID.WRPS.SIM.RESET 1.1.332.0.0.1.1.7
22 AID.WRPS.SIM.SCENARIO 1.1.331.0.0.1.1.7
23 AID.WRPS.SIM.TIME_SCALE 1.1.333.0.0.1.1.7
24 AID.WRPS.SP.CMD_PARAM 1.1.321.0.0.1.1.7
25 AID.WRPS.SP.CMD_WORD 1.1.320.0.0.1.1.7
26 AID.WRPS.SP.HIGH_ALARM 1.1.327.0.0.1.1.7
27 AID.WRPS.SP.LEVEL_SP 1.1.322.0.0.1.1.7
28 AID.WRPS.SP.MIN_SPEED 1.1.328.0.0.1.1.7
29 AID.WRPS.SP.MODE 1.1.319.0.0.1.1.7
30 AID.WRPS.SP.SERVICE_HRS 1.1.329.0.0.1.1.7
31 AID.WRPS.SP.START_DUTY 1.1.323.0.0.1.1.7
32 AID.WRPS.SP.START_P2 1.1.324.0.0.1.1.7
33 AID.WRPS.SP.START_P3 1.1.325.0.0.1.1.7
34 AID.WRPS.SP.STOP_ALL 1.1.326.0.0.1.1.7
35 AID.WRPS.STN.ALARM_WORD 1.1.317.0.0.1.1.7
36 AID.WRPS.STN.CMD_ACK 1.1.318.0.0.1.1.7
37 AID.WRPS.STN.DISCHARGE 1.1.302.0.0.1.1.7
38 AID.WRPS.STN.DUTY_PUMP 1.1.316.0.0.1.1.7
39 AID.WRPS.STN.HIGH_LEVEL 1.1.295.0.0.1.1.1
40 AID.WRPS.STN.INFLOW 1.1.301.0.0.1.1.7
41 AID.WRPS.STN.IN_AUTO 1.1.294.0.0.1.1.1
42 AID.WRPS.STN.LEVEL 1.1.300.0.0.1.1.7
43 AID.WRPS.STN.NET_ACCUM 1.1.307.0.0.1.1.7
44 AID.WRPS.STN.PUMPS_RUNNING 1.1.303.0.0.1.1.7
45 AID.WRPS.STN.SPEED 1.1.304.0.0.1.1.7
46 AID.WRPS.STN.SPILL_ACTIVE 1.1.296.0.0.1.1.1
47 AID.WRPS.STN.STATE 1.1.312.0.0.1.1.7
48 AID.WRPS.STN.TIME_TO_LSHH 1.1.306.0.0.1.1.7
49 AID.WRPS.STN.TIME_TO_SPILL 1.1.305.0.0.1.1.7
50 AID.WRPS.STN.VOL_TO_SPILL 1.1.311.0.0.1.1.7

View file

@ -0,0 +1,8 @@
{
"harvested_from": "<scratch copy of a saved WRPS_TagTest.xml>",
"harvested_at": "2026-08-14T16:50:54",
"item_file": "wrps_item_df.qli",
"item_file_sha": "326d3bc8e525cebf0d3eba0df73b10b487f11b43b9459e2438e370f605a15bd1",
"measured": 49,
"note": "Every .qli item import renumbers every item. Validate the displays in CI Server's Editor Module, then re-harvest - otherwise the screens go dead."
}

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Display format mask for every project item. Shared by both generators.
CI Server's `<format>` is a **digit mask**, not a Java DecimalFormat pattern:
<format>99.99</format> -> two integer digits, always two decimals
That is what CI View wrote when the FIT-201 INLET value was set to 99.99 by
hand, and it matches the `VALUE_FORMAT` masks in the item export
(`"99999"` on our items, `"99.99"` on the reference analog IO). `9` is a
digit position; the count of positions after the point is the number of
decimals always shown.
The first build emitted `<format>0</format>` and `<format>0.0</format>` -
DecimalFormat patterns, which read as masks **one digit wide**. Every value
wider than that had nowhere to render. That, not the missing `<value>`, is
why the displays read 0: `WRPS_TagTest` carried no `<format>` at all and all
98 bindings were live.
So a mask must be **wide enough for the point's full range**, or the value
may not fit. The masks below are sized from the register map's engineering
ranges, and points of the same kind share a mask so the screens stay
consistent:
level & speed in %, and their setpoints 999.9
flows (m3/h, three pumps reach ~1300) 9999.9
durations & hour counters (s / h) 99999
volume (m3) 9999
alarm bitmask (unsigned 16-bit) 99999
enums, counts, modes, booleans 9
Decimals follow the point's real resolution, not a fixed two: level in % of
the 6.000 m spill weir resolves to 0.017% because the register is mm, so one
decimal is honest and two would not be; a flow in m3/h resolves to 0.1.
Integer registers get no decimal at all - a decimal place there would be
invented precision.
**These masks must agree with `format_mask` in `04-scada/modbus_points/scada-points.csv`,**
which is what the item export carries into CI Server as `VALUE_FORMAT`. The
units and their gains are defined once, in `gen_scada_points.py`.
"""
# item short name (after "AID.WRPS.") -> mask
MASKS = {
# --- digital status, one digit ------------------------------------
"PU301.RUN_CMD": "9",
"PU302.RUN_CMD": "9",
"PU303.RUN_CMD": "9",
"PU301.RUNNING": "9",
"PU302.RUNNING": "9",
"PU303.RUNNING": "9",
"PU301.AVAILABLE": "9",
"PU302.AVAILABLE": "9",
"PU303.AVAILABLE": "9",
"STN.IN_AUTO": "9",
"STN.HIGH_LEVEL": "9",
"STN.SPILL_ACTIVE": "9",
"PU301.TRIPPED": "9",
"PU302.TRIPPED": "9",
"PU303.TRIPPED": "9",
# --- analog measurements ------------------------------------------
"STN.LEVEL": "999.9", # %, 100% = the 6.000 m spill weir
"STN.INFLOW": "9999.9", # m3/h, raw (L/s x10) x 0.36
"STN.DISCHARGE": "9999.9", # m3/h, three pumps reach ~1300
"STN.PUMPS_RUNNING": "9", # count 0-3
"STN.SPEED": "999.9", # %, 100% = 50 Hz
"STN.TIME_TO_SPILL": "99999", # s, 32767 = drawing down
"STN.TIME_TO_LSHH": "99999", # s, 32767 = drawing down
"STN.NET_ACCUM": "9999.9", # m3/h, SIGNED
"PU301.RUN_HOURS": "99999", # h
"PU302.RUN_HOURS": "99999",
"PU303.RUN_HOURS": "99999",
"STN.VOL_TO_SPILL": "9999", # m3
# --- enums and words ----------------------------------------------
"STN.STATE": "9", # enum 3.1
"PU301.STATE": "9", # enum 3.2
"PU302.STATE": "9",
"PU303.STATE": "9",
"STN.DUTY_PUMP": "9", # 0 = none, 1-3
"STN.ALARM_WORD": "99999", # unsigned 16-bit bitmask
"STN.CMD_ACK": "99",
# --- setpoints ------------------------------------------------------
"SP.MODE": "9", # 1 = auto, 2 = off
"SP.CMD_WORD": "99",
"SP.CMD_PARAM": "9", # pump number
"SP.LEVEL_SP": "999.9", # %, entered in % of spill weir
"SP.START_DUTY": "999.9", # %
"SP.START_P2": "999.9", # %
"SP.START_P3": "999.9", # %
"SP.STOP_ALL": "999.9", # %
"SP.HIGH_ALARM": "999.9", # %
"SP.MIN_SPEED": "999.9", # %, 100% = 50 Hz
"SP.SERVICE_HRS": "99999", # h
# --- simulation ------------------------------------------------------
"SIM.INFLOW": "9999.9", # m3/h
"SIM.SCENARIO": "9", # 0-3
"SIM.RESET": "9", # write 1, self-clearing
"SIM.TIME_SCALE": "999", # 1-120
}
PREFIX = "AID.WRPS."
def mask(item):
"""Mask for an item, by full or short name. Unknown -> widest integer."""
short = item[len(PREFIX):] if item.startswith(PREFIX) else item
return MASKS.get(short, "99999")
def decimals(item):
m = mask(item)
return len(m.split(".")[1]) if "." in m else 0
def check(names):
"""Fail loudly if an item has no mask - a new point must be given one."""
missing = [n for n in names
if (n[len(PREFIX):] if n.startswith(PREFIX) else n) not in MASKS]
if missing:
import sys
sys.exit("no format mask for: %s\n add it to point_format.py"
% ", ".join(missing))

View file

@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""Render built CI Server display XML back to a browser preview.
python render_preview.py -> out/preview.html (all displays)
python render_preview.py WRPS_FP_Pump -> just that one
WHY THIS EXISTS
---------------
Every preview up to now was a drawing of what I *meant* to build, made
alongside the generator rather than from it. That is the same mistake as
a test that checks the intent instead of the artefact, and it has already
cost this project a round: a script that failed before writing its file,
while a checker with hard-coded coordinates cheerfully reported the file
was fine.
This reads the XML that was actually generated and draws it. If a
component is in the file it appears here; if it is not, it does not.
It also resolves what a static picture normally cannot:
live values read over Modbus, formatted through the point's own
gain and display mask, so a number shows what the
operator would see rather than "9999.9"
visibility a component bound to a Boolean item is drawn only if
that item is true; one bound to a display parameter is
resolved through the genericFunctions equality that
writes the parameter. Without this, every state stack
renders at once and the preview is a smear.
Approximate by construction: browser font metrics are not Java's, so
text width is estimated. It is a faithful reading of the geometry, not
a pixel-exact simulation of CI View.
"""
import csv
import html
import importlib.util
import math
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT = HERE / "out"
POINTS = HERE.parent / "modbus_points" / "scada-points.csv"
QLI_GEN = HERE.parent / "modbus_points" / "gen_ciserver_qli.py"
PLC_HOST, PLC_PORT, PLC_UNIT = "192.168.153.192", 502, 1
# ------------------------------------------------------------------ data
def leaf_to_iec():
spec = importlib.util.spec_from_file_location("q", QLI_GEN)
q = importlib.util.module_from_spec(spec)
spec.loader.exec_module(q)
return {"AID.WRPS.%s.%s" % v: k for k, v in q.LEAF.items()}
def live_values():
"""{itemName: (raw, formatted_string)} - or {} if the PLC is unreachable."""
rows = {r["iec_address"]: r for r in csv.DictReader(POINTS.open(newline="", encoding="utf-8"))}
names = leaf_to_iec()
try:
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient(PLC_HOST, port=PLC_PORT, timeout=3)
if not c.connect():
return {}
hr = c.read_holding_registers(address=0, count=21, device_id=PLC_UNIT).registers
sp = c.read_holding_registers(address=1024, count=11, device_id=PLC_UNIT).registers
sim = c.read_holding_registers(address=1044, count=4, device_id=PLC_UNIT).registers
co = [int(b) for b in c.read_coils(address=0, count=15, device_id=PLC_UNIT).bits[:15]]
c.close()
except Exception:
return {}
raw = {"%%QW%d" % i: v for i, v in enumerate(hr)}
raw.update({"%%MW%d" % i: v for i, v in enumerate(sp)})
raw.update({"%%MW%d" % (20 + i): v for i, v in enumerate(sim)})
raw.update({"%%QX0.%d" % i: co[i] for i in range(8)})
raw.update({"%%QX1.%d" % i: co[8 + i] for i in range(7)})
out = {}
for name, iec in names.items():
if iec not in raw or iec not in rows:
continue
r, v = rows[iec], raw[iec]
if r["data_type"] == "Boolean":
out[name] = (v, str(v))
continue
if v > 32767 and iec != "%QW17":
v -= 65536
dec = len(r["format_mask"].split(".")[1]) if "." in r["format_mask"] else 0
out[name] = (v, "%.*f" % (dec, v * float(r["eng_gain"])))
return out
# ------------------------------------------------------------------ parse
def num(body, tag, default=None):
m = re.search(r"<%s>(-?[\d.]+)</%s>" % (tag, tag), body)
return float(m.group(1)) if m else default
def attr(tag_text, name, default=None):
m = re.search(r'%s="(-?[\d.]+)"' % name, tag_text)
return float(m.group(1)) if m else default
class Doc:
def __init__(self, path):
self.xml = path.read_text(encoding="utf-8")
self.name = path.stem
self.w = int(num(self.xml, "width", 1920) or 1920)
self.h = int(num(self.xml, "height", 1080) or 1080)
self.paints, self.datas = {}, {}
for rid, body in re.findall(r'<intRef id="(\d+)">(.*?)</intRef>', self.xml, re.S):
p = re.search(r'colorSet r="(\d+)" g="(\d+)" b="(\d+)"', body)
if p:
self.paints[rid] = "rgb(%s,%s,%s)" % p.groups()
else:
self.datas[rid] = body
# genericFunctions: parameter name -> (item, constant)
self.funcs = {}
for body in re.findall(r"<genericFunctions type=\"componentData\">(.*?)</genericFunctions>",
self.xml, re.S):
pname = re.search(r"<name>([^<]+)</name>", body)
const = num(body, "numberArgument2")
item = re.search(r"<itemName>([^<]+)</itemName>", body)
if pname and const is not None and item:
self.funcs[pname.group(1)] = (item.group(1), const)
def paint(self, body, tag):
m = re.search(r'<%s ref="(\d+)"/>' % tag, body)
if m:
return self.paints.get(m.group(1), "none")
m = re.search(r'<%s type="paint"><paint type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"' % tag, body)
return "rgb(%s,%s,%s)" % m.groups() if m else "none"
def bound_item(self, body):
m = re.search(r"<itemName>([^<]+)</itemName>", body)
if m:
return m.group(1)
m = re.search(r'<data ref="(\d+)"/>', body)
if m:
d = self.datas.get(m.group(1), "")
mm = re.search(r"<itemName>([^<]+)</itemName>", d)
if mm:
return mm.group(1)
return None
def visible(self, body, live):
"""Resolve a .visible binding. Unbound components are always drawn."""
refs = re.findall(r'<data ref="(\d+)"/>', body)
blocks = [self.datas.get(r, "") for r in refs] + [body]
for blk in blocks:
if ".visible" not in blk:
continue
p = re.search(r"<parameter>([^<]+)</parameter>", blk)
if p:
item, const = self.funcs.get(p.group(1), (None, None))
if item is None or item not in live:
return None
return abs(live[item][0] - const) < 0.001
i = re.search(r"<itemName>([^<]+)</itemName>", blk)
if i:
if i.group(1) not in live:
return None
return bool(live[i.group(1)][0])
return True
def render(doc, live):
"""SVG for one display, components in document order."""
svg = ['<svg viewBox="0 0 %d %d" width="%d" height="%d" xmlns="http://www.w3.org/2000/svg">'
% (doc.w, doc.h, doc.w, doc.h),
'<rect width="%d" height="%d" fill="rgb(237,240,244)"/>' % (doc.w, doc.h)]
unknown = 0
pattern = (r'<(rectangle|ellipse|line|text|number|numberField|dataBar|button|microTrend)'
r'( type="componentData"[^>]*)>(.*?)</\1>')
for kind, head, body in re.findall(pattern, doc.xml, re.S):
vis = doc.visible(body, live)
if vis is False:
continue
if vis is None:
unknown += 1
op = ' opacity="0.35"' if vis is None else ""
# geometry: child elements, or attributes on the tag (numberField/dataBar/button)
cx = num(body, "x", attr(head, "x"))
cy = num(body, "y", attr(head, "y"))
if cx is None or cy is None:
continue
t = num(body, "top", attr(head, "top", 0)) or 0
b = num(body, "bottom", attr(head, "bottom", 0)) or 0
l = num(body, "left", attr(head, "left", 0)) or 0
r = num(body, "right", attr(head, "right", 0)) or 0
x, y, w, h = cx - l, cy - t, l + r, t + b
if kind == "rectangle":
svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>'
% (x, y, w, h, doc.paint(body, "fillPaint"), doc.paint(body, "strokePaint"),
num(body, "width", 1) or attr(body, "width", 1) or 1, op))
elif kind == "ellipse":
svg.append('<ellipse cx="%.1f" cy="%.1f" rx="%.1f" ry="%.1f" fill="%s" stroke="%s" stroke-width="2"%s/>'
% (cx, cy, l, t, doc.paint(body, "fillPaint"), doc.paint(body, "strokePaint"), op))
elif kind == "line":
pts = re.findall(r"<point><x>(-?[\d.]+)</x><y>(-?[\d.]+)</y></point>", body)
if len(pts) >= 2:
svg.append('<polyline points="%s" fill="none" stroke="%s" stroke-width="%.1f"%s/>'
% (" ".join("%s,%s" % p for p in pts), doc.paint(body, "strokePaint"),
float(re.search(r'stroke" width="([\d.]+)"', body).group(1)) if re.search(r'stroke" width="([\d.]+)"', body) else 2, op))
else:
svg.append('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="2"%s/>'
% (x, y, x + w, y + h, doc.paint(body, "strokePaint"), op))
elif kind == "text":
v = re.search(r"<value>(.*?)</value>", body, re.S)
f = re.search(r'name="([^"]+)" bold="(\w+)"[^>]*size="(\d+)"', body)
fam, bold, size = (f.group(1), f.group(2), int(f.group(3))) if f else ("Segoe UI", "false", 13)
svg.append('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="%s" font-size="%d" '
'font-weight="%s" fill="%s"%s>%s</text>'
% (cx, cy + size * 0.35, fam, size, "700" if bold == "true" else "400",
doc.paint(body, "fillPaint"), op, html.escape(v.group(1) if v else "")))
elif kind == "number":
item = doc.bound_item(body)
mask = re.search(r"<format>([^<]*)</format>", body)
shown = live.get(item, (None, mask.group(1) if mask else "?"))[1]
f = re.search(r'size="(\d+)"', body)
size = int(f.group(1)) if f else 20
svg.append('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="Consolas,monospace" '
'font-size="%d" font-weight="700" fill="%s"%s>%s</text>'
% (cx, cy + size * 0.35, size, doc.paint(body, "fillPaint"), op,
html.escape(str(shown))))
elif kind == "numberField":
item = doc.bound_item(body)
shown = live.get(item, (None, ""))[1]
svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#fff" stroke="rgb(11,125,168)"%s/>'
% (x, y, w, h, op))
svg.append('<text x="%.1f" y="%.1f" font-family="Consolas,monospace" font-size="14" fill="#0e1621"%s>%s</text>'
% (x + 8, cy + 5, op, html.escape(str(shown))))
elif kind == "dataBar":
item = doc.bound_item(body)
lo = num(body, "lowLimit", 0) or 0
hi = num(body, "highLimit", 100) or 100
val = live.get(item, (lo, ""))[0]
try:
pct = max(0.0, min(1.0, (float(live[item][1]) - lo) / (hi - lo)))
except Exception:
pct = 0.0
svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"%s/>'
% (x, y, w, h, doc.paint(body, "background"), op))
svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"%s/>'
% (x, y + h * (1 - pct), w, h * pct, doc.paint(body, "foreground"), op))
elif kind == "microTrend":
# the trace itself is CI Server's; what the preview has to show is
# the FOOTPRINT, so a trend that lands on a pipe is visible here
# rather than after deployment
item = doc.bound_item(body)
hi = num(body, "value", 100) or 100 # scaleMax[0]
svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="rgb(220,220,220)" '
'stroke="rgb(195,204,216)"%s/>' % (x, y, w, h, op))
try:
pct = max(0.0, min(1.0, float(live[item][1]) / hi))
except Exception:
pct = 0.5
svg.append('<polyline points="%s" fill="none" stroke="rgb(11,125,168)" stroke-width="1.5"%s/>'
% (" ".join("%.1f,%.1f" % (x + w * k / 24.0,
y + h * (1 - pct * (0.55 + 0.45 * math.sin(k / 2.4))))
for k in range(25)), op))
svg.append('<text x="%.1f" y="%.1f" font-family="Consolas,monospace" font-size="9" '
'fill="rgb(70,84,104)"%s>%s</text>'
% (x + 4, y + 11, op, html.escape((item or "").replace("AID.WRPS.", ""))))
elif kind == "button":
lab = re.search(r"<label>([^<]*)</label>", body)
fg = re.search(r'foregroundColor type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"', body)
bg = re.search(r'backgroundColor type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"', body)
svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" stroke="rgb(195,204,216)"%s/>'
% (x, y, w, h, "rgb(%s,%s,%s)" % bg.groups() if bg else "#fff", op))
svg.append('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="Segoe UI" font-size="15" '
'font-weight="700" fill="%s"%s>%s</text>'
% (cx, cy + 5, "rgb(%s,%s,%s)" % fg.groups() if fg else "#0e1621", op,
html.escape(lab.group(1) if lab else "")))
svg.append("</svg>")
return "\n".join(svg), unknown
def main():
want = sys.argv[1:]
files = sorted(OUT.glob("WRPS_*.xml"))
if want:
files = [f for f in files if f.stem in want]
if not files:
sys.exit("nothing to render in %s" % OUT)
live = live_values()
parts = ['<!doctype html><meta charset="utf-8"><title>WRPS displays — rendered from the built XML</title>',
'<style>body{margin:0;background:#eef1f5;font-family:"Segoe UI",sans-serif;color:#0e1621}'
'.w{padding:20px}h1{font-size:15px;margin:0 0 2px}p.s{font-size:12px;color:#465468;margin:0 0 16px}'
'h2{font-size:13px;margin:22px 0 6px}.box{background:#fff;border:1px solid #c3ccd8;overflow:auto;display:inline-block}'
'</style><div class="w">',
"<h1>Rendered from the generated XML in <code>out/</code></h1>",
'<p class="s">%s. Components are drawn only if the file contains them; '
"values and visibility resolved against the live PLC.</p>"
% ("Live values read from the PLC" if live else
"PLC unreachable - numbers show their format mask, state-bound shapes are dimmed")]
for f in files:
doc = Doc(f)
svg, unknown = render(doc, live)
note = " <span style='color:#b45309'>%d component(s) whose visibility could not be resolved, shown dimmed</span>" % unknown if unknown else ""
parts.append("<h2>%s &nbsp;<span style='font-weight:400;color:#465468'>%d x %d</span>%s</h2>"
% (doc.name, doc.w, doc.h, note))
parts.append('<div class="box">%s</div>' % svg)
print("rendered %-22s %d x %d" % (doc.name, doc.w, doc.h))
parts.append("</div>")
dest = OUT / "preview.html"
dest.write_text("\n".join(parts), encoding="utf-8")
print("wrote %s" % dest)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,222 @@
# modbus_points — the CI Server tag database
Named for the protocol: CI Server configures other protocols differently, so a
folder called `points` would be ambiguous.
Three kinds of file live here, and only the first two are generated:
| | |
|---|---|
| **Generated** | `scada-points.csv` and the three `wrps_*.qli` |
| **Generators** | `gen_scada_points.py`, `gen_ciserver_qli.py` |
| **Hand-made** | `historian/` — no generator, and it has drifted. See its README. |
Deployed with `dssqld`, not by file copy — see `../QUICKLOAD.md`.
```bash
python gen_scada_points.py # register-map.csv -> scada-points.csv
python gen_ciserver_qli.py # scada-points.csv -> the two .qli files
```
| File | What |
|---|---|
| `scada-points.csv` | The point list, for reading and for configuring by hand |
| `historian/` | Collection groups and 49 item bindings — **hand-made**, see its README |
| `wrps_section_df.qli` | `@SECTION_DF` — the six `AID.WRPS.*` sections |
| `wrps_modbus_point_df.qli` | `@MODBUS_POINT_DF` — 49 Modbus point definitions, station `WRPS_PLC` |
| `wrps_item_df.qli` | `@ITEM_DF` — 49 items in those sections, bound to those points |
**Import in that order.** CI Server derives its hierarchy from the dots in a name,
and a section must exist before an item can be created inside it.
The chain is ST sources → `03-plc/register-map.csv``scada-points.csv``.qli`,
so a register change propagates by re-running the scripts. **Nothing here is
hand-edited**; if CI Server needs a field the generators do not emit, it goes in
the generator.
## Item structure under AID.WRPS
| Section | Points | Contents |
|---|---|---|
| `AID.WRPS.STN` | 16 | Level, inflow, discharge, pumps running, speed, times, volumes, station state, duty pump, alarm word, command ack, in-auto, high level, spill |
| `AID.WRPS.PU301/2/3` | 6 each | Run command, running, available, tripped, pump state, run hours |
| `AID.WRPS.SP` | 11 | Mode, command word/param, level setpoints, high alarm, min speed, service interval |
| `AID.WRPS.SIM` | 4 | Scenario, manual inflow, reset, time scale |
Equipment-oriented rather than address-oriented, so an HMI face for a pump binds
to one folder, and the simulation controls sit apart from anything a real plant
would have.
## NSIDs
The NSID space is shared by sections and items. In your exports items occupy
3166 and sections 1170, so 171 is the first free id:
| | NSIDs |
|---|---|
| Sections `STN`, `PU301`, `PU302`, `PU303`, `SP`, `SIM` | 171176 |
| The 49 items | 177225 |
`AID` (169) and `AID.WRPS` (170) **already exist**, so the section file emits only
their six children, parented to 170. Pass `--include-root` to emit `AID` and
`AID.WRPS` too, for a system that lacks them. Override with `--nsid-base`,
`--parent-nsid`, or pin one with `--section-nsid STN=41`.
## Address base — settled
**`--address-base 1` is correct.** CI Server's `IO_ADDRESS` numbering is 1-based:
holding register 0 (`%QW0`, wet well level) is `RO:01`.
Confirmed by import on 2026-08-14, cross-checking CI Server against a direct
pymodbus read. The decisive check was self-consistency between two independent
registers: `STN.LEVEL` 4140 mm and `STN.VOL_TO_SPILL` 223 m3 reconcile through the
plant geometry, (6.000 - 4.140) x 120 = 223.2 m3. An off-by-one mapping cannot
produce that agreement.
## Engineering units — the SCADA side chooses them
The PLC publishes mm, L/s × 10 and Hz × 10, and that does not change: it is the
contract in `03-plc/register-map.csv`. What the operator *reads* is a
presentation choice, made here, as a pure gain on the raw register:
engineering value = raw register × gain (offset is always 0)
| Item | Unit | Gain | Basis |
|---|---|---|---|
| `STN.LEVEL` | % | 1/60 | **100% = the 6.000 m spill weir**, register is mm |
| `STN.INFLOW`, `STN.DISCHARGE`, `STN.NET_ACCUM`, `SIM.INFLOW` | m³/h | 0.36 | L/s × 3.6, register is L/s × 10 |
| `STN.SPEED` | % | 0.2 | 100% = 50 Hz, the drive maximum |
| `SP.LEVEL_SP`, `START_DUTY`, `START_P2/P3`, `STOP_ALL`, `HIGH_ALARM` | % | 1/60 | same scale as `STN.LEVEL`; **entered in %** |
| `SP.MIN_SPEED` | % | 0.2 | same scale as `STN.SPEED`; **entered in %** |
The gains live in one place — `UNITS` in `gen_scada_points.py`. Change a unit
there and it propagates to `scada-points.csv`, to both `.qli` files and to the
HMI masks.
> [!IMPORTANT]
> **One item per I/O address — a register carries exactly one unit.** Publishing
> level in m *and* % as two Modbus points on the same `IO_ADDRESS` was tried on
> 2026-08-14 and CI Server R1.03 refused the item import:
>
> ```
> EQP-E-DUP_ITEM, I/O address of item already defined
> DSSP-E-INSREC, Failed to insert a record in the dataset ITEM_DF
> ```
>
> The point definitions are not the constraint; the *item* is. So a second unit
> for the same measurement needs a second **PLC** register publishing it, not a
> second view of one register — a register-map change, not a SCADA change.
> Level and speed are therefore published in % alone.
**Setpoints are written in %.** A setpoint entered as 70.0% arrives at the PLC as
4200 mm. One decimal of % is a 6 mm step, so not every mm value is reachable —
66.7% writes 4002 mm, not 4000. That is the intended trade for a single scale
shared by the level reading and its setpoints.
### Ranges
Analog points use `Linear` with a **full-scale** mapping: raw 32768..32767 to
that range × the gain. That is exact and invents no plant range — which is why
the offset must stay zero on both ends. It does make some `PHYS` values large
(`STN_SPEED` spans ±6553.6%); that is the arithmetic of a full-scale map, not
a plant range. Display ranges, trend limits and alarm limits are deliberately
left at defaults for you to set per item — the generator does not guess at them.
`HAS_SIGN` is 1 for signed registers and **0 for `STN_ALARM_WORD`**, which is
unsigned; see below.
## The point list
`gen_scada_points.py` reads `03-plc/register-map.csv` (the PLC-side list, itself
generated from the ST sources) and rewrites `scada-points.csv`. Re-run it after any
register change so the two sides cannot drift — that is the §3 invariant. Anything
changed by hand here is lost on the next run; if CI Server needs a field the script
does not emit, it goes in the generator.
## Connection
| | |
|---|---|
| Protocol | Modbus TCP, CI Server is the **client/master** |
| Host | `yau-sls-poc-lin001`**`10.0.0.17`**, static |
| Port | `502` |
| Unit / slave id | `1` |
> The PLC publishes 502 on `10.0.0.17` only, not `0.0.0.0`. CI Server reaches it
> over the LAN. This is the one place in the project where a literal IP is
> unavoidable — see `../../02-environment/`.
## Poll groups
| Group | FC | Range | Points | What |
|---|---|---|---|---|
| `PS_STATUS_BITS` | FC01 | 014 | 15 | Run commands, running, available, in-auto, alarms, trips |
| `PS_PUBLISHED` | FC03 | 020 | 19 | Every live measurement and station/pump state |
| `PS_SETPOINTS` | FC03/06 | 10241034 | 11 | Mode, command word/param, level setpoints, limits |
| `PS_SIM_CONTROL` | FC03/06 | 10441047 | 4 | Scenario, manual inflow, reset, time scale |
Each group is contiguous, so each is one request.
## Three things that will bite
**1. Do not poll `%IW` / `%IX` (FC04 / FC02).** They are the *field* inputs. In the
simulation build nothing writes them — they read 0 forever, and an HMI built on
them shows a dead plant. Every live value is published in the `%QW` block instead.
The generator excludes them by default; `--include-field` emits them for a real
field deployment.
**2. `%QW17` (holding register 17, alarm bitmask) is UNSIGNED.** Bit 15 does not
fit a signed INT, so a signed configuration turns the alarm word negative exactly
when the most severe alarm sets. The CSV marks it `Unsigned 16-bit`.
**3. `%MW` is not holding register 0.** `%QW` occupies 01023 and `%MW` starts at
**1024**, so the level setpoint `%MW3` is holding register **1027**. The CSV
carries resolved addresses — use them, not the IEC names.
## Driving the simulation from CI Server
The four `PS_SIM_CONTROL` points exist so scenarios can be run from the HMI rather
than from a script:
| Tag | HR | Values |
|---|---|---|
| `PS_SIM_SCENARIO_*` | 1045 | 0 manual · 1 diurnal dry weather · 2 wet weather · 3 demo reference |
| `PS_SIM_MANUAL_INFLOW` | 1044 | m³/h in CI Server (the register is L/s × 10), scenario 0 only |
| `PS_SIM_WRITE_1_TO_RESET_SCENARIO` | 1046 | write 1 to reset; self-clearing |
| `PS_SIM_TIME_SCALE_1_120` | 1047 | 1 while testing, 3060 when presenting |
Reset restores the scenario's initial level and volume. It does **not** clear run
hours or trip states — those live in the control logic, and need command word
`%MW1` = 1 (reset trips) or 5 (reset hours). See `03-plc/README.md`.
These four registers only do anything in the **simulation build**. In a field
build they exist but nothing reads them.
## Scaling
`raw_to_eng` says how to convert, and `eng_gain` is the same thing as a number
for the generators to use. Points with no unit override keep the PLC's own
scaling — `value / 10` where it publishes ×10, `value` otherwise. The points
whose unit the SCADA side changes are listed under **Engineering units** above.
`format_mask` is the display mask that becomes `VALUE_FORMAT` on the item, and
it must stay in step with `../hmi/point_format.py`.
## Status
**CI Server reads live PLC values** (2026-08-14) — sections, points and items
imported, values verified against a direct Modbus read. That is Phase 4's
definition of done.
**Units re-cut, 2026-08-14** — level in % of the spill weir, drive speed in % of
50 Hz, flows in m³/h, level and speed setpoints entered in %. **No new items and
no renumbering**: the same 49 items, same NSIDs, same `ID_NUMBER`s, so a
re-import of `wrps_modbus_point_df.qli` and `wrps_item_df.qli` updates them in
place and the HMI's harvested `item-ids.csv` stays valid. Verified against a live
read: raw 4217 → 70.3%, reconciling with `VOL_TO_SPILL` 214 m³ = (6.000 4.217)
× 120; raw 404 → 80.8% of 50 Hz.
One thing left for you to set, deliberately not guessed at:
- **Alarm and trend limits.** Every item imports with alarming off and limits at
0. Which points alarm, at what thresholds and priority, is engineering judgement
about the plant, not something to derive from a register map.

View file

@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""Emit CI Server .qli imports for the WRPS points.
python gen_scada_points.py # first: refresh scada-points.csv
python gen_ciserver_qli.py
Writes, alongside this script:
wrps_section_df.qli @SECTION_DF - the AID.WRPS.* sections
wrps_modbus_point_df.qli @MODBUS_POINT_DF - the Modbus point definitions
wrps_item_df.qli @ITEM_DF - the items in those sections
Import them in that order: CI Server derives its hierarchy from the dots
in a name, and a section must exist before an item can be created in it.
The chain is ST sources -> 03-plc/register-map.csv -> scada-points.csv ->
these files, so a register change propagates by re-running the three
scripts rather than by hand-editing anything.
Field layouts and every constant here were copied from the user's own
exports in 99-reference/ciserver-qli-exports/ (section_df.qli,
modbus_point_df.qli, item_df.qli), not invented.
NSIDs
-----
The NSID space is shared by sections and items. In the reference
exports items occupy 3-166 and sections 1-170, so 171 is the first free
id: sections take 171-176 and items 177 onward (--nsid-base).
AID (169) and AID.WRPS (170) already exist, so this script emits only
their six children and hangs them off 170 (--parent-nsid). Pass
--include-root to emit AID and AID.WRPS as well, for a system that does
not have them yet.
THE ONE THING TO VERIFY ON A SMALL TEST IMPORT
----------------------------------------------
**Address base** (--address-base, default 1). The reference export maps
point AI_01 to IO_ADDRESS "RI:01". Whether CI Server's 01 means
protocol address 0 or 1 cannot be told from the file. Default here is
1-based, i.e. IO_ADDRESS = modbus address + 1, so holding register 0
(%QW0, wet well level) becomes "RO:01". If the level shows up where the
inflow should be, it is 0-based: re-run with --address-base 0.
"""
import argparse
import csv
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
POINTS = HERE / "scada-points.csv"
STATION = "WRPS_PLC"
ROOT = "AID.WRPS"
# IO_ADDRESS prefixes, from the reference export:
# RI = register input (FC04) RO = register output (FC03/06)
# DI = digital input (FC02) DO = digital output (FC01)
PREFIX = {"FC01": "DO", "FC02": "DI", "FC03": "RO", "FC03/FC06": "RO", "FC04": "RI"}
# Section per poll group; PS_PUBLISHED and PS_STATUS_BITS are split by
# equipment so each pump gets its own folder.
GROUP_SECTION = {"PS_SETPOINTS": "SP", "PS_SIM_CONTROL": "SIM"}
SECTION_ORDER = ["STN", "PU301", "PU302", "PU303", "SP", "SIM"]
SECTION_DESC = {
"STN": "Waterloo Road PS - station wide measurements and status",
"PU301": "Pump PU-301",
"PU302": "Pump PU-302",
"PU303": "Pump PU-303",
"SP": "Operator setpoints and commands",
"SIM": "Simulation control - simulation build only",
}
SECTION_FIELDS = [
"NAME", "SECTION_PATH", "SECTION_NAME", "BLOCKED", "PARENT_BLOCKED",
"ALARM_INHIBIT", "PARENT_ALARM_INHIBIT", "OPC_VISIBLE", "PARENT_OPC_VISIBLE", "NSID", "PARENT_NSID",
"DESCRIPTION", "CREATED_BY",
]
# Short, stable leaf names. Keyed on IEC address so they never drift
# with a description edit.
LEAF = {
"%QX0.0": ("PU301", "RUN_CMD"), "%QX0.1": ("PU302", "RUN_CMD"),
"%QX0.2": ("PU303", "RUN_CMD"), "%QX0.3": ("PU301", "RUNNING"),
"%QX0.4": ("PU302", "RUNNING"), "%QX0.5": ("PU303", "RUNNING"),
"%QX0.6": ("PU301", "AVAILABLE"), "%QX0.7": ("PU302", "AVAILABLE"),
"%QX1.0": ("PU303", "AVAILABLE"), "%QX1.1": ("STN", "IN_AUTO"),
"%QX1.2": ("STN", "HIGH_LEVEL"), "%QX1.3": ("STN", "SPILL_ACTIVE"),
"%QX1.4": ("PU301", "TRIPPED"), "%QX1.5": ("PU302", "TRIPPED"),
"%QX1.6": ("PU303", "TRIPPED"),
"%QW0": ("STN", "LEVEL"), "%QW1": ("STN", "INFLOW"),
"%QW2": ("STN", "DISCHARGE"), "%QW3": ("STN", "PUMPS_RUNNING"),
"%QW4": ("STN", "SPEED"), "%QW5": ("STN", "TIME_TO_SPILL"),
"%QW6": ("STN", "TIME_TO_LSHH"), "%QW7": ("STN", "NET_ACCUM"),
"%QW8": ("PU301", "RUN_HOURS"), "%QW9": ("PU302", "RUN_HOURS"),
"%QW10": ("PU303", "RUN_HOURS"), "%QW11": ("STN", "VOL_TO_SPILL"),
"%QW12": ("STN", "STATE"), "%QW13": ("PU301", "STATE"),
"%QW14": ("PU302", "STATE"), "%QW15": ("PU303", "STATE"),
"%QW16": ("STN", "DUTY_PUMP"), "%QW17": ("STN", "ALARM_WORD"),
"%QW20": ("STN", "CMD_ACK"),
"%MW0": ("SP", "MODE"), "%MW1": ("SP", "CMD_WORD"),
"%MW2": ("SP", "CMD_PARAM"), "%MW3": ("SP", "LEVEL_SP"),
"%MW4": ("SP", "START_DUTY"), "%MW5": ("SP", "START_P2"),
"%MW6": ("SP", "START_P3"), "%MW7": ("SP", "STOP_ALL"),
"%MW8": ("SP", "HIGH_ALARM"), "%MW9": ("SP", "MIN_SPEED"),
"%MW10": ("SP", "SERVICE_HRS"),
"%MW20": ("SIM", "INFLOW"), "%MW21": ("SIM", "SCENARIO"),
"%MW22": ("SIM", "RESET"), "%MW23": ("SIM", "TIME_SCALE"),
}
def q(v):
"""Quote a .qli value the way the reference exports do."""
return '"%s"' % ("" if v is None else str(v))
def wrap(values):
"""Split into the reference exports' line shape: 5, then 6 per line.
Both reference files wrap this way, in the @FIELDS header and in
every record. Reproduced exactly rather than assuming the importer
tolerates a different grouping.
"""
out = [list(values[:5])]
rest = list(values[5:])
for i in range(0, len(rest), 6):
out.append(rest[i:i + 6])
return out
def record(rows):
"""Format one record, backslash continuations between lines."""
lines = [",".join(str(v) for v in chunk) for chunk in wrap(rows)]
return (",\\\n".join(lines)) + "\n"
def header(version, fields, tag):
lines = ["", "@LANGUAGE", "ENGLISH", "", "", "@VERSION", version, "", "", "!" + "=" * 130, "", "@FIELDS"]
lines += [",".join(chunk) for chunk in wrap(fields)]
lines += ["", tag]
return "\n".join(lines) + "\n"
POINT_FIELDS = [
"NAME", "STATION", "POINT", "DESCRIPTION", "IO_ADDRESS",
"EXTERNAL_RELATION", "SCAN_TYPE", "CONV_TYPE", "DELTA_LIMIT", "MAX_INSENS", "OFFSET",
"PHYS_LOW", "PHYS_HIGH", "STEP", "INVERS", "HAS_SIGN", "OVERFL_DET",
"SWAP_BYTES", "SWAP_WORDS", "ELEC_LOW", "ELEC_HIGH", "TMO_NONE", "TMO_BOTH",
"AVE_UPD_INTERVAL", "NO_ZERO", "BURST_LIMIT", "BITS", "CHARS", "DIGITS",
"FLOAT_TYPE", "TIME_ZONE", "TIME_REPRES", "WLS_VAL_TYPE",
]
ITEM_FIELDS = [
"NAME", "NSID", "PARENT_NSID", "SECTION_PATH", "DESCRIPTION",
"DEADBAND", "LOW_LIMIT", "HIGH_LIMIT", "LOW_LOW_LIMIT", "HIGH_HIGH_LIMIT", "T_VALUE",
"P_VALUE", "I_VALUE", "TREND_UP_LIMIT", "TREND_LOW_LIMIT", "SCALE_HIGH_LIMIT", "SCALE_LOW_LIMIT",
"DUMMY2", "COMMENT_1", "COMMENT_2", "VALUE_FORMAT", "POSITIONED", "LONGITUDE",
"LATITUDE", "ALARMING", "DIAG", "NAME_IN_ITM_TAB", "STORAGE", "AUDIT_INFO",
"FO_ITEM", "BLOCKED", "PARENT_BLOCKED", "ALARM_INHIBIT", "PARENT_ALARM_INHIBIT", "OPC_VISIBLE",
"PARENT_OPC_VISIBLE", "OPC_READ", "OPC_WRITE", "OPC_ALARM_DETECTION", "HAS_SUB", "STRING_LENGTH",
"DELAY", "REPEAT", "ID_GROUP", "ID_NUMBER", "ITEM_REP", "ITEM_TYPE",
"ITEM_SPECIAL", "ACKN_TYPE", "ALARM_GROUP", "FRONT_END_NODE", "DISTR_TYPE", "INSTALL",
"UNIT", "TAG", "LIMIT_CLAMP", "OUT_OF_RANGE", "COL_GROUP", "STATION",
"POINT", "FO_GROUP", "ITEM_STAT_1", "ITEM_STAT_2", "ITEM_STAT_3", "ITEM_STAT_4",
"ITEM_STAT_5", "ITEM_STAT_6", "ALARM_STATE_1", "ALARM_STATE_2", "ALARM_STATE_3", "ALARM_STATE_4",
"ALARM_STATE_5", "ALARM_STATE_6", "PRIORITY_1", "PRIORITY_2", "PRIORITY_3", "PRIORITY_4",
"PRIORITY_5", "PRIORITY_6", "ALARM_TEXT_1", "ALARM_TEXT_2", "ALARM_TEXT_3", "ALARM_TEXT_4",
"ALARM_TEXT_5", "ALARM_TEXT_6", "ALARM_COLOR_1", "ALARM_COLOR_2", "ALARM_COLOR_3", "ALARM_COLOR_4",
"ALARM_COLOR_5", "ALARM_COLOR_6", "MNEMONIC_1", "MNEMONIC_2", "MNEMONIC_3", "MNEMONIC_4",
"MNEMONIC_5", "MNEMONIC_6", "AOI_1", "AOI_2", "AOI_3", "AOI_4",
"AOI_5", "AOI_6", "AOI_7", "AOI_8", "AOI_9", "AOI_10",
"AOI_11", "AOI_12", "AOI_13", "AOI_14", "AOI_15", "AOI_16",
"ENG_UNIT", "PROCESS_LIST", "POINT_NAME", "OPC_AE_STATION_NAME", "OPC_EVENT_SOURCE", "OPC_EVENT_SOURCE_NAME",
"CREATED_BY", "SHELVE_ENABLED_1", "SHELVE_ENABLED_2", "SHELVE_ENABLED_3", "SHELVE_ENABLED_4", "SHELVE_ENABLED_5",
"SHELVE_ENABLED_6", "AGG_INTERVAL",
]
def build_section(name, parent_path, leaf, nsid, parent_nsid, description):
"""One @SECTION_DF record, shaped like the reference export.
PARENT_OPC_VISIBLE is 1 for a root section and 0 for a child, which
is what the reference shows for AID (root) versus AID.WRPS (child).
"""
return record([
q(name), q(parent_path), q(leaf), 0, 0,
0, 0, 0, 1 if parent_path == "" else 0, nsid, parent_nsid,
q(description), q("unknown"),
])
def trimmed_span(gain, signed):
"""The ELEC range to map from, trimmed so PHYS lands on exact decimals.
CI Server stores the conversion as two endpoints, not as a gain, so
it recovers the gain by dividing. With a gain of 1/60 the full-scale
endpoint 32767/60 = 546.11666... has to be written rounded, and the
recovered gain is then slightly off: setpoint 4200 came back as
70.00003333333336 rather than 70.
So when the gain is 1/n, trim the span to the nearest multiple of n
inside it - 32760 instead of 32767, giving PHYS exactly +/-546 and a
gain CI Server recovers exactly. Nothing is lost: the trimmed 7
counts are 0.4 mm of a level register that tops out at 7000.
Gains that already terminate (0.1, 0.2, 0.36) keep the full span.
"""
lo, hi = (-32768, 32767) if signed else (0, 65535)
inv = 1.0 / gain
n = int(round(inv))
if n > 1 and abs(inv - n) < 1e-9:
lo, hi = -((-lo) // n) * n, (hi // n) * n
return lo, hi
def build_point(row, io_addr):
digital = row["data_type"] == "Boolean"
writable = row["access"] == "Read/Write"
gain = float(row["eng_gain"])
signed = row["data_type"] == "Signed 16-bit"
if digital:
conv, bits = "Digital", 16
elec_lo, elec_hi, phys_lo, phys_hi = 0, 100, 0, 100
else:
conv, bits = "Linear", 16
# Full-scale linear mapping. Exact, and invents no plant range;
# display ranges and alarm limits are set per item in CI Server.
# The engineering unit is carried entirely by the gain (see UNITS
# in gen_scada_points.py), so PHYS is ELEC scaled by it - which is
# why the offset must stay zero on both ends.
elec_lo, elec_hi = trimmed_span(gain, signed)
phys_lo, phys_hi = elec_lo * gain, elec_hi * gain
external = "Input + Output" if writable else "Input"
scan = "MOD_SCAN"
name = "%s:%s" % (STATION, row["point"])
return record([
q(name), q(STATION), q(row["point"]), q(row["description"][:60]), q(io_addr),
q(external), q(scan), q(conv), 0, 0, 0,
_num(phys_lo), _num(phys_hi), 1, 0, 1 if signed else 0, 0,
0, 0, _num(elec_lo), _num(elec_hi), 0, 0,
0, 0, 0, bits, 16, 4,
q("Intel"), q("Date+time GMT"), q("7 bytes IEC"), q("Float value"),
])
def _num(v):
"""Whole numbers stay whole; fractions keep enough digits to be exact.
Four decimals, not one: a gain of 0.001 puts PHYS_HIGH at 32.767, and
rounding that to 32.8 would bend the conversion by 0.1%.
"""
return int(v) if float(v) == int(float(v)) else round(float(v), 4)
def build_item(row, nsid, parent_nsid, id_number):
digital = row["data_type"] == "Boolean"
section = row["section"]
path = "%s.%s" % (ROOT, section)
name = "%s.%s" % (path, row["leaf"])
item_rep = "Boolean" if digital else "Real"
value_format = "" if digital else row["format_mask"]
eng_unit = row["eng_units"]
f = {k: 0 for k in ITEM_FIELDS}
f.update({k: q("") for k in ITEM_FIELDS if k.startswith(
("COMMENT", "ITEM_STAT", "ALARM_TEXT", "ALARM_COLOR", "MNEMONIC", "AOI"))})
f["NAME"] = q(name)
f["NSID"] = nsid
f["PARENT_NSID"] = parent_nsid
f["SECTION_PATH"] = q(path)
f["DESCRIPTION"] = q(row["description"][:60])
f["VALUE_FORMAT"] = q(value_format)
f["NAME_IN_ITM_TAB"] = 1
f["STRING_LENGTH"] = 1
f["ID_GROUP"] = 1
f["ID_NUMBER"] = id_number
f["ITEM_REP"] = q(item_rep)
f["ITEM_TYPE"] = q("")
f["ITEM_SPECIAL"] = q("")
f["ACKN_TYPE"] = q("")
f["ALARM_GROUP"] = q("")
f["FRONT_END_NODE"] = q("UNLICENCED")
f["DISTR_TYPE"] = q("Local Host")
f["INSTALL"] = q("WRPS")
f["UNIT"] = q(section)
f["TAG"] = q(row["leaf"])
f["LIMIT_CLAMP"] = q("")
f["COL_GROUP"] = q("")
f["STATION"] = q(STATION)
f["POINT"] = q(row["point"])
f["FO_GROUP"] = q("")
for i in range(1, 7):
f["ALARM_STATE_%d" % i] = q("Normal")
if digital:
f["ITEM_STAT_1"] = q("BOOLEAN 0")
f["ITEM_STAT_2"] = q("BOOLEAN 1")
f["ENG_UNIT"] = q(eng_unit)
f["PROCESS_LIST"] = q("")
f["POINT_NAME"] = q("%s:%s" % (STATION, row["point"]))
f["OPC_AE_STATION_NAME"] = q("")
f["OPC_EVENT_SOURCE"] = q("")
f["OPC_EVENT_SOURCE_NAME"] = q(":")
f["CREATED_BY"] = q("unknown")
for i in range(1, 7):
f["SHELVE_ENABLED_%d" % i] = 1
f["AGG_INTERVAL"] = q("")
return record([f[k] for k in ITEM_FIELDS])
def main():
ap = argparse.ArgumentParser(description="Emit CI Server .qli imports.")
ap.add_argument("--address-base", type=int, choices=(0, 1), default=1,
help="IO_ADDRESS numbering: 1 = protocol address + 1 (default)")
ap.add_argument("--nsid-base", type=int, default=171,
help="first free NSID; sections take six from here, items follow")
ap.add_argument("--parent-nsid", type=int, default=170,
help="NSID of AID.WRPS, which the six sections hang off")
ap.add_argument("--include-root", action="store_true",
help="also emit AID and AID.WRPS sections (they already exist here)")
ap.add_argument("--section-nsid", action="append", default=[],
metavar="SECTION=NSID", help="pin a section's NSID, e.g. STN=41")
ap.add_argument("--version", default="1.03.00", help="@VERSION written into both files")
args = ap.parse_args()
if not POINTS.is_file():
sys.exit("missing scada-points.csv - run gen_scada_points.py first")
pinned = {}
for spec in args.section_nsid:
k, _, v = spec.partition("=")
pinned[k.strip().upper()] = int(v)
rows = list(csv.DictReader(open(POINTS, newline="", encoding="utf-8")))
prepared = []
for r in rows:
iec = r["iec_address"]
if iec not in LEAF:
sys.exit("no leaf name defined for %s - add it to LEAF" % iec)
section, leaf = LEAF[iec]
r["section"] = section
r["leaf"] = leaf
r["point"] = ("%s_%s" % (section, leaf))[:24]
prepared.append(r)
# NSIDs: one per section, then one per item, from --nsid-base.
next_id = args.nsid_base
section_nsid = {}
for s in SECTION_ORDER:
if s in pinned:
section_nsid[s] = pinned[s]
else:
section_nsid[s] = next_id
next_id += 1
section_out = [header(args.version, SECTION_FIELDS, "@SECTION_DF")]
if args.include_root:
root, _, wrps_leaf = ROOT.partition(".")
section_out.append(build_section(root, "", root, args.parent_nsid - 1, 0,
"Waterloo Road Pump Station demo"))
section_out.append(build_section(ROOT, root, wrps_leaf, args.parent_nsid,
args.parent_nsid - 1,
"Waterloo Road Pump Station"))
for s in SECTION_ORDER:
section_out.append(build_section("%s.%s" % (ROOT, s), ROOT, s,
section_nsid[s], args.parent_nsid,
SECTION_DESC[s]))
point_out = [header(args.version, POINT_FIELDS, "@MODBUS_POINT_DF")]
item_out = [header(args.version, ITEM_FIELDS, "@ITEM_DF")]
# NSID and ID_NUMBER follow register order, which is also the order the
# items were first imported in. Keep it stable: a re-import must update
# the existing items in place, not renumber them - the HMI's item ids in
# 05-scada/hmi/item-ids.csv are derived from that creation order.
for n, r in enumerate(prepared):
addr = int(r["modbus_address"]) + args.address_base
# Zero-padded to at least two digits, as the reference export does
# ("RI:01", not "RI:1"). Wider addresses keep their own width.
io_addr = "%s:%02d" % (PREFIX[r["function_code"]], addr)
point_out.append(build_point(r, io_addr))
item_out.append(build_item(r, next_id + n, section_nsid[r["section"]], n + 1))
# The header already ends in a newline, so the first record follows it
# directly; records are then separated by one blank line, exactly as
# the reference exports are.
def assemble(parts):
return parts[0] + "\n".join(parts[1:])
(HERE / "wrps_section_df.qli").write_text(assemble(section_out), encoding="utf-8")
(HERE / "wrps_modbus_point_df.qli").write_text(assemble(point_out), encoding="utf-8")
(HERE / "wrps_item_df.qli").write_text(assemble(item_out), encoding="utf-8")
n_sections = len(SECTION_ORDER) + (2 if args.include_root else 0)
print("OK wrps_section_df.qli (%d sections under %s)" % (n_sections, ROOT))
print("OK wrps_modbus_point_df.qli (%d points, station %s)" % (len(prepared), STATION))
print("OK wrps_item_df.qli (%d items under %s.*)" % (len(prepared), ROOT))
print(" address base : %d (holding register 0 -> RO:%02d)" % (args.address_base, args.address_base))
print(" section NSIDs: " + ", ".join("%s=%d" % (s, section_nsid[s]) for s in SECTION_ORDER))
print(" item NSIDs : %d-%d" % (next_id, next_id + len(prepared) - 1))
by_sec = {}
for r in prepared:
by_sec.setdefault(r["section"], 0)
by_sec[r["section"]] += 1
print(" per section : " + ", ".join("%s=%d" % (s, by_sec.get(s, 0)) for s in SECTION_ORDER))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Derive the SCADA-side point list from the PLC-side register map.
python gen_scada_points.py
Reads ../../03-plc/register-map.csv (PLC side, generated by build.py)
Writes ./scada-points.csv (SCADA side, for CI Server config)
CLAUDE.md section 3: the two sides are two views of the same points, and
the PLC side leads. This script makes the SCADA view reproducible
instead of hand-transcribed, so the two cannot drift apart silently.
It is a *starting point* for CI Server configuration, not a CI Server
import file. CI Server's .qli item exports carry no Modbus addressing
(that lives in the front-end I/O configuration), so the mapping is
applied by hand in CI Server from this list.
The four judgements this script encodes
---------------------------------------
1. **Poll groups.** Four, not one per function code. The holding
registers split by purpose and address range: read-only published
data at 0-20, operator setpoints at 1024-1034, simulation control at
1044-1047. Each group is then contiguous and polls as one request;
a single holding-register group would span 1048 mostly-empty
addresses.
2. **%QW17 is UNSIGNED.** Bit 15 does not fit a signed INT. Configure
it as a 16-bit unsigned register or the alarm word goes negative
exactly when the most severe alarm is set.
3. **Engineering units are chosen here, not in the PLC.** The PLC
publishes mm, L/s x10 and Hz x10 and keeps doing so; a unit on the
SCADA side is a gain on the raw register (see UNITS below). One
unit per register, though - CI Server rejects a second item on an
I/O address it already has.
4. **%IW / %IX are excluded by default.** They are the *field* inputs.
In the simulation build nothing writes them, so they read 0 forever
and would show a dead plant on the HMI. Every live measurement is
published in the %QW block. Pass --include-field to emit them
anyway, for a real field deployment.
"""
import argparse
import csv
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SRC_MAP = ROOT / "03-plc" / "register-map.csv"
OUT = Path(__file__).resolve().parent / "scada-points.csv"
# Modbus object -> (function code, poll group name)
#
# Holding registers are split into three groups rather than one. They
# share a function code but not a purpose or an address range: %QW is
# read-only published data at 0-1023, %MW is writable at 1024+. One
# group spanning both would be a single 1048-register request over a
# mostly empty address space.
GROUPS = {
"Coil": ("FC01", "PS_STATUS_BITS"),
"Discrete input": ("FC02", "PS_FIELD_BITS"),
"Input register": ("FC04", "PS_FIELD_ANALOG"),
}
HR_PUBLISHED = ("FC03", "PS_PUBLISHED") # %QW, read-only
HR_SETPOINTS = ("FC03/FC06", "PS_SETPOINTS") # %MW0-10, operator writable
HR_SIM = ("FC03/FC06", "PS_SIM_CONTROL") # %MW20-23, simulation only
FIELD_ONLY = {"Input register", "Discrete input"}
UNSIGNED = {"%QW17"}
# ---------------------------------------------------------------------------
# Engineering units, SCADA side
# ---------------------------------------------------------------------------
#
# The PLC publishes mm, L/s x10 and Hz x10 - that is the contract in
# register-map.csv and it does not change. What the operator reads is a
# presentation choice, and CI Server already applies a linear conversion
# per Modbus point, so a unit here is nothing but a gain on the raw
# register:
#
# engineering value = raw register * gain (offset is always 0)
#
# Zero offset matters: it keeps the full-scale ELEC -> PHYS mapping in
# gen_ciserver_qli.py exact, and it means a second unit on the same
# register is just a second point with a different gain.
#
# ONE ITEM PER I/O ADDRESS. A register carries exactly one engineering
# unit, because CI Server will not accept two items on the same Modbus
# address - an import that tries it fails with
#
# EQP-E-DUP_ITEM, I/O address of item already defined
# DSSP-E-INSREC, Failed to insert a record in the dataset ITEM_DF
#
# (confirmed on R1.03, 2026-08-14, trying to publish level in m *and* %).
# So showing one measurement in two units needs two *PLC* registers, not
# two views of one. Level and speed are published in % alone.
#
# Gains chosen 2026-08-14:
# level % 100% = the 6.000 m spill weir, so a full bar means spilling
# and the reading reconciles with VOL_TO_SPILL. 1/60 per mm.
# flow m3/h = L/s x 3.6, and the register is L/s x10: 0.36.
# speed % 100% = 50 Hz, the drive's maximum. Register Hz x10: 0.2.
#
# iec address -> (units, gain, format mask)
UNITS = {
"%QW0": ("%", 1 / 60.0, "999.9"), # wet well level, was mm
"%QW1": ("m3/h", 0.36, "9999.9"), # inflow, was L/s
"%QW2": ("m3/h", 0.36, "9999.9"), # total discharge, was L/s
"%QW4": ("%", 0.2, "999.9"), # common drive speed, was Hz
"%QW7": ("m3/h", 0.36, "9999.9"), # net accumulation, signed
"%MW3": ("%", 1 / 60.0, "999.9"), # level setpoint, was mm
"%MW4": ("%", 1 / 60.0, "999.9"), # start duty
"%MW5": ("%", 1 / 60.0, "999.9"), # start pump 2
"%MW6": ("%", 1 / 60.0, "999.9"), # start pump 3
"%MW7": ("%", 1 / 60.0, "999.9"), # stop all
"%MW8": ("%", 1 / 60.0, "999.9"), # high level alarm
"%MW9": ("%", 0.2, "999.9"), # minimum drive speed, was Hz
"%MW20": ("m3/h", 0.36, "9999.9"), # SIM manual inflow, was L/s
}
# Points that keep their PLC unit but not the default mask. Enums, modes
# and small counts are one or two digits wide; a five-digit mask on them
# is not wrong, only wide, and it would disagree with the HMI's masks in
# 04-scada/hmi/point_format.py. iec address -> mask
MASK_ONLY = {
"%QW3": "9", # pumps running, 0-3
"%QW11": "9999", # volume remaining to spill, m3
"%QW12": "9", # station state enum
"%QW13": "9", "%QW14": "9", "%QW15": "9", # pump state enums
"%QW16": "9", # duty pump, 0-3
"%QW20": "99", # command acknowledge
"%MW0": "9", # station mode
"%MW1": "99", # command word
"%MW2": "9", # command parameter, pump number
"%MW21": "9", # SIM scenario 0-3
"%MW22": "9", # SIM reset, write 1
"%MW23": "999", # SIM time scale 1-120
}
def eng_view(iec, scaling):
"""(units override, gain, mask override) for a register.
Falls back to the PLC's own unit and the raw scaling when the point
is not in UNITS - most points are already in the unit the operator
wants, and inventing a conversion for them would only add risk.
"""
if iec in UNITS:
return UNITS[iec]
gain = 0.1 if scaling == "x10" else 1.0
default = "9999.9" if scaling == "x10" else "99999"
return None, gain, MASK_ONLY.get(iec, default)
def gain_expr(gain):
"""The gain as the human-readable expression the CSV carries."""
if gain == 1.0:
return "value"
if gain == 0.1:
return "value / 10"
if abs(gain - 1 / 60.0) < 1e-12:
return "value / 60"
return "value * %g" % gain
def tag_for(row, description):
"""Tag name per CLAUDE.md section 8: PS_<EQUIP><NN>_<MEAS>.
Built from the cleaned description, so the "SIM ONLY:" marker does
not end up inside the tag name.
"""
equip = row["tag"].replace("-", "")
meas = description.split("(")[0].split(",")[0].strip()
meas = "".join(ch if ch.isalnum() else "_" for ch in meas)
meas = "_".join(p for p in meas.split("_") if p)[:28].upper()
return f"PS_{equip}_{meas}".upper()
def main():
ap = argparse.ArgumentParser(description="Derive the SCADA point list.")
ap.add_argument(
"--include-field",
action="store_true",
help="also emit %%IW/%%IX field inputs (they read 0 in the simulation build)",
)
args = ap.parse_args()
if not SRC_MAP.is_file():
sys.exit(f"missing {SRC_MAP} - run 04-plc/wrps-plc/build.py first")
rows = list(csv.DictReader(open(SRC_MAP, newline="", encoding="utf-8")))
out_rows, skipped = [], 0
for r in rows:
obj = r["modbus_object"]
if obj in FIELD_ONLY and not args.include_field:
skipped += 1
continue
iec = r["iec_address"]
sim_only = r["description"].startswith("SIM ONLY")
writable = r["access"] == "RW"
description = r["description"].replace("SIM ONLY: ", "")
if obj == "Holding register":
if sim_only:
fc, group = HR_SIM
elif writable:
fc, group = HR_SETPOINTS
else:
fc, group = HR_PUBLISHED
else:
fc, group = GROUPS[obj]
if obj in ("Coil", "Discrete input"):
data_type = "Boolean"
elif iec in UNSIGNED:
data_type = "Unsigned 16-bit"
else:
data_type = "Signed 16-bit"
units_override, gain, mask = eng_view(iec, r["scaling"])
out_rows.append(
{
"scada_tag": tag_for(r, description),
"description": description,
"poll_group": group,
"function_code": fc,
"modbus_address": r["modbus_address"],
"data_type": data_type,
"access": "Read/Write" if writable else "Read",
"eng_units": units_override if units_override is not None else r["units"],
"raw_to_eng": gain_expr(gain),
"eng_gain": repr(gain),
"format_mask": mask,
"iec_address": iec,
"plc_tag": r["tag"],
"notes": "SIMULATION CONTROL - simulation build only" if sim_only else "",
}
)
with open(OUT, "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=list(out_rows[0].keys()))
w.writeheader()
w.writerows(out_rows)
groups = {}
for r in out_rows:
groups.setdefault(r["poll_group"], []).append(int(r["modbus_address"]))
print(f"OK {OUT} ({len(out_rows)} points)")
for g, addrs in groups.items():
print(f" {g:<18} {len(addrs):>3} points, addresses {min(addrs)}-{max(addrs)}")
if skipped:
print(
f" skipped {skipped} %IW/%IX field points - they read 0 in the\n"
f" simulation build; pass --include-field for a real field deployment"
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,62 @@
# historian — collection groups and item bindings
**Hand-made. There is no generator for this.** Everything else under
`modbus_points/` is generated from `03-plc/register-map.csv`; these two files are
not, and that is the reason they have drifted from the server.
| File | Class | Holds |
|---|---|---|
| `his_group.qli` | `@HIS_GROUP_DF` | 3 collection groups |
| `item_his.qli` | `@ITEM_HIS_DF` | 49 item → group bindings |
## The three groups
| Group | Items | Collection |
|---|---|---|
| `WRPS_ONE_SEC` | 5 | 1 s scan — the fast analogues: level, inflow, discharge |
| `WRPS_ONE_MIN` | 4 | 1 min scan — slower trends |
| `WRPS_EVENT` | 40 | on change — states, commands, alarms, trips |
Written 2026-08-21.
## Why this matters more than it looks
The demo this project exists to support is built on **historical grounding**. From
`01-design/00-origin/`:
> "Are you sure? What did inflow actually do last time we had rain like this?"
The answer to that question comes from the historian. Layer 1 of the demo — risk
and headroom — works from live values alone. The push-back that earns trust does
not. **Without these two files there is no historian, and the demo loses the layer
it was designed around.**
## ⚠️ These files disagree with the server
`ciserver-backup-2026-08/export_his_group.qli`, exported from CI Server around
2026-08-20, contains groups that `his_group.qli` does not define:
| In the CI Server export | In `his_group.qli` |
|---|---|
| `WRPS_EVENT` | ✅ |
| `WRPS_ONE_SEC` | ✅ |
| `WRPS_THIRTY_SEC` | ❌ not defined here |
| `WRPS_ONE_MIN` | — not in the export |
| `FIVE_SECONDS` (binding `STN.LEVEL`, `STN.INFLOW`) | ❌ not a WRPS group at all |
**Nobody currently knows which is right.** The export is dated a day before these
files, but contains groups they do not. Resolve it by exporting from `cicore1` and
comparing — see `../../QUICKLOAD.md`.
Until then: **do not import these files onto a working server** without checking
what is already there. You may remove a group something depends on.
## Worth doing: generate these too
The points and items chain is generated end to end, which is what keeps the PLC
and SCADA from drifting apart. The historian stops short of that, is maintained by
hand, and has drifted — exactly the failure the generated chain exists to prevent.
Extending `../gen_ciserver_qli.py` to emit `@HIS_GROUP_DF` and `@ITEM_HIS_DF`
would close the gap. The item list is already there; what it needs is a table
saying which item belongs to which group at what rate. Not done in this audit.

View file

@ -0,0 +1,44 @@
@LANGUAGE
ENGLISH
@VERSION
1.03.00
!==================================================================================================================================
@FIELDS
NAME,DESCRIPTION,START_TIME,STOP_TIME,FIRST_ROLLOVER
LIFE_TIME,WARN_TIME,ROLLOVER_INT,FORCE_ROLLOVER,FILL_OLD,STORE_QUALITY
SAVE_TIME,SCAN_INTERVAL,CORRECT_DAYLIGHT,EXCLUDE_ARCHIVE,DATA_COMP,AGG_PERIOD_HOUR
AGG_PERIOD_SHIFT,AGG_PERIOD_DAY,AGG_PERIOD_WEEK,AGG_PERIOD_MONTH,AGG_PERIOD_YEAR,AGG_PERIOD_30MIN
AGG_CORRECT_DAY_DST,AGG_CORRECT_SHIFT_DST,NUMBER,NODE,NODE_NAME,NEXT_UNIT_SEQ
NEXT_DATA_SEQ,AVERAGING_INTERVAL,PRIORITY,SAMPLES_RECORD,TYPE,AVERAGING_METHOD
COL_STOR_TYPE,AGG_SHIFT_LENGTH,AGG_WEEK_START,AGG_SHIFT_START,AGG_DAY_START,AGG_COMP_NOT_ITEM
@HIS_GROUP_DF
"WRPS_ONE_SEC","","","","21-08-2026 00:00:00",\
"7 days","","1 hours",0,0,1,\
0,1,0,0,0,1,\
0,0,0,0,0,0,\
0,0,50,1,"",1,\
2,0,8,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"WRPS_ONE_MIN","","","","21-08-2026 00:00:00",\
"7 days","","6 hours",0,0,1,\
0,60,0,0,0,1,\
0,0,0,0,0,0,\
0,0,51,1,"",1,\
2,0,5,0,"Item","",\
"Scan/Time","8 hours","Monday","00:00","00:00",""
"WRPS_EVENT","","","","21-08-2026 00:00:00",\
"7 days","","1 days",0,0,1,\
0,0,0,0,0,1,\
0,0,0,0,0,0,\
0,0,52,1,"",1,\
2,0,11,100,"Item","",\
"Event/Item","8 hours","Monday","00:00","00:00",""

View file

@ -0,0 +1,312 @@
@LANGUAGE
ENGLISH
@VERSION
1.03.00
!==================================================================================================================================
@FIELDS
NAME,ITEM_NAME,GROUP_NAME,ON_EACH_UPDATE,ON_FIRST_UPDATE
ON_OPTION_CHANGE,ON_PASS_PIT,ON_QUALITY_CHANGE,ON_STATUS_CHANGE,ON_VALUE_CHANGE,STORE_DEADBAND
STORE_HIGH_HIGH_LIMIT,STORE_HIGH_LIMIT,STORE_LOW_LIMIT,STORE_LOW_LOW_LIMIT,STORE_VALUE,STORE_AGG_MAX
STORE_AGG_MIN,STORE_AGG_AVG,STORE_AGG_INTG,STORE_AGG_STDV,STORE_AGG_CNT,STORE_AGG_DIFF_SUM
AGG_DIFF_SUM_RANGE,AGG_DIFF_SUM_PERC,AGG_INTG_PERIOD
@ITEM_HIS_DF
"WRPS_ONE_SEC:AID.WRPS.STN.LEVEL","AID.WRPS.STN.LEVEL","WRPS_ONE_SEC",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_SEC:AID.WRPS.STN.INFLOW","AID.WRPS.STN.INFLOW","WRPS_ONE_SEC",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_SEC:AID.WRPS.STN.DISCHARGE","AID.WRPS.STN.DISCHARGE","WRPS_ONE_SEC",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_SEC:AID.WRPS.STN.SPEED","AID.WRPS.STN.SPEED","WRPS_ONE_SEC",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_SEC:AID.WRPS.STN.NET_ACCUM","AID.WRPS.STN.NET_ACCUM","WRPS_ONE_SEC",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_MIN:AID.WRPS.STN.VOL_TO_SPILL","AID.WRPS.STN.VOL_TO_SPILL","WRPS_ONE_MIN",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_MIN:AID.WRPS.STN.TIME_TO_SPILL","AID.WRPS.STN.TIME_TO_SPILL","WRPS_ONE_MIN",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_MIN:AID.WRPS.STN.TIME_TO_LSHH","AID.WRPS.STN.TIME_TO_LSHH","WRPS_ONE_MIN",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_ONE_MIN:AID.WRPS.STN.PUMPS_RUNNING","AID.WRPS.STN.PUMPS_RUNNING","WRPS_ONE_MIN",0,0,\
0,0,0,0,0,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU301.RUN_CMD","AID.WRPS.PU301.RUN_CMD","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU302.RUN_CMD","AID.WRPS.PU302.RUN_CMD","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU303.RUN_CMD","AID.WRPS.PU303.RUN_CMD","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU301.RUNNING","AID.WRPS.PU301.RUNNING","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU302.RUNNING","AID.WRPS.PU302.RUNNING","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU303.RUNNING","AID.WRPS.PU303.RUNNING","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU301.AVAILABLE","AID.WRPS.PU301.AVAILABLE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU302.AVAILABLE","AID.WRPS.PU302.AVAILABLE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU303.AVAILABLE","AID.WRPS.PU303.AVAILABLE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU301.TRIPPED","AID.WRPS.PU301.TRIPPED","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU302.TRIPPED","AID.WRPS.PU302.TRIPPED","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU303.TRIPPED","AID.WRPS.PU303.TRIPPED","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.STN.IN_AUTO","AID.WRPS.STN.IN_AUTO","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.STN.HIGH_LEVEL","AID.WRPS.STN.HIGH_LEVEL","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.STN.SPILL_ACTIVE","AID.WRPS.STN.SPILL_ACTIVE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.STN.STATE","AID.WRPS.STN.STATE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU301.STATE","AID.WRPS.PU301.STATE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU302.STATE","AID.WRPS.PU302.STATE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU303.STATE","AID.WRPS.PU303.STATE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.STN.DUTY_PUMP","AID.WRPS.STN.DUTY_PUMP","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.STN.ALARM_WORD","AID.WRPS.STN.ALARM_WORD","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.STN.CMD_ACK","AID.WRPS.STN.CMD_ACK","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU301.RUN_HOURS","AID.WRPS.PU301.RUN_HOURS","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU302.RUN_HOURS","AID.WRPS.PU302.RUN_HOURS","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.PU303.RUN_HOURS","AID.WRPS.PU303.RUN_HOURS","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.MODE","AID.WRPS.SP.MODE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.CMD_WORD","AID.WRPS.SP.CMD_WORD","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.CMD_PARAM","AID.WRPS.SP.CMD_PARAM","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.LEVEL_SP","AID.WRPS.SP.LEVEL_SP","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.START_DUTY","AID.WRPS.SP.START_DUTY","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.START_P2","AID.WRPS.SP.START_P2","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.START_P3","AID.WRPS.SP.START_P3","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.STOP_ALL","AID.WRPS.SP.STOP_ALL","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.HIGH_ALARM","AID.WRPS.SP.HIGH_ALARM","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.MIN_SPEED","AID.WRPS.SP.MIN_SPEED","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SP.SERVICE_HRS","AID.WRPS.SP.SERVICE_HRS","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SIM.INFLOW","AID.WRPS.SIM.INFLOW","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SIM.SCENARIO","AID.WRPS.SIM.SCENARIO","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SIM.RESET","AID.WRPS.SIM.RESET","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"
"WRPS_EVENT:AID.WRPS.SIM.TIME_SCALE","AID.WRPS.SIM.TIME_SCALE","WRPS_EVENT",0,0,\
0,0,0,0,1,0,\
0,0,0,0,1,0,\
0,0,0,0,0,0,\
1000000,10,"Second"

View file

@ -0,0 +1,50 @@
scada_tag,description,poll_group,function_code,modbus_address,data_type,access,eng_units,raw_to_eng,eng_gain,format_mask,iec_address,plc_tag,notes
PS_PU301_RUN_COMMAND,Run command,PS_STATUS_BITS,FC01,0,Boolean,Read,,value,1.0,99999,%QX0.0,PU-301,
PS_PU302_RUN_COMMAND,Run command,PS_STATUS_BITS,FC01,1,Boolean,Read,,value,1.0,99999,%QX0.1,PU-302,
PS_PU303_RUN_COMMAND,Run command,PS_STATUS_BITS,FC01,2,Boolean,Read,,value,1.0,99999,%QX0.2,PU-303,
PS_PU301_RUNNING,Running,PS_STATUS_BITS,FC01,3,Boolean,Read,,value,1.0,99999,%QX0.3,PU-301,
PS_PU302_RUNNING,Running,PS_STATUS_BITS,FC01,4,Boolean,Read,,value,1.0,99999,%QX0.4,PU-302,
PS_PU303_RUNNING,Running,PS_STATUS_BITS,FC01,5,Boolean,Read,,value,1.0,99999,%QX0.5,PU-303,
PS_PU301_AVAILABLE,Available,PS_STATUS_BITS,FC01,6,Boolean,Read,,value,1.0,99999,%QX0.6,PU-301,
PS_PU302_AVAILABLE,Available,PS_STATUS_BITS,FC01,7,Boolean,Read,,value,1.0,99999,%QX0.7,PU-302,
PS_PU303_AVAILABLE,Available,PS_STATUS_BITS,FC01,8,Boolean,Read,,value,1.0,99999,%QX1.0,PU-303,
PS_STN_STATION_IN_AUTO,Station in auto,PS_STATUS_BITS,FC01,9,Boolean,Read,,value,1.0,99999,%QX1.1,STN,
PS_STN_HIGH_LEVEL_ALARM,High level alarm,PS_STATUS_BITS,FC01,10,Boolean,Read,,value,1.0,99999,%QX1.2,STN,
PS_STN_SPILL_ACTIVE,Spill active,PS_STATUS_BITS,FC01,11,Boolean,Read,,value,1.0,99999,%QX1.3,STN,
PS_PU301_TRIPPED,Tripped,PS_STATUS_BITS,FC01,12,Boolean,Read,,value,1.0,99999,%QX1.4,PU-301,
PS_PU302_TRIPPED,Tripped,PS_STATUS_BITS,FC01,13,Boolean,Read,,value,1.0,99999,%QX1.5,PU-302,
PS_PU303_TRIPPED,Tripped,PS_STATUS_BITS,FC01,14,Boolean,Read,,value,1.0,99999,%QX1.6,PU-303,
PS_STN_WET_WELL_LEVEL,Wet well level,PS_PUBLISHED,FC03,0,Signed 16-bit,Read,%,value / 60,0.016666666666666666,999.9,%QW0,STN,
PS_STN_INFLOW,Inflow,PS_PUBLISHED,FC03,1,Signed 16-bit,Read,m3/h,value * 0.36,0.36,9999.9,%QW1,STN,
PS_STN_TOTAL_DISCHARGE_FLOW,Total discharge flow,PS_PUBLISHED,FC03,2,Signed 16-bit,Read,m3/h,value * 0.36,0.36,9999.9,%QW2,STN,
PS_STN_PUMPS_RUNNING,Pumps running,PS_PUBLISHED,FC03,3,Signed 16-bit,Read,count,value,1.0,9,%QW3,STN,
PS_STN_COMMON_DRIVE_SPEED,Common drive speed,PS_PUBLISHED,FC03,4,Signed 16-bit,Read,%,value * 0.2,0.2,999.9,%QW4,STN,
PS_STN_TIME_TO_SPILL_WEIR,Time to spill weir (32767 = drawing down),PS_PUBLISHED,FC03,5,Signed 16-bit,Read,s,value,1.0,99999,%QW5,STN,
PS_STN_TIME_TO_LSHH,Time to LSHH (32767 = drawing down),PS_PUBLISHED,FC03,6,Signed 16-bit,Read,s,value,1.0,99999,%QW6,STN,
PS_STN_NET_ACCUMULATION,Net accumulation (signed),PS_PUBLISHED,FC03,7,Signed 16-bit,Read,m3/h,value * 0.36,0.36,9999.9,%QW7,STN,
PS_PU301_RUN_HOURS,Run hours,PS_PUBLISHED,FC03,8,Signed 16-bit,Read,h,value,1.0,99999,%QW8,PU-301,
PS_PU302_RUN_HOURS,Run hours,PS_PUBLISHED,FC03,9,Signed 16-bit,Read,h,value,1.0,99999,%QW9,PU-302,
PS_PU303_RUN_HOURS,Run hours,PS_PUBLISHED,FC03,10,Signed 16-bit,Read,h,value,1.0,99999,%QW10,PU-303,
PS_STN_VOLUME_REMAINING_TO_SPILL,Volume remaining to spill,PS_PUBLISHED,FC03,11,Signed 16-bit,Read,m3,value,1.0,9999,%QW11,STN,
PS_STN_STATION_STATE,Station state (enum 3.1),PS_PUBLISHED,FC03,12,Signed 16-bit,Read,,value,1.0,9,%QW12,STN,
PS_PU301_PUMP_STATE,Pump state (enum 3.2),PS_PUBLISHED,FC03,13,Signed 16-bit,Read,,value,1.0,9,%QW13,PU-301,
PS_PU302_PUMP_STATE,Pump state (enum 3.2),PS_PUBLISHED,FC03,14,Signed 16-bit,Read,,value,1.0,9,%QW14,PU-302,
PS_PU303_PUMP_STATE,Pump state (enum 3.2),PS_PUBLISHED,FC03,15,Signed 16-bit,Read,,value,1.0,9,%QW15,PU-303,
PS_STN_CURRENT_DUTY_PUMP,"Current duty pump (0 = none, 1-3)",PS_PUBLISHED,FC03,16,Signed 16-bit,Read,,value,1.0,9,%QW16,STN,
PS_STN_ALARM_BITMASK,Alarm bitmask (section 6) - READ AS UNSIGNED,PS_PUBLISHED,FC03,17,Unsigned 16-bit,Read,,value,1.0,99999,%QW17,STN,
PS_STN_COMMAND_ACKNOWLEDGE,Command acknowledge (echoes %MW1),PS_PUBLISHED,FC03,20,Signed 16-bit,Read,,value,1.0,99,%QW20,STN,
PS_STN_STATION_MODE_1_AUTO,"Station mode: 1 = auto, 2 = off",PS_SETPOINTS,FC03/FC06,1024,Signed 16-bit,Read/Write,,value,1.0,9,%MW0,STN,
PS_STN_COMMAND_WORD,Command word (section 3.3),PS_SETPOINTS,FC03/FC06,1025,Signed 16-bit,Read/Write,,value,1.0,99,%MW1,STN,
PS_STN_COMMAND_PARAMETER,Command parameter (pump number),PS_SETPOINTS,FC03/FC06,1026,Signed 16-bit,Read/Write,,value,1.0,9,%MW2,STN,
PS_STN_LEVEL_CONTROL_SETPOINT,Level control setpoint,PS_SETPOINTS,FC03/FC06,1027,Signed 16-bit,Read/Write,%,value / 60,0.016666666666666666,999.9,%MW3,STN,
PS_STN_START_DUTY_LEVEL,Start duty level,PS_SETPOINTS,FC03/FC06,1028,Signed 16-bit,Read/Write,%,value / 60,0.016666666666666666,999.9,%MW4,STN,
PS_STN_START_PUMP_2_LEVEL,Start pump 2 level,PS_SETPOINTS,FC03/FC06,1029,Signed 16-bit,Read/Write,%,value / 60,0.016666666666666666,999.9,%MW5,STN,
PS_STN_START_PUMP_3_LEVEL,Start pump 3 level,PS_SETPOINTS,FC03/FC06,1030,Signed 16-bit,Read/Write,%,value / 60,0.016666666666666666,999.9,%MW6,STN,
PS_STN_STOP_ALL_LEVEL,Stop all level,PS_SETPOINTS,FC03/FC06,1031,Signed 16-bit,Read/Write,%,value / 60,0.016666666666666666,999.9,%MW7,STN,
PS_STN_HIGH_LEVEL_ALARM,High level alarm,PS_SETPOINTS,FC03/FC06,1032,Signed 16-bit,Read/Write,%,value / 60,0.016666666666666666,999.9,%MW8,STN,
PS_STN_MINIMUM_DRIVE_SPEED,Minimum drive speed,PS_SETPOINTS,FC03/FC06,1033,Signed 16-bit,Read/Write,%,value * 0.2,0.2,999.9,%MW9,STN,
PS_STN_SERVICE_INTERVAL,Service interval,PS_SETPOINTS,FC03/FC06,1034,Signed 16-bit,Read/Write,h,value,1.0,99999,%MW10,STN,
PS_SIM_MANUAL_INFLOW,manual inflow (mode 0),PS_SIM_CONTROL,FC03/FC06,1044,Signed 16-bit,Read/Write,m3/h,value * 0.36,0.36,9999.9,%MW20,SIM,SIMULATION CONTROL - simulation build only
PS_SIM_SCENARIO_0_MAN_1_DIURNAL_2_W,scenario 0=man 1=diurnal 2=wet 3=ref,PS_SIM_CONTROL,FC03/FC06,1045,Signed 16-bit,Read/Write,,value,1.0,9,%MW21,SIM,SIMULATION CONTROL - simulation build only
PS_SIM_WRITE_1_TO_RESET_SCENARIO,"write 1 to reset scenario, self-clearing",PS_SIM_CONTROL,FC03/FC06,1046,Signed 16-bit,Read/Write,,value,1.0,9,%MW22,SIM,SIMULATION CONTROL - simulation build only
PS_SIM_TIME_SCALE_1_120,time scale 1-120,PS_SIM_CONTROL,FC03/FC06,1047,Signed 16-bit,Read/Write,x,value,1.0,999,%MW23,SIM,SIMULATION CONTROL - simulation build only
1 scada_tag description poll_group function_code modbus_address data_type access eng_units raw_to_eng eng_gain format_mask iec_address plc_tag notes
2 PS_PU301_RUN_COMMAND Run command PS_STATUS_BITS FC01 0 Boolean Read value 1.0 99999 %QX0.0 PU-301
3 PS_PU302_RUN_COMMAND Run command PS_STATUS_BITS FC01 1 Boolean Read value 1.0 99999 %QX0.1 PU-302
4 PS_PU303_RUN_COMMAND Run command PS_STATUS_BITS FC01 2 Boolean Read value 1.0 99999 %QX0.2 PU-303
5 PS_PU301_RUNNING Running PS_STATUS_BITS FC01 3 Boolean Read value 1.0 99999 %QX0.3 PU-301
6 PS_PU302_RUNNING Running PS_STATUS_BITS FC01 4 Boolean Read value 1.0 99999 %QX0.4 PU-302
7 PS_PU303_RUNNING Running PS_STATUS_BITS FC01 5 Boolean Read value 1.0 99999 %QX0.5 PU-303
8 PS_PU301_AVAILABLE Available PS_STATUS_BITS FC01 6 Boolean Read value 1.0 99999 %QX0.6 PU-301
9 PS_PU302_AVAILABLE Available PS_STATUS_BITS FC01 7 Boolean Read value 1.0 99999 %QX0.7 PU-302
10 PS_PU303_AVAILABLE Available PS_STATUS_BITS FC01 8 Boolean Read value 1.0 99999 %QX1.0 PU-303
11 PS_STN_STATION_IN_AUTO Station in auto PS_STATUS_BITS FC01 9 Boolean Read value 1.0 99999 %QX1.1 STN
12 PS_STN_HIGH_LEVEL_ALARM High level alarm PS_STATUS_BITS FC01 10 Boolean Read value 1.0 99999 %QX1.2 STN
13 PS_STN_SPILL_ACTIVE Spill active PS_STATUS_BITS FC01 11 Boolean Read value 1.0 99999 %QX1.3 STN
14 PS_PU301_TRIPPED Tripped PS_STATUS_BITS FC01 12 Boolean Read value 1.0 99999 %QX1.4 PU-301
15 PS_PU302_TRIPPED Tripped PS_STATUS_BITS FC01 13 Boolean Read value 1.0 99999 %QX1.5 PU-302
16 PS_PU303_TRIPPED Tripped PS_STATUS_BITS FC01 14 Boolean Read value 1.0 99999 %QX1.6 PU-303
17 PS_STN_WET_WELL_LEVEL Wet well level PS_PUBLISHED FC03 0 Signed 16-bit Read % value / 60 0.016666666666666666 999.9 %QW0 STN
18 PS_STN_INFLOW Inflow PS_PUBLISHED FC03 1 Signed 16-bit Read m3/h value * 0.36 0.36 9999.9 %QW1 STN
19 PS_STN_TOTAL_DISCHARGE_FLOW Total discharge flow PS_PUBLISHED FC03 2 Signed 16-bit Read m3/h value * 0.36 0.36 9999.9 %QW2 STN
20 PS_STN_PUMPS_RUNNING Pumps running PS_PUBLISHED FC03 3 Signed 16-bit Read count value 1.0 9 %QW3 STN
21 PS_STN_COMMON_DRIVE_SPEED Common drive speed PS_PUBLISHED FC03 4 Signed 16-bit Read % value * 0.2 0.2 999.9 %QW4 STN
22 PS_STN_TIME_TO_SPILL_WEIR Time to spill weir (32767 = drawing down) PS_PUBLISHED FC03 5 Signed 16-bit Read s value 1.0 99999 %QW5 STN
23 PS_STN_TIME_TO_LSHH Time to LSHH (32767 = drawing down) PS_PUBLISHED FC03 6 Signed 16-bit Read s value 1.0 99999 %QW6 STN
24 PS_STN_NET_ACCUMULATION Net accumulation (signed) PS_PUBLISHED FC03 7 Signed 16-bit Read m3/h value * 0.36 0.36 9999.9 %QW7 STN
25 PS_PU301_RUN_HOURS Run hours PS_PUBLISHED FC03 8 Signed 16-bit Read h value 1.0 99999 %QW8 PU-301
26 PS_PU302_RUN_HOURS Run hours PS_PUBLISHED FC03 9 Signed 16-bit Read h value 1.0 99999 %QW9 PU-302
27 PS_PU303_RUN_HOURS Run hours PS_PUBLISHED FC03 10 Signed 16-bit Read h value 1.0 99999 %QW10 PU-303
28 PS_STN_VOLUME_REMAINING_TO_SPILL Volume remaining to spill PS_PUBLISHED FC03 11 Signed 16-bit Read m3 value 1.0 9999 %QW11 STN
29 PS_STN_STATION_STATE Station state (enum 3.1) PS_PUBLISHED FC03 12 Signed 16-bit Read value 1.0 9 %QW12 STN
30 PS_PU301_PUMP_STATE Pump state (enum 3.2) PS_PUBLISHED FC03 13 Signed 16-bit Read value 1.0 9 %QW13 PU-301
31 PS_PU302_PUMP_STATE Pump state (enum 3.2) PS_PUBLISHED FC03 14 Signed 16-bit Read value 1.0 9 %QW14 PU-302
32 PS_PU303_PUMP_STATE Pump state (enum 3.2) PS_PUBLISHED FC03 15 Signed 16-bit Read value 1.0 9 %QW15 PU-303
33 PS_STN_CURRENT_DUTY_PUMP Current duty pump (0 = none, 1-3) PS_PUBLISHED FC03 16 Signed 16-bit Read value 1.0 9 %QW16 STN
34 PS_STN_ALARM_BITMASK Alarm bitmask (section 6) - READ AS UNSIGNED PS_PUBLISHED FC03 17 Unsigned 16-bit Read value 1.0 99999 %QW17 STN
35 PS_STN_COMMAND_ACKNOWLEDGE Command acknowledge (echoes %MW1) PS_PUBLISHED FC03 20 Signed 16-bit Read value 1.0 99 %QW20 STN
36 PS_STN_STATION_MODE_1_AUTO Station mode: 1 = auto, 2 = off PS_SETPOINTS FC03/FC06 1024 Signed 16-bit Read/Write value 1.0 9 %MW0 STN
37 PS_STN_COMMAND_WORD Command word (section 3.3) PS_SETPOINTS FC03/FC06 1025 Signed 16-bit Read/Write value 1.0 99 %MW1 STN
38 PS_STN_COMMAND_PARAMETER Command parameter (pump number) PS_SETPOINTS FC03/FC06 1026 Signed 16-bit Read/Write value 1.0 9 %MW2 STN
39 PS_STN_LEVEL_CONTROL_SETPOINT Level control setpoint PS_SETPOINTS FC03/FC06 1027 Signed 16-bit Read/Write % value / 60 0.016666666666666666 999.9 %MW3 STN
40 PS_STN_START_DUTY_LEVEL Start duty level PS_SETPOINTS FC03/FC06 1028 Signed 16-bit Read/Write % value / 60 0.016666666666666666 999.9 %MW4 STN
41 PS_STN_START_PUMP_2_LEVEL Start pump 2 level PS_SETPOINTS FC03/FC06 1029 Signed 16-bit Read/Write % value / 60 0.016666666666666666 999.9 %MW5 STN
42 PS_STN_START_PUMP_3_LEVEL Start pump 3 level PS_SETPOINTS FC03/FC06 1030 Signed 16-bit Read/Write % value / 60 0.016666666666666666 999.9 %MW6 STN
43 PS_STN_STOP_ALL_LEVEL Stop all level PS_SETPOINTS FC03/FC06 1031 Signed 16-bit Read/Write % value / 60 0.016666666666666666 999.9 %MW7 STN
44 PS_STN_HIGH_LEVEL_ALARM High level alarm PS_SETPOINTS FC03/FC06 1032 Signed 16-bit Read/Write % value / 60 0.016666666666666666 999.9 %MW8 STN
45 PS_STN_MINIMUM_DRIVE_SPEED Minimum drive speed PS_SETPOINTS FC03/FC06 1033 Signed 16-bit Read/Write % value * 0.2 0.2 999.9 %MW9 STN
46 PS_STN_SERVICE_INTERVAL Service interval PS_SETPOINTS FC03/FC06 1034 Signed 16-bit Read/Write h value 1.0 99999 %MW10 STN
47 PS_SIM_MANUAL_INFLOW manual inflow (mode 0) PS_SIM_CONTROL FC03/FC06 1044 Signed 16-bit Read/Write m3/h value * 0.36 0.36 9999.9 %MW20 SIM SIMULATION CONTROL - simulation build only
48 PS_SIM_SCENARIO_0_MAN_1_DIURNAL_2_W scenario 0=man 1=diurnal 2=wet 3=ref PS_SIM_CONTROL FC03/FC06 1045 Signed 16-bit Read/Write value 1.0 9 %MW21 SIM SIMULATION CONTROL - simulation build only
49 PS_SIM_WRITE_1_TO_RESET_SCENARIO write 1 to reset scenario, self-clearing PS_SIM_CONTROL FC03/FC06 1046 Signed 16-bit Read/Write value 1.0 9 %MW22 SIM SIMULATION CONTROL - simulation build only
50 PS_SIM_TIME_SCALE_1_120 time scale 1-120 PS_SIM_CONTROL FC03/FC06 1047 Signed 16-bit Read/Write x value 1.0 999 %MW23 SIM SIMULATION CONTROL - simulation build only

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,362 @@
@LANGUAGE
ENGLISH
@VERSION
1.03.00
!==================================================================================================================================
@FIELDS
NAME,STATION,POINT,DESCRIPTION,IO_ADDRESS
EXTERNAL_RELATION,SCAN_TYPE,CONV_TYPE,DELTA_LIMIT,MAX_INSENS,OFFSET
PHYS_LOW,PHYS_HIGH,STEP,INVERS,HAS_SIGN,OVERFL_DET
SWAP_BYTES,SWAP_WORDS,ELEC_LOW,ELEC_HIGH,TMO_NONE,TMO_BOTH
AVE_UPD_INTERVAL,NO_ZERO,BURST_LIMIT,BITS,CHARS,DIGITS
FLOAT_TYPE,TIME_ZONE,TIME_REPRES,WLS_VAL_TYPE
@MODBUS_POINT_DF
"WRPS_PLC:PU301_RUN_CMD","WRPS_PLC","PU301_RUN_CMD","Run command","DO:01",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU302_RUN_CMD","WRPS_PLC","PU302_RUN_CMD","Run command","DO:02",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU303_RUN_CMD","WRPS_PLC","PU303_RUN_CMD","Run command","DO:03",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU301_RUNNING","WRPS_PLC","PU301_RUNNING","Running","DO:04",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU302_RUNNING","WRPS_PLC","PU302_RUNNING","Running","DO:05",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU303_RUNNING","WRPS_PLC","PU303_RUNNING","Running","DO:06",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU301_AVAILABLE","WRPS_PLC","PU301_AVAILABLE","Available","DO:07",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU302_AVAILABLE","WRPS_PLC","PU302_AVAILABLE","Available","DO:08",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU303_AVAILABLE","WRPS_PLC","PU303_AVAILABLE","Available","DO:09",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_IN_AUTO","WRPS_PLC","STN_IN_AUTO","Station in auto","DO:10",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_HIGH_LEVEL","WRPS_PLC","STN_HIGH_LEVEL","High level alarm","DO:11",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_SPILL_ACTIVE","WRPS_PLC","STN_SPILL_ACTIVE","Spill active","DO:12",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU301_TRIPPED","WRPS_PLC","PU301_TRIPPED","Tripped","DO:13",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU302_TRIPPED","WRPS_PLC","PU302_TRIPPED","Tripped","DO:14",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU303_TRIPPED","WRPS_PLC","PU303_TRIPPED","Tripped","DO:15",\
"Input","MOD_SCAN","Digital",0,0,0,\
0,100,1,0,0,0,\
0,0,0,100,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_LEVEL","WRPS_PLC","STN_LEVEL","Wet well level","RO:01",\
"Input","MOD_SCAN","Linear",0,0,0,\
-546,546,1,0,1,0,\
0,0,-32760,32760,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_INFLOW","WRPS_PLC","STN_INFLOW","Inflow","RO:02",\
"Input","MOD_SCAN","Linear",0,0,0,\
-11796.48,11796.12,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_DISCHARGE","WRPS_PLC","STN_DISCHARGE","Total discharge flow","RO:03",\
"Input","MOD_SCAN","Linear",0,0,0,\
-11796.48,11796.12,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_PUMPS_RUNNING","WRPS_PLC","STN_PUMPS_RUNNING","Pumps running","RO:04",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_SPEED","WRPS_PLC","STN_SPEED","Common drive speed","RO:05",\
"Input","MOD_SCAN","Linear",0,0,0,\
-6553,6553,1,0,1,0,\
0,0,-32765,32765,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_TIME_TO_SPILL","WRPS_PLC","STN_TIME_TO_SPILL","Time to spill weir (32767 = drawing down)","RO:06",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_TIME_TO_LSHH","WRPS_PLC","STN_TIME_TO_LSHH","Time to LSHH (32767 = drawing down)","RO:07",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_NET_ACCUM","WRPS_PLC","STN_NET_ACCUM","Net accumulation (signed)","RO:08",\
"Input","MOD_SCAN","Linear",0,0,0,\
-11796.48,11796.12,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU301_RUN_HOURS","WRPS_PLC","PU301_RUN_HOURS","Run hours","RO:09",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU302_RUN_HOURS","WRPS_PLC","PU302_RUN_HOURS","Run hours","RO:10",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU303_RUN_HOURS","WRPS_PLC","PU303_RUN_HOURS","Run hours","RO:11",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_VOL_TO_SPILL","WRPS_PLC","STN_VOL_TO_SPILL","Volume remaining to spill","RO:12",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_STATE","WRPS_PLC","STN_STATE","Station state (enum 3.1)","RO:13",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU301_STATE","WRPS_PLC","PU301_STATE","Pump state (enum 3.2)","RO:14",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU302_STATE","WRPS_PLC","PU302_STATE","Pump state (enum 3.2)","RO:15",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:PU303_STATE","WRPS_PLC","PU303_STATE","Pump state (enum 3.2)","RO:16",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_DUTY_PUMP","WRPS_PLC","STN_DUTY_PUMP","Current duty pump (0 = none, 1-3)","RO:17",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_ALARM_WORD","WRPS_PLC","STN_ALARM_WORD","Alarm bitmask (section 6) - READ AS UNSIGNED","RO:18",\
"Input","MOD_SCAN","Linear",0,0,0,\
0,65535,1,0,0,0,\
0,0,0,65535,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:STN_CMD_ACK","WRPS_PLC","STN_CMD_ACK","Command acknowledge (echoes %MW1)","RO:21",\
"Input","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_MODE","WRPS_PLC","SP_MODE","Station mode: 1 = auto, 2 = off","RO:1025",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_CMD_WORD","WRPS_PLC","SP_CMD_WORD","Command word (section 3.3)","RO:1026",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_CMD_PARAM","WRPS_PLC","SP_CMD_PARAM","Command parameter (pump number)","RO:1027",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_LEVEL_SP","WRPS_PLC","SP_LEVEL_SP","Level control setpoint","RO:1028",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-546,546,1,0,1,0,\
0,0,-32760,32760,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_START_DUTY","WRPS_PLC","SP_START_DUTY","Start duty level","RO:1029",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-546,546,1,0,1,0,\
0,0,-32760,32760,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_START_P2","WRPS_PLC","SP_START_P2","Start pump 2 level","RO:1030",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-546,546,1,0,1,0,\
0,0,-32760,32760,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_START_P3","WRPS_PLC","SP_START_P3","Start pump 3 level","RO:1031",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-546,546,1,0,1,0,\
0,0,-32760,32760,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_STOP_ALL","WRPS_PLC","SP_STOP_ALL","Stop all level","RO:1032",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-546,546,1,0,1,0,\
0,0,-32760,32760,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_HIGH_ALARM","WRPS_PLC","SP_HIGH_ALARM","High level alarm","RO:1033",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-546,546,1,0,1,0,\
0,0,-32760,32760,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_MIN_SPEED","WRPS_PLC","SP_MIN_SPEED","Minimum drive speed","RO:1034",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-6553,6553,1,0,1,0,\
0,0,-32765,32765,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SP_SERVICE_HRS","WRPS_PLC","SP_SERVICE_HRS","Service interval","RO:1035",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SIM_INFLOW","WRPS_PLC","SIM_INFLOW","manual inflow (mode 0)","RO:1045",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-11796.48,11796.12,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SIM_SCENARIO","WRPS_PLC","SIM_SCENARIO","scenario 0=man 1=diurnal 2=wet 3=ref","RO:1046",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SIM_RESET","WRPS_PLC","SIM_RESET","write 1 to reset scenario, self-clearing","RO:1047",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"
"WRPS_PLC:SIM_TIME_SCALE","WRPS_PLC","SIM_TIME_SCALE","time scale 1-120","RO:1048",\
"Input + Output","MOD_SCAN","Linear",0,0,0,\
-32768,32767,1,0,1,0,\
0,0,-32768,32767,0,0,\
0,0,0,16,16,4,\
"Intel","Date+time GMT","7 bytes IEC","Float value"

View file

@ -0,0 +1,40 @@
@LANGUAGE
ENGLISH
@VERSION
1.03.00
!==================================================================================================================================
@FIELDS
NAME,SECTION_PATH,SECTION_NAME,BLOCKED,PARENT_BLOCKED
ALARM_INHIBIT,PARENT_ALARM_INHIBIT,OPC_VISIBLE,PARENT_OPC_VISIBLE,NSID,PARENT_NSID
DESCRIPTION,CREATED_BY
@SECTION_DF
"AID.WRPS.STN","AID.WRPS","STN",0,0,\
0,0,0,0,171,170,\
"Waterloo Road PS - station wide measurements and status","unknown"
"AID.WRPS.PU301","AID.WRPS","PU301",0,0,\
0,0,0,0,172,170,\
"Pump PU-301","unknown"
"AID.WRPS.PU302","AID.WRPS","PU302",0,0,\
0,0,0,0,173,170,\
"Pump PU-302","unknown"
"AID.WRPS.PU303","AID.WRPS","PU303",0,0,\
0,0,0,0,174,170,\
"Pump PU-303","unknown"
"AID.WRPS.SP","AID.WRPS","SP",0,0,\
0,0,0,0,175,170,\
"Operator setpoints and commands","unknown"
"AID.WRPS.SIM","AID.WRPS","SIM",0,0,\
0,0,0,0,176,170,\
"Simulation control - simulation build only","unknown"