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