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.
10 KiB
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.
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 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 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_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 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.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.