# 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.