scada-points.csv led with a scada_tag column of PS_* names derived from
the PLC register map - PS_STN_WET_WELL_LEVEL and the like. CI Server never
adopted them. They named nothing that exists, nothing in this repo read
them, and read as if authoritative they have already caused a real defect
in a downstream project.
Replaced with ci-server-points.csv, the as-built delivery from the CI
Server side (copied from yau-plant-assistant, not re-derived). It is keyed
on the CI Server item, with the station and point name beside it:
ci_item,ci_station,ci_point,description,poll_group,...
gen_scada_points.py now reproduces that file byte-identically, taking the
names from the LEAF table in gen_ciserver_qli.py so the CSV and the .qli
cannot disagree. tag_for() is gone. Verified: the CSV and all three .qli
regenerate unchanged, and all six displays still build.
The four poll groups keep their PS_ names - PS_STATUS_BITS, PS_PUBLISHED,
PS_SETPOINTS, PS_SIM_CONTROL. Those are live configuration, and are the
only legitimate PS_ names.
modbus_points/README.md gains a 'four namespaces' section - instrument tag,
PLC address, CI Server point, CI Server item - since confusing them is what
produced the placeholder column in the first place.
Also settles the historian drift left open in the previous commit. The
live export is the authority: the server runs WRPS_ONE_SEC at 5 SECONDS,
not 1, and WRPS_THIRTY_SEC at 30 seconds rather than a 60 second
WRPS_ONE_MIN. Code assuming 60 s was wrong by twelvefold. Recorded in
historian/README.md with a caution never to hardcode a sample interval.
Every .qli in the delivery was byte-identical to ours - only the CSV
differed.
257 lines
12 KiB
Markdown
257 lines
12 KiB
Markdown
# 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** | `ci-server-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`.
|
||
|
||
## Four namespaces, and which one is which
|
||
|
||
Getting these confused is the single most common mistake in this folder.
|
||
|
||
```
|
||
LIT-101 instrument tag the field device
|
||
%QW0 PLC symbol/address the register the PLC writes
|
||
WRPS_PLC:STN_LEVEL CI Server point Modbus station : point name
|
||
AID.WRPS.STN.LEVEL CI Server item what displays bind to, and what
|
||
the historian is keyed on
|
||
```
|
||
|
||
`ci-server-points.csv` carries all four, one row per point: `ci_item`,
|
||
`ci_station`, `ci_point`, `iec_address` and `plc_tag`.
|
||
|
||
**The historian is keyed on the item, and only the item.** Modbus carries register
|
||
numbers, not names, so the point layer and the item layer are free to differ — and
|
||
they do.
|
||
|
||
> [!WARNING]
|
||
> **`PS_*` is not one of these namespaces.**
|
||
> An earlier version of this file carried a `scada_tag` column with names like
|
||
> `PS_STN_WET_WELL_LEVEL`, derived from the PLC register map. **CI Server never
|
||
> adopted them.** They named nothing that exists, and they caused a real defect
|
||
> downstream when read as if they did.
|
||
>
|
||
> The **only** legitimate `PS_` names are the four Modbus **poll groups** in the
|
||
> `poll_group` column — `PS_STATUS_BITS`, `PS_PUBLISHED`, `PS_SETPOINTS`,
|
||
> `PS_SIM_CONTROL`. Those are live configuration.
|
||
>
|
||
> `ci-server-points.csv` is the as-built delivery from the CI Server side.
|
||
> `gen_scada_points.py` reproduces it byte-identically, taking the item names from
|
||
> the `LEAF` table in `gen_ciserver_qli.py` so the CSV and the `.qli` cannot
|
||
> disagree.
|
||
|
||
```bash
|
||
python gen_scada_points.py # register-map.csv -> ci-server-points.csv
|
||
python gen_ciserver_qli.py # ci-server-points.csv -> the two .qli files
|
||
```
|
||
|
||
| File | What |
|
||
|---|---|
|
||
| `ci-server-points.csv` | The point list, keyed on the **CI Server item** each point feeds |
|
||
| `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` → `ci-server-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
|
||
3–166 and sections 1–170, so 171 is the first free id:
|
||
|
||
| | NSIDs |
|
||
|---|---|
|
||
| Sections `STN`, `PU301`, `PU302`, `PU303`, `SP`, `SIM` | 171–176 |
|
||
| The 49 items | 177–225 |
|
||
|
||
`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 `ci-server-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 `ci-server-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 | 0–14 | 15 | Run commands, running, available, in-auto, alarms, trips |
|
||
| `PS_PUBLISHED` | FC03 | 0–20 | 19 | Every live measurement and station/pump state |
|
||
| `PS_SETPOINTS` | FC03/06 | 1024–1034 | 11 | Mode, command word/param, level setpoints, limits |
|
||
| `PS_SIM_CONTROL` | FC03/06 | 1044–1047 | 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 0–1023 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, 30–60 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.
|