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.
281 lines
11 KiB
Python
281 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Derive the SCADA-side point list from the PLC-side register map.
|
|
|
|
python gen_scada_points.py
|
|
|
|
Reads ../../03-plc/register-map.csv (PLC side, generated by build.py)
|
|
Writes ./ci-server-points.csv (SCADA side, for CI Server config)
|
|
|
|
CLAUDE.md section 3: the two sides are two views of the same points, and
|
|
the PLC side leads. This script makes the SCADA view reproducible
|
|
instead of hand-transcribed, so the two cannot drift apart silently.
|
|
|
|
It is a *starting point* for CI Server configuration, not a CI Server
|
|
import file. CI Server's .qli item exports carry no Modbus addressing
|
|
(that lives in the front-end I/O configuration), so the mapping is
|
|
applied by hand in CI Server from this list.
|
|
|
|
The four judgements this script encodes
|
|
---------------------------------------
|
|
1. **Poll groups.** Four, not one per function code. The holding
|
|
registers split by purpose and address range: read-only published
|
|
data at 0-20, operator setpoints at 1024-1034, simulation control at
|
|
1044-1047. Each group is then contiguous and polls as one request;
|
|
a single holding-register group would span 1048 mostly-empty
|
|
addresses.
|
|
|
|
2. **%QW17 is UNSIGNED.** Bit 15 does not fit a signed INT. Configure
|
|
it as a 16-bit unsigned register or the alarm word goes negative
|
|
exactly when the most severe alarm is set.
|
|
|
|
3. **Engineering units are chosen here, not in the PLC.** The PLC
|
|
publishes mm, L/s x10 and Hz x10 and keeps doing so; a unit on the
|
|
SCADA side is a gain on the raw register (see UNITS below). One
|
|
unit per register, though - CI Server rejects a second item on an
|
|
I/O address it already has.
|
|
|
|
4. **%IW / %IX are excluded by default.** They are the *field* inputs.
|
|
In the simulation build nothing writes them, so they read 0 forever
|
|
and would show a dead plant on the HMI. Every live measurement is
|
|
published in the %QW block. Pass --include-field to emit them
|
|
anyway, for a real field deployment.
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SRC_MAP = ROOT / "03-plc" / "register-map.csv"
|
|
OUT = Path(__file__).resolve().parent / "ci-server-points.csv"
|
|
|
|
# Modbus object -> (function code, poll group name)
|
|
#
|
|
# Holding registers are split into three groups rather than one. They
|
|
# share a function code but not a purpose or an address range: %QW is
|
|
# read-only published data at 0-1023, %MW is writable at 1024+. One
|
|
# group spanning both would be a single 1048-register request over a
|
|
# mostly empty address space.
|
|
GROUPS = {
|
|
"Coil": ("FC01", "PS_STATUS_BITS"),
|
|
"Discrete input": ("FC02", "PS_FIELD_BITS"),
|
|
"Input register": ("FC04", "PS_FIELD_ANALOG"),
|
|
}
|
|
|
|
HR_PUBLISHED = ("FC03", "PS_PUBLISHED") # %QW, read-only
|
|
HR_SETPOINTS = ("FC03/FC06", "PS_SETPOINTS") # %MW0-10, operator writable
|
|
HR_SIM = ("FC03/FC06", "PS_SIM_CONTROL") # %MW20-23, simulation only
|
|
|
|
FIELD_ONLY = {"Input register", "Discrete input"}
|
|
UNSIGNED = {"%QW17"}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Engineering units, SCADA side
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# The PLC publishes mm, L/s x10 and Hz x10 - that is the contract in
|
|
# register-map.csv and it does not change. What the operator reads is a
|
|
# presentation choice, and CI Server already applies a linear conversion
|
|
# per Modbus point, so a unit here is nothing but a gain on the raw
|
|
# register:
|
|
#
|
|
# engineering value = raw register * gain (offset is always 0)
|
|
#
|
|
# Zero offset matters: it keeps the full-scale ELEC -> PHYS mapping in
|
|
# gen_ciserver_qli.py exact, and it means a second unit on the same
|
|
# register is just a second point with a different gain.
|
|
#
|
|
# ONE ITEM PER I/O ADDRESS. A register carries exactly one engineering
|
|
# unit, because CI Server will not accept two items on the same Modbus
|
|
# address - an import that tries it fails with
|
|
#
|
|
# EQP-E-DUP_ITEM, I/O address of item already defined
|
|
# DSSP-E-INSREC, Failed to insert a record in the dataset ITEM_DF
|
|
#
|
|
# (confirmed on R1.03, 2026-08-14, trying to publish level in m *and* %).
|
|
# So showing one measurement in two units needs two *PLC* registers, not
|
|
# two views of one. Level and speed are published in % alone.
|
|
#
|
|
# Gains chosen 2026-08-14:
|
|
# level % 100% = the 6.000 m spill weir, so a full bar means spilling
|
|
# and the reading reconciles with VOL_TO_SPILL. 1/60 per mm.
|
|
# flow m3/h = L/s x 3.6, and the register is L/s x10: 0.36.
|
|
# speed % 100% = 50 Hz, the drive's maximum. Register Hz x10: 0.2.
|
|
#
|
|
# iec address -> (units, gain, format mask)
|
|
UNITS = {
|
|
"%QW0": ("%", 1 / 60.0, "999.9"), # wet well level, was mm
|
|
"%QW1": ("m3/h", 0.36, "9999.9"), # inflow, was L/s
|
|
"%QW2": ("m3/h", 0.36, "9999.9"), # total discharge, was L/s
|
|
"%QW4": ("%", 0.2, "999.9"), # common drive speed, was Hz
|
|
"%QW7": ("m3/h", 0.36, "9999.9"), # net accumulation, signed
|
|
"%MW3": ("%", 1 / 60.0, "999.9"), # level setpoint, was mm
|
|
"%MW4": ("%", 1 / 60.0, "999.9"), # start duty
|
|
"%MW5": ("%", 1 / 60.0, "999.9"), # start pump 2
|
|
"%MW6": ("%", 1 / 60.0, "999.9"), # start pump 3
|
|
"%MW7": ("%", 1 / 60.0, "999.9"), # stop all
|
|
"%MW8": ("%", 1 / 60.0, "999.9"), # high level alarm
|
|
"%MW9": ("%", 0.2, "999.9"), # minimum drive speed, was Hz
|
|
"%MW20": ("m3/h", 0.36, "9999.9"), # SIM manual inflow, was L/s
|
|
}
|
|
|
|
# Points that keep their PLC unit but not the default mask. Enums, modes
|
|
# and small counts are one or two digits wide; a five-digit mask on them
|
|
# is not wrong, only wide, and it would disagree with the HMI's masks in
|
|
# 04-scada/hmi/point_format.py. iec address -> mask
|
|
MASK_ONLY = {
|
|
"%QW3": "9", # pumps running, 0-3
|
|
"%QW11": "9999", # volume remaining to spill, m3
|
|
"%QW12": "9", # station state enum
|
|
"%QW13": "9", "%QW14": "9", "%QW15": "9", # pump state enums
|
|
"%QW16": "9", # duty pump, 0-3
|
|
"%QW20": "99", # command acknowledge
|
|
"%MW0": "9", # station mode
|
|
"%MW1": "99", # command word
|
|
"%MW2": "9", # command parameter, pump number
|
|
"%MW21": "9", # SIM scenario 0-3
|
|
"%MW22": "9", # SIM reset, write 1
|
|
"%MW23": "999", # SIM time scale 1-120
|
|
}
|
|
|
|
|
|
def eng_view(iec, scaling):
|
|
"""(units override, gain, mask override) for a register.
|
|
|
|
Falls back to the PLC's own unit and the raw scaling when the point
|
|
is not in UNITS - most points are already in the unit the operator
|
|
wants, and inventing a conversion for them would only add risk.
|
|
"""
|
|
if iec in UNITS:
|
|
return UNITS[iec]
|
|
gain = 0.1 if scaling == "x10" else 1.0
|
|
default = "9999.9" if scaling == "x10" else "99999"
|
|
return None, gain, MASK_ONLY.get(iec, default)
|
|
|
|
|
|
def gain_expr(gain):
|
|
"""The gain as the human-readable expression the CSV carries."""
|
|
if gain == 1.0:
|
|
return "value"
|
|
if gain == 0.1:
|
|
return "value / 10"
|
|
if abs(gain - 1 / 60.0) < 1e-12:
|
|
return "value / 60"
|
|
return "value * %g" % gain
|
|
|
|
|
|
# The CI Server naming, imported from the .qli generator so the two files
|
|
# cannot disagree. LEAF is keyed on IEC address and is the single place
|
|
# section/leaf names are defined.
|
|
#
|
|
# An earlier version of this script emitted a `scada_tag` column holding
|
|
# 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 below, which are
|
|
# live configuration.
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from gen_ciserver_qli import LEAF, ROOT, STATION
|
|
|
|
|
|
def ci_names(iec):
|
|
"""(item, station, point) - the three CI Server namespaces.
|
|
|
|
AID.WRPS.STN.LEVEL item what the historian is keyed on
|
|
WRPS_PLC station the Modbus station
|
|
STN_LEVEL point the Modbus point on that station
|
|
"""
|
|
if iec not in LEAF:
|
|
sys.exit(f"no leaf name defined for {iec} - add it to LEAF in gen_ciserver_qli.py")
|
|
section, leaf = LEAF[iec]
|
|
return (f"{ROOT}.{section}.{leaf}", STATION, ("%s_%s" % (section, leaf))[:24])
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Derive the SCADA point list.")
|
|
ap.add_argument(
|
|
"--include-field",
|
|
action="store_true",
|
|
help="also emit %%IW/%%IX field inputs (they read 0 in the simulation build)",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
if not SRC_MAP.is_file():
|
|
sys.exit(f"missing {SRC_MAP} - run 04-plc/wrps-plc/build.py first")
|
|
|
|
rows = list(csv.DictReader(open(SRC_MAP, newline="", encoding="utf-8")))
|
|
out_rows, skipped = [], 0
|
|
|
|
for r in rows:
|
|
obj = r["modbus_object"]
|
|
if obj in FIELD_ONLY and not args.include_field:
|
|
skipped += 1
|
|
continue
|
|
|
|
iec = r["iec_address"]
|
|
sim_only = r["description"].startswith("SIM ONLY")
|
|
writable = r["access"] == "RW"
|
|
description = r["description"].replace("SIM ONLY: ", "")
|
|
|
|
if obj == "Holding register":
|
|
if sim_only:
|
|
fc, group = HR_SIM
|
|
elif writable:
|
|
fc, group = HR_SETPOINTS
|
|
else:
|
|
fc, group = HR_PUBLISHED
|
|
else:
|
|
fc, group = GROUPS[obj]
|
|
|
|
if obj in ("Coil", "Discrete input"):
|
|
data_type = "Boolean"
|
|
elif iec in UNSIGNED:
|
|
data_type = "Unsigned 16-bit"
|
|
else:
|
|
data_type = "Signed 16-bit"
|
|
|
|
units_override, gain, mask = eng_view(iec, r["scaling"])
|
|
|
|
out_rows.append(
|
|
{
|
|
"ci_item": ci_names(iec)[0],
|
|
"ci_station": ci_names(iec)[1],
|
|
"ci_point": ci_names(iec)[2],
|
|
"description": description,
|
|
"poll_group": group,
|
|
"function_code": fc,
|
|
"modbus_address": r["modbus_address"],
|
|
"data_type": data_type,
|
|
"access": "Read/Write" if writable else "Read",
|
|
"eng_units": units_override if units_override is not None else r["units"],
|
|
"raw_to_eng": gain_expr(gain),
|
|
"eng_gain": repr(gain),
|
|
"format_mask": mask,
|
|
"iec_address": iec,
|
|
"plc_tag": r["tag"],
|
|
"notes": "SIMULATION CONTROL - simulation build only" if sim_only else "",
|
|
}
|
|
)
|
|
|
|
with open(OUT, "w", newline="", encoding="utf-8") as fh:
|
|
w = csv.DictWriter(fh, fieldnames=list(out_rows[0].keys()))
|
|
w.writeheader()
|
|
w.writerows(out_rows)
|
|
|
|
groups = {}
|
|
for r in out_rows:
|
|
groups.setdefault(r["poll_group"], []).append(int(r["modbus_address"]))
|
|
|
|
print(f"OK {OUT} ({len(out_rows)} points)")
|
|
for g, addrs in groups.items():
|
|
print(f" {g:<18} {len(addrs):>3} points, addresses {min(addrs)}-{max(addrs)}")
|
|
if skipped:
|
|
print(
|
|
f" skipped {skipped} %IW/%IX field points - they read 0 in the\n"
|
|
f" simulation build; pass --include-field for a real field deployment"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|