wrps-demo-kit/04-scada/modbus_points/README.md
Clio Liu 5be48b6bbb fix: CI Server on cicore1 is R1.05, not R1.03
Corrected in README.md, CLAUDE.md, 02-environment/README.md and
03-plc/VERSIONS.md.

The @VERSION 1.03.00 header inside the .qli files is the quickload FILE
FORMAT version, not the CI Server release. Left alone.

This also sharpens a caveat that was previously stated wrongly. The
EQP-E-DUP_ITEM rejection - an item import fails if two items share an
IO_ADDRESS - was observed on 2026-08-14 on the DEVELOPMENT machine, which
ran R1.03. Production runs R1.05, where it has never been tested. The
design still works around it by publishing level and speed in % only, and
that workaround costs nothing; but anyone wanting a second unit for a
measurement should try it on R1.05 before assuming they need a second PLC
register to carry it.
2026-09-02 17:34:58 +10:00

261 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
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 `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 on the development machine (R1.03) and CI Server 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.
>
> **Not retested on R1.05**, which is what production runs. The workaround costs
> nothing, but if you need a second unit, try it before assuming you cannot.
**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 | 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.