wrps-demo-kit/04-scada/modbus_points
Clio Liu 0927323e13 fix(scada): PS_* placeholder tags out, CI Server item names in
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.
2026-09-02 17:21:57 +10:00
..
historian fix(scada): PS_* placeholder tags out, CI Server item names in 2026-09-02 17:21:57 +10:00
ci-server-points.csv fix(scada): PS_* placeholder tags out, CI Server item names in 2026-09-02 17:21:57 +10:00
gen_ciserver_qli.py fix(scada): PS_* placeholder tags out, CI Server item names in 2026-09-02 17:21:57 +10:00
gen_scada_points.py fix(scada): PS_* placeholder tags out, CI Server item names in 2026-09-02 17:21:57 +10:00
README.md fix(scada): PS_* placeholder tags out, CI Server item names in 2026-09-02 17:21:57 +10:00
wrps_item_df.qli feat(scada): CI Server tag database, historian, displays 2026-09-02 17:16:18 +10:00
wrps_modbus_point_df.qli feat(scada): CI Server tag database, historian, displays 2026-09-02 17:16:18 +10:00
wrps_section_df.qli feat(scada): CI Server tag database, historian, displays 2026-09-02 17:16:18 +10:00

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.

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.csvci-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 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-lin00110.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_NUMBERs, 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.