wrps-demo-kit/03-plc/build.py
Clio Liu 13a05d0135 feat(plc): ST sources, generators and the Modbus contract
The PLC program from the old repo's 04-plc/, flattened into one folder and
checked against the running system.

Verified during the move:
  - build.py regenerates register-map.csv byte-identically (69 points)
  - polled the live PLC: the SIMULATION build is what is deployed and
    running, %MW21=2 wet weather, values moving, run hours accumulating
  - addresses, %MW HR1024 segmentation and %QW17/%QW7 signedness all
    match the map

Corrections against the old repo:
  - 10_globals.st header cited WRPS-CTL-002 (the FDS); it means CTL-003
  - build.py wrote the map to its parent directory; now beside itself
  - deploy/README.md was a single-file folder; now DEPLOY.md
  - dropped the empty editor-devices/remote/
  - README no longer claims the simulation build is uncompiled - it is
    the one running

Two open items are now stated plainly rather than buried:
  - none of the 20 acceptance tests in CTL-003 have ever been run
  - the OpenPLC Editor lived only on the retired dev-ubuntu host, so
    there is currently NO route to deploy a new program (DEPLOY.md 0)

Documents the setpoint distinction: IO_MUX seeds %MW defaults once at
first scan, operators retune them live, and that tuning exists only in
the container volume - a restart reverts it.
2026-09-02 15:48:49 +10:00

316 lines
12 KiB
Python

#!/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()