#!/usr/bin/env python3 """Emit CI Server .qli imports for the WRPS points. python gen_scada_points.py # first: refresh ci-server-points.csv python gen_ciserver_qli.py Writes, alongside this script: wrps_section_df.qli @SECTION_DF - the AID.WRPS.* sections wrps_modbus_point_df.qli @MODBUS_POINT_DF - the Modbus point definitions wrps_item_df.qli @ITEM_DF - the items in those sections Import them 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 in it. The chain is ST sources -> 03-plc/register-map.csv -> ci-server-points.csv -> these files, so a register change propagates by re-running the three scripts rather than by hand-editing anything. Field layouts and every constant here were copied from the user's own exports in 99-reference/ciserver-qli-exports/ (section_df.qli, modbus_point_df.qli, item_df.qli), not invented. NSIDs ----- The NSID space is shared by sections and items. In the reference exports items occupy 3-166 and sections 1-170, so 171 is the first free id: sections take 171-176 and items 177 onward (--nsid-base). AID (169) and AID.WRPS (170) already exist, so this script emits only their six children and hangs them off 170 (--parent-nsid). Pass --include-root to emit AID and AID.WRPS as well, for a system that does not have them yet. THE ONE THING TO VERIFY ON A SMALL TEST IMPORT ---------------------------------------------- **Address base** (--address-base, default 1). The reference export maps point AI_01 to IO_ADDRESS "RI:01". Whether CI Server's 01 means protocol address 0 or 1 cannot be told from the file. Default here is 1-based, i.e. IO_ADDRESS = modbus address + 1, so holding register 0 (%QW0, wet well level) becomes "RO:01". If the level shows up where the inflow should be, it is 0-based: re-run with --address-base 0. """ import argparse import csv import sys from pathlib import Path HERE = Path(__file__).resolve().parent POINTS = HERE / "ci-server-points.csv" STATION = "WRPS_PLC" ROOT = "AID.WRPS" # IO_ADDRESS prefixes, from the reference export: # RI = register input (FC04) RO = register output (FC03/06) # DI = digital input (FC02) DO = digital output (FC01) PREFIX = {"FC01": "DO", "FC02": "DI", "FC03": "RO", "FC03/FC06": "RO", "FC04": "RI"} # Section per poll group; PS_PUBLISHED and PS_STATUS_BITS are split by # equipment so each pump gets its own folder. GROUP_SECTION = {"PS_SETPOINTS": "SP", "PS_SIM_CONTROL": "SIM"} SECTION_ORDER = ["STN", "PU301", "PU302", "PU303", "SP", "SIM"] SECTION_DESC = { "STN": "Waterloo Road PS - station wide measurements and status", "PU301": "Pump PU-301", "PU302": "Pump PU-302", "PU303": "Pump PU-303", "SP": "Operator setpoints and commands", "SIM": "Simulation control - simulation build only", } SECTION_FIELDS = [ "NAME", "SECTION_PATH", "SECTION_NAME", "BLOCKED", "PARENT_BLOCKED", "ALARM_INHIBIT", "PARENT_ALARM_INHIBIT", "OPC_VISIBLE", "PARENT_OPC_VISIBLE", "NSID", "PARENT_NSID", "DESCRIPTION", "CREATED_BY", ] # Short, stable leaf names. Keyed on IEC address so they never drift # with a description edit. LEAF = { "%QX0.0": ("PU301", "RUN_CMD"), "%QX0.1": ("PU302", "RUN_CMD"), "%QX0.2": ("PU303", "RUN_CMD"), "%QX0.3": ("PU301", "RUNNING"), "%QX0.4": ("PU302", "RUNNING"), "%QX0.5": ("PU303", "RUNNING"), "%QX0.6": ("PU301", "AVAILABLE"), "%QX0.7": ("PU302", "AVAILABLE"), "%QX1.0": ("PU303", "AVAILABLE"), "%QX1.1": ("STN", "IN_AUTO"), "%QX1.2": ("STN", "HIGH_LEVEL"), "%QX1.3": ("STN", "SPILL_ACTIVE"), "%QX1.4": ("PU301", "TRIPPED"), "%QX1.5": ("PU302", "TRIPPED"), "%QX1.6": ("PU303", "TRIPPED"), "%QW0": ("STN", "LEVEL"), "%QW1": ("STN", "INFLOW"), "%QW2": ("STN", "DISCHARGE"), "%QW3": ("STN", "PUMPS_RUNNING"), "%QW4": ("STN", "SPEED"), "%QW5": ("STN", "TIME_TO_SPILL"), "%QW6": ("STN", "TIME_TO_LSHH"), "%QW7": ("STN", "NET_ACCUM"), "%QW8": ("PU301", "RUN_HOURS"), "%QW9": ("PU302", "RUN_HOURS"), "%QW10": ("PU303", "RUN_HOURS"), "%QW11": ("STN", "VOL_TO_SPILL"), "%QW12": ("STN", "STATE"), "%QW13": ("PU301", "STATE"), "%QW14": ("PU302", "STATE"), "%QW15": ("PU303", "STATE"), "%QW16": ("STN", "DUTY_PUMP"), "%QW17": ("STN", "ALARM_WORD"), "%QW20": ("STN", "CMD_ACK"), "%MW0": ("SP", "MODE"), "%MW1": ("SP", "CMD_WORD"), "%MW2": ("SP", "CMD_PARAM"), "%MW3": ("SP", "LEVEL_SP"), "%MW4": ("SP", "START_DUTY"), "%MW5": ("SP", "START_P2"), "%MW6": ("SP", "START_P3"), "%MW7": ("SP", "STOP_ALL"), "%MW8": ("SP", "HIGH_ALARM"), "%MW9": ("SP", "MIN_SPEED"), "%MW10": ("SP", "SERVICE_HRS"), "%MW20": ("SIM", "INFLOW"), "%MW21": ("SIM", "SCENARIO"), "%MW22": ("SIM", "RESET"), "%MW23": ("SIM", "TIME_SCALE"), } def q(v): """Quote a .qli value the way the reference exports do.""" return '"%s"' % ("" if v is None else str(v)) def wrap(values): """Split into the reference exports' line shape: 5, then 6 per line. Both reference files wrap this way, in the @FIELDS header and in every record. Reproduced exactly rather than assuming the importer tolerates a different grouping. """ out = [list(values[:5])] rest = list(values[5:]) for i in range(0, len(rest), 6): out.append(rest[i:i + 6]) return out def record(rows): """Format one record, backslash continuations between lines.""" lines = [",".join(str(v) for v in chunk) for chunk in wrap(rows)] return (",\\\n".join(lines)) + "\n" def header(version, fields, tag): lines = ["", "@LANGUAGE", "ENGLISH", "", "", "@VERSION", version, "", "", "!" + "=" * 130, "", "@FIELDS"] lines += [",".join(chunk) for chunk in wrap(fields)] lines += ["", tag] return "\n".join(lines) + "\n" POINT_FIELDS = [ "NAME", "STATION", "POINT", "DESCRIPTION", "IO_ADDRESS", "EXTERNAL_RELATION", "SCAN_TYPE", "CONV_TYPE", "DELTA_LIMIT", "MAX_INSENS", "OFFSET", "PHYS_LOW", "PHYS_HIGH", "STEP", "INVERS", "HAS_SIGN", "OVERFL_DET", "SWAP_BYTES", "SWAP_WORDS", "ELEC_LOW", "ELEC_HIGH", "TMO_NONE", "TMO_BOTH", "AVE_UPD_INTERVAL", "NO_ZERO", "BURST_LIMIT", "BITS", "CHARS", "DIGITS", "FLOAT_TYPE", "TIME_ZONE", "TIME_REPRES", "WLS_VAL_TYPE", ] ITEM_FIELDS = [ "NAME", "NSID", "PARENT_NSID", "SECTION_PATH", "DESCRIPTION", "DEADBAND", "LOW_LIMIT", "HIGH_LIMIT", "LOW_LOW_LIMIT", "HIGH_HIGH_LIMIT", "T_VALUE", "P_VALUE", "I_VALUE", "TREND_UP_LIMIT", "TREND_LOW_LIMIT", "SCALE_HIGH_LIMIT", "SCALE_LOW_LIMIT", "DUMMY2", "COMMENT_1", "COMMENT_2", "VALUE_FORMAT", "POSITIONED", "LONGITUDE", "LATITUDE", "ALARMING", "DIAG", "NAME_IN_ITM_TAB", "STORAGE", "AUDIT_INFO", "FO_ITEM", "BLOCKED", "PARENT_BLOCKED", "ALARM_INHIBIT", "PARENT_ALARM_INHIBIT", "OPC_VISIBLE", "PARENT_OPC_VISIBLE", "OPC_READ", "OPC_WRITE", "OPC_ALARM_DETECTION", "HAS_SUB", "STRING_LENGTH", "DELAY", "REPEAT", "ID_GROUP", "ID_NUMBER", "ITEM_REP", "ITEM_TYPE", "ITEM_SPECIAL", "ACKN_TYPE", "ALARM_GROUP", "FRONT_END_NODE", "DISTR_TYPE", "INSTALL", "UNIT", "TAG", "LIMIT_CLAMP", "OUT_OF_RANGE", "COL_GROUP", "STATION", "POINT", "FO_GROUP", "ITEM_STAT_1", "ITEM_STAT_2", "ITEM_STAT_3", "ITEM_STAT_4", "ITEM_STAT_5", "ITEM_STAT_6", "ALARM_STATE_1", "ALARM_STATE_2", "ALARM_STATE_3", "ALARM_STATE_4", "ALARM_STATE_5", "ALARM_STATE_6", "PRIORITY_1", "PRIORITY_2", "PRIORITY_3", "PRIORITY_4", "PRIORITY_5", "PRIORITY_6", "ALARM_TEXT_1", "ALARM_TEXT_2", "ALARM_TEXT_3", "ALARM_TEXT_4", "ALARM_TEXT_5", "ALARM_TEXT_6", "ALARM_COLOR_1", "ALARM_COLOR_2", "ALARM_COLOR_3", "ALARM_COLOR_4", "ALARM_COLOR_5", "ALARM_COLOR_6", "MNEMONIC_1", "MNEMONIC_2", "MNEMONIC_3", "MNEMONIC_4", "MNEMONIC_5", "MNEMONIC_6", "AOI_1", "AOI_2", "AOI_3", "AOI_4", "AOI_5", "AOI_6", "AOI_7", "AOI_8", "AOI_9", "AOI_10", "AOI_11", "AOI_12", "AOI_13", "AOI_14", "AOI_15", "AOI_16", "ENG_UNIT", "PROCESS_LIST", "POINT_NAME", "OPC_AE_STATION_NAME", "OPC_EVENT_SOURCE", "OPC_EVENT_SOURCE_NAME", "CREATED_BY", "SHELVE_ENABLED_1", "SHELVE_ENABLED_2", "SHELVE_ENABLED_3", "SHELVE_ENABLED_4", "SHELVE_ENABLED_5", "SHELVE_ENABLED_6", "AGG_INTERVAL", ] def build_section(name, parent_path, leaf, nsid, parent_nsid, description): """One @SECTION_DF record, shaped like the reference export. PARENT_OPC_VISIBLE is 1 for a root section and 0 for a child, which is what the reference shows for AID (root) versus AID.WRPS (child). """ return record([ q(name), q(parent_path), q(leaf), 0, 0, 0, 0, 0, 1 if parent_path == "" else 0, nsid, parent_nsid, q(description), q("unknown"), ]) def trimmed_span(gain, signed): """The ELEC range to map from, trimmed so PHYS lands on exact decimals. CI Server stores the conversion as two endpoints, not as a gain, so it recovers the gain by dividing. With a gain of 1/60 the full-scale endpoint 32767/60 = 546.11666... has to be written rounded, and the recovered gain is then slightly off: setpoint 4200 came back as 70.00003333333336 rather than 70. So when the gain is 1/n, trim the span to the nearest multiple of n inside it - 32760 instead of 32767, giving PHYS exactly +/-546 and a gain CI Server recovers exactly. Nothing is lost: the trimmed 7 counts are 0.4 mm of a level register that tops out at 7000. Gains that already terminate (0.1, 0.2, 0.36) keep the full span. """ lo, hi = (-32768, 32767) if signed else (0, 65535) inv = 1.0 / gain n = int(round(inv)) if n > 1 and abs(inv - n) < 1e-9: lo, hi = -((-lo) // n) * n, (hi // n) * n return lo, hi def build_point(row, io_addr): digital = row["data_type"] == "Boolean" writable = row["access"] == "Read/Write" gain = float(row["eng_gain"]) signed = row["data_type"] == "Signed 16-bit" if digital: conv, bits = "Digital", 16 elec_lo, elec_hi, phys_lo, phys_hi = 0, 100, 0, 100 else: conv, bits = "Linear", 16 # Full-scale linear mapping. Exact, and invents no plant range; # display ranges and alarm limits are set per item in CI Server. # The engineering unit is carried entirely by the gain (see UNITS # in gen_scada_points.py), so PHYS is ELEC scaled by it - which is # why the offset must stay zero on both ends. elec_lo, elec_hi = trimmed_span(gain, signed) phys_lo, phys_hi = elec_lo * gain, elec_hi * gain external = "Input + Output" if writable else "Input" scan = "MOD_SCAN" name = "%s:%s" % (STATION, row["point"]) return record([ q(name), q(STATION), q(row["point"]), q(row["description"][:60]), q(io_addr), q(external), q(scan), q(conv), 0, 0, 0, _num(phys_lo), _num(phys_hi), 1, 0, 1 if signed else 0, 0, 0, 0, _num(elec_lo), _num(elec_hi), 0, 0, 0, 0, 0, bits, 16, 4, q("Intel"), q("Date+time GMT"), q("7 bytes IEC"), q("Float value"), ]) def _num(v): """Whole numbers stay whole; fractions keep enough digits to be exact. Four decimals, not one: a gain of 0.001 puts PHYS_HIGH at 32.767, and rounding that to 32.8 would bend the conversion by 0.1%. """ return int(v) if float(v) == int(float(v)) else round(float(v), 4) def build_item(row, nsid, parent_nsid, id_number): digital = row["data_type"] == "Boolean" section = row["section"] path = "%s.%s" % (ROOT, section) name = "%s.%s" % (path, row["leaf"]) item_rep = "Boolean" if digital else "Real" value_format = "" if digital else row["format_mask"] eng_unit = row["eng_units"] f = {k: 0 for k in ITEM_FIELDS} f.update({k: q("") for k in ITEM_FIELDS if k.startswith( ("COMMENT", "ITEM_STAT", "ALARM_TEXT", "ALARM_COLOR", "MNEMONIC", "AOI"))}) f["NAME"] = q(name) f["NSID"] = nsid f["PARENT_NSID"] = parent_nsid f["SECTION_PATH"] = q(path) f["DESCRIPTION"] = q(row["description"][:60]) f["VALUE_FORMAT"] = q(value_format) f["NAME_IN_ITM_TAB"] = 1 f["STRING_LENGTH"] = 1 f["ID_GROUP"] = 1 f["ID_NUMBER"] = id_number f["ITEM_REP"] = q(item_rep) f["ITEM_TYPE"] = q("") f["ITEM_SPECIAL"] = q("") f["ACKN_TYPE"] = q("") f["ALARM_GROUP"] = q("") f["FRONT_END_NODE"] = q("UNLICENCED") f["DISTR_TYPE"] = q("Local Host") f["INSTALL"] = q("WRPS") f["UNIT"] = q(section) f["TAG"] = q(row["leaf"]) f["LIMIT_CLAMP"] = q("") f["COL_GROUP"] = q("") f["STATION"] = q(STATION) f["POINT"] = q(row["point"]) f["FO_GROUP"] = q("") for i in range(1, 7): f["ALARM_STATE_%d" % i] = q("Normal") if digital: f["ITEM_STAT_1"] = q("BOOLEAN 0") f["ITEM_STAT_2"] = q("BOOLEAN 1") f["ENG_UNIT"] = q(eng_unit) f["PROCESS_LIST"] = q("") f["POINT_NAME"] = q("%s:%s" % (STATION, row["point"])) f["OPC_AE_STATION_NAME"] = q("") f["OPC_EVENT_SOURCE"] = q("") f["OPC_EVENT_SOURCE_NAME"] = q(":") f["CREATED_BY"] = q("unknown") for i in range(1, 7): f["SHELVE_ENABLED_%d" % i] = 1 f["AGG_INTERVAL"] = q("") return record([f[k] for k in ITEM_FIELDS]) def main(): ap = argparse.ArgumentParser(description="Emit CI Server .qli imports.") ap.add_argument("--address-base", type=int, choices=(0, 1), default=1, help="IO_ADDRESS numbering: 1 = protocol address + 1 (default)") ap.add_argument("--nsid-base", type=int, default=171, help="first free NSID; sections take six from here, items follow") ap.add_argument("--parent-nsid", type=int, default=170, help="NSID of AID.WRPS, which the six sections hang off") ap.add_argument("--include-root", action="store_true", help="also emit AID and AID.WRPS sections (they already exist here)") ap.add_argument("--section-nsid", action="append", default=[], metavar="SECTION=NSID", help="pin a section's NSID, e.g. STN=41") ap.add_argument("--version", default="1.03.00", help="@VERSION written into both files") args = ap.parse_args() if not POINTS.is_file(): sys.exit("missing ci-server-points.csv - run gen_scada_points.py first") pinned = {} for spec in args.section_nsid: k, _, v = spec.partition("=") pinned[k.strip().upper()] = int(v) rows = list(csv.DictReader(open(POINTS, newline="", encoding="utf-8"))) prepared = [] for r in rows: iec = r["iec_address"] if iec not in LEAF: sys.exit("no leaf name defined for %s - add it to LEAF" % iec) section, leaf = LEAF[iec] r["section"] = section r["leaf"] = leaf r["point"] = ("%s_%s" % (section, leaf))[:24] prepared.append(r) # NSIDs: one per section, then one per item, from --nsid-base. next_id = args.nsid_base section_nsid = {} for s in SECTION_ORDER: if s in pinned: section_nsid[s] = pinned[s] else: section_nsid[s] = next_id next_id += 1 section_out = [header(args.version, SECTION_FIELDS, "@SECTION_DF")] if args.include_root: root, _, wrps_leaf = ROOT.partition(".") section_out.append(build_section(root, "", root, args.parent_nsid - 1, 0, "Waterloo Road Pump Station demo")) section_out.append(build_section(ROOT, root, wrps_leaf, args.parent_nsid, args.parent_nsid - 1, "Waterloo Road Pump Station")) for s in SECTION_ORDER: section_out.append(build_section("%s.%s" % (ROOT, s), ROOT, s, section_nsid[s], args.parent_nsid, SECTION_DESC[s])) point_out = [header(args.version, POINT_FIELDS, "@MODBUS_POINT_DF")] item_out = [header(args.version, ITEM_FIELDS, "@ITEM_DF")] # NSID and ID_NUMBER follow register order, which is also the order the # items were first imported in. Keep it stable: a re-import must update # the existing items in place, not renumber them - the HMI's item ids in # 05-scada/hmi/item-ids.csv are derived from that creation order. for n, r in enumerate(prepared): addr = int(r["modbus_address"]) + args.address_base # Zero-padded to at least two digits, as the reference export does # ("RI:01", not "RI:1"). Wider addresses keep their own width. io_addr = "%s:%02d" % (PREFIX[r["function_code"]], addr) point_out.append(build_point(r, io_addr)) item_out.append(build_item(r, next_id + n, section_nsid[r["section"]], n + 1)) # The header already ends in a newline, so the first record follows it # directly; records are then separated by one blank line, exactly as # the reference exports are. def assemble(parts): return parts[0] + "\n".join(parts[1:]) (HERE / "wrps_section_df.qli").write_text(assemble(section_out), encoding="utf-8") (HERE / "wrps_modbus_point_df.qli").write_text(assemble(point_out), encoding="utf-8") (HERE / "wrps_item_df.qli").write_text(assemble(item_out), encoding="utf-8") n_sections = len(SECTION_ORDER) + (2 if args.include_root else 0) print("OK wrps_section_df.qli (%d sections under %s)" % (n_sections, ROOT)) print("OK wrps_modbus_point_df.qli (%d points, station %s)" % (len(prepared), STATION)) print("OK wrps_item_df.qli (%d items under %s.*)" % (len(prepared), ROOT)) print(" address base : %d (holding register 0 -> RO:%02d)" % (args.address_base, args.address_base)) print(" section NSIDs: " + ", ".join("%s=%d" % (s, section_nsid[s]) for s in SECTION_ORDER)) print(" item NSIDs : %d-%d" % (next_id, next_id + len(prepared) - 1)) by_sec = {} for r in prepared: by_sec.setdefault(r["section"], 0) by_sec[r["section"]] += 1 print(" per section : " + ", ".join("%s=%d" % (s, by_sec.get(s, 0)) for s in SECTION_ORDER)) if __name__ == "__main__": main()