#!/usr/bin/env python3 """WRPS-CTL-003 build. Concatenates src/*.st in lexical order into a single flat .st file for import into OpenPLC Editor v4. python build.py --mode field python build.py --mode sim Also emits register-map.csv (the PLC-side point list) from the same source of truth as the ST, so CI Server tag configuration and the PLC cannot drift apart (brief section 9). """ import argparse import csv import datetime import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent SRC = ROOT / "src" BUILD = ROOT / "build" # The register map is the PLC-side point list and the upstream half of # the Modbus contract. The SCADA-side point list is a separate, # differently shaped file kept under 04-scada/modbus/ and derived from # this one - never the other way round. PLC_DIR = ROOT # register-map.csv sits beside this script # Files excluded per mode. Everything else in src/ is concatenated. EXCLUDE = { "field": {"40_prog_simulation.st", "91_config_sim.st"}, "sim": {"90_config_field.st"}, } # Files that must exist for a mode to build. Listed explicitly rather # than globbed so that a missing file fails loudly instead of silently # emitting a short file (brief section 1). REQUIRED = { "field": [ "10_globals.st", "20_fb_pump.st", "21_fb_duty_selector.st", "22_fb_level_control.st", "23_fb_headroom.st", "30_prog_control.st", "50_prog_io_mux.st", "90_config_field.st", ], "sim": [ "10_globals.st", "20_fb_pump.st", "21_fb_duty_selector.st", "22_fb_level_control.st", "23_fb_headroom.st", "30_prog_control.st", "40_prog_simulation.st", "50_prog_io_mux.st", "91_config_sim.st", ], } # Only these files may reference located variables (brief sections 8.1, 10). LOCATED_ALLOWED = {"10_globals.st", "50_prog_io_mux.st"} LOCATED_TOKENS = ("%I", "%Q", "%M") # --------------------------------------------------------------------- # Register map - the single source of truth for section 2. # # IMPORTANT: on OpenPLC Runtime v4 the Modbus slave segments holding # registers. %QW occupies holding registers 0-1023 and %MW starts at # 1024, so %MW0 is holding register 1024. This offset is applied in # modbus_address() below and is the reason this file is generated # rather than hand-maintained. # --------------------------------------------------------------------- MW_HR_OFFSET = 1024 # (iec, tag, description, units, scaling, access) REGISTERS = [ # --- 2.1 field inputs ------------------------------------------- ("%IW0", "LIT-101", "Wet well level", "mm", "1", "R"), ("%IW1", "FIT-201", "Inlet flow", "L/s", "x10", "R"), ("%IW2", "FIT-301", "Discharge flow", "L/s", "x10", "R"), ("%IW3", "PIT-302", "Manifold pressure", "kPa", "1", "R"), ("%IW4", "PIT-311", "PU-301 discharge pressure", "kPa", "1", "R"), ("%IW5", "PIT-321", "PU-302 discharge pressure", "kPa", "1", "R"), ("%IW6", "PIT-331", "PU-303 discharge pressure", "kPa", "1", "R"), ("%IW7", "VE-314", "PU-301 bearing vibration", "mm/s", "x10", "R"), ("%IW8", "VE-324", "PU-302 bearing vibration", "mm/s", "x10", "R"), ("%IW9", "VE-334", "PU-303 bearing vibration", "mm/s", "x10", "R"), ("%IX0.0", "LSHH-102", "High high level (TRUE = wet)", "", "", "R"), ("%IX0.1", "LSLL-103", "Low low level (TRUE = wet, FALSE = dry)", "", "", "R"), ("%IX0.2", "LSH-104", "Spill detected (TRUE = spilling)", "", "", "R"), ("%IX0.3", "TE-312", "PU-301 motor thermal (TRUE = healthy)", "", "", "R"), ("%IX0.4", "TE-322", "PU-302 motor thermal (TRUE = healthy)", "", "", "R"), ("%IX0.5", "TE-332", "PU-303 motor thermal (TRUE = healthy)", "", "", "R"), ("%IX0.6", "MSE-313", "PU-301 seal leak (TRUE = leak)", "", "", "R"), ("%IX0.7", "MSE-323", "PU-302 seal leak (TRUE = leak)", "", "", "R"), ("%IX1.0", "MSE-333", "PU-303 seal leak (TRUE = leak)", "", "", "R"), ("%IX1.1", "XA-502", "Mains healthy (TRUE = healthy)", "", "", "R"), # --- 2.2 PLC outputs -------------------------------------------- ("%QX0.0", "PU-301", "Run command", "", "", "R"), ("%QX0.1", "PU-302", "Run command", "", "", "R"), ("%QX0.2", "PU-303", "Run command", "", "", "R"), ("%QX0.3", "PU-301", "Running", "", "", "R"), ("%QX0.4", "PU-302", "Running", "", "", "R"), ("%QX0.5", "PU-303", "Running", "", "", "R"), ("%QX0.6", "PU-301", "Available", "", "", "R"), ("%QX0.7", "PU-302", "Available", "", "", "R"), ("%QX1.0", "PU-303", "Available", "", "", "R"), ("%QX1.1", "STN", "Station in auto", "", "", "R"), ("%QX1.2", "STN", "High level alarm", "", "", "R"), ("%QX1.3", "STN", "Spill active", "", "", "R"), ("%QX1.4", "PU-301", "Tripped", "", "", "R"), ("%QX1.5", "PU-302", "Tripped", "", "", "R"), ("%QX1.6", "PU-303", "Tripped", "", "", "R"), ("%QW0", "STN", "Wet well level", "mm", "1", "R"), ("%QW1", "STN", "Inflow", "L/s", "x10", "R"), ("%QW2", "STN", "Total discharge flow", "L/s", "x10", "R"), ("%QW3", "STN", "Pumps running", "count", "1", "R"), ("%QW4", "STN", "Common drive speed", "Hz", "x10", "R"), ("%QW5", "STN", "Time to spill weir (32767 = drawing down)", "s", "1", "R"), ("%QW6", "STN", "Time to LSHH (32767 = drawing down)", "s", "1", "R"), ("%QW7", "STN", "Net accumulation (signed)", "L/s", "x10", "R"), ("%QW8", "PU-301", "Run hours", "h", "1", "R"), ("%QW9", "PU-302", "Run hours", "h", "1", "R"), ("%QW10", "PU-303", "Run hours", "h", "1", "R"), ("%QW11", "STN", "Volume remaining to spill", "m3", "1", "R"), ("%QW12", "STN", "Station state (enum 3.1)", "", "1", "R"), ("%QW13", "PU-301", "Pump state (enum 3.2)", "", "1", "R"), ("%QW14", "PU-302", "Pump state (enum 3.2)", "", "1", "R"), ("%QW15", "PU-303", "Pump state (enum 3.2)", "", "1", "R"), ("%QW16", "STN", "Current duty pump (0 = none, 1-3)", "", "1", "R"), ("%QW17", "STN", "Alarm bitmask (section 6) - READ AS UNSIGNED", "", "1", "R"), ("%QW20", "STN", "Command acknowledge (echoes %MW1)", "", "1", "R"), # --- 2.3 commands and setpoints --------------------------------- ("%MW0", "STN", "Station mode: 1 = auto, 2 = off", "", "1", "RW"), ("%MW1", "STN", "Command word (section 3.3)", "", "1", "RW"), ("%MW2", "STN", "Command parameter (pump number)", "", "1", "RW"), ("%MW3", "STN", "Level control setpoint", "mm", "1", "RW"), ("%MW4", "STN", "Start duty level", "mm", "1", "RW"), ("%MW5", "STN", "Start pump 2 level", "mm", "1", "RW"), ("%MW6", "STN", "Start pump 3 level", "mm", "1", "RW"), ("%MW7", "STN", "Stop all level", "mm", "1", "RW"), ("%MW8", "STN", "High level alarm", "mm", "1", "RW"), ("%MW9", "STN", "Minimum drive speed", "Hz", "x10", "RW"), ("%MW10", "STN", "Service interval", "h", "1", "RW"), # --- 8.2 simulation control (simulation build only) -------------- ("%MW20", "SIM", "SIM ONLY: manual inflow (mode 0)", "L/s", "x10", "RW"), ("%MW21", "SIM", "SIM ONLY: scenario 0=man 1=diurnal 2=wet 3=ref", "", "1", "RW"), ("%MW22", "SIM", "SIM ONLY: write 1 to reset scenario, self-clearing", "", "1", "RW"), ("%MW23", "SIM", "SIM ONLY: time scale 1-120", "x", "1", "RW"), ] def modbus_address(iec): """Map an IEC located address to (object type, Modbus address). Reflects the v4 simple_modbus segmented data blocks, not v3. """ body = iec[2:] kind = iec[:2] if kind == "%I": if body[0] == "W": return "Input register", int(body[1:]) byte, bit = body[1:].split(".") return "Discrete input", int(byte) * 8 + int(bit) if kind == "%Q": if body[0] == "W": return "Holding register", int(body[1:]) byte, bit = body[1:].split(".") return "Coil", int(byte) * 8 + int(bit) if kind == "%M": return "Holding register", MW_HR_OFFSET + int(body[1:]) raise ValueError(f"unrecognised located address: {iec}") def git_hash(): try: out = subprocess.run( ["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, check=True, ) return out.stdout.strip() except (subprocess.CalledProcessError, FileNotFoundError): return "nogit" def strip_comments(text): """Blank out (* ... *) comment spans, preserving line structure. Line structure is preserved so that reported line numbers still match the source file. ST block comments do not nest. """ out = [] depth = 0 i = 0 while i < len(text): if text.startswith("(*", i): depth += 1 out.append(" ") i += 2 elif text.startswith("*)", i) and depth: depth -= 1 out.append(" ") i += 2 else: ch = text[i] out.append(ch if (depth == 0 or ch == "\n") else " ") i += 1 return "".join(out) def check_located(files): """Enforce the section 10 grep check as a build step. Comments are stripped first: the brief bans located variables in the code, not in the commentary explaining the register map. """ offenders = [] for path in files: if path.name in LOCATED_ALLOWED: continue code = strip_comments(path.read_text(encoding="utf-8")) for n, line in enumerate(code.splitlines(), 1): if any(tok in line for tok in LOCATED_TOKENS): offenders.append(f" {path.name}:{n}: {line.strip()}") if offenders: sys.exit( "BUILD FAILED: located variable referenced outside " + " / ".join(sorted(LOCATED_ALLOWED)) + "\n" + "\n".join(offenders) ) def write_register_map(): PLC_DIR.mkdir(exist_ok=True) out = PLC_DIR / "register-map.csv" with out.open("w", newline="", encoding="utf-8") as fh: w = csv.writer(fh) w.writerow( [ "iec_address", "modbus_object", "modbus_address", "tag", "description", "units", "scaling", "access", ] ) for iec, tag, desc, units, scaling, access in REGISTERS: obj, addr = modbus_address(iec) w.writerow([iec, obj, addr, tag, desc, units, scaling, access]) return out def main(): ap = argparse.ArgumentParser(description="Build WRPS-CTL-002 flat ST.") ap.add_argument("--mode", choices=("field", "sim"), required=True) args = ap.parse_args() missing = [n for n in REQUIRED[args.mode] if not (SRC / n).is_file()] if missing: sys.exit( f"BUILD FAILED: missing source file(s) for mode '{args.mode}':\n" + "\n".join(f" src/{n}" for n in missing) ) # Lexical order by filename, per brief section 1. files = sorted( (p for p in SRC.glob("*.st") if p.name not in EXCLUDE[args.mode]), key=lambda p: p.name, ) if not files: sys.exit("BUILD FAILED: no source files found in src/") check_located(files) stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") header = [ "(* =====================================================================", " WRPS-CTL-002 Waterloo Road Pump Station", " GENERATED FILE - do not edit. Edit src/ and rebuild.", "", f" mode : {args.mode}", f" git : {git_hash()}", f" generated : {stamp}", f" sources : {', '.join(p.name for p in files)}", " ===================================================================== *)", "", ] parts = ["\n".join(header)] for p in files: parts.append(f"\n(* ---- {p.name} ---- *)\n") parts.append(p.read_text(encoding="utf-8")) BUILD.mkdir(exist_ok=True) out = BUILD / "wrps.st" out.write_text("\n".join(parts), encoding="utf-8") regmap = write_register_map() print(f"OK {out} ({out.stat().st_size} bytes, {len(files)} sources)") print(f"OK {regmap} ({len(REGISTERS)} registers)") if __name__ == "__main__": main()