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.
|
||
|---|---|---|
| .. | ||
| historian | ||
| ci-server-points.csv | ||
| gen_ciserver_qli.py | ||
| gen_scada_points.py | ||
| README.md | ||
| wrps_item_df.qli | ||
| wrps_modbus_point_df.qli | ||
| wrps_section_df.qli | ||
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 ascada_tagcolumn with names likePS_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 thepoll_groupcolumn —PS_STATUS_BITS,PS_PUBLISHED,PS_SETPOINTS,PS_SIM_CONTROL. Those are live configuration.
ci-server-points.csvis the as-built delivery from the CI Server side.gen_scada_points.pyreproduces it byte-identically, taking the item names from theLEAFtable ingen_ciserver_qli.pyso the CSV and the.qlicannot 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.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_ADDRESSwas 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_DFThe 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.17only, not0.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_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.