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.
316 lines
12 KiB
Python
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()
|