wrps-demo-kit/04-scada/hmi/check_layout.py
Clio Liu 947f632d7f feat(scada): CI Server tag database, historian, displays
The old 05-scada/, restructured around the distinction its README never
drew: configuration is deployed with dssqld, displays are deployed by
file copy. Conflating the two is what made the folder confusing.

  modbus_points/          the tag database - named for the protocol,
                          since CI Server configures others differently
  modbus_points/historian/  3 groups, 49 bindings - HAND-MADE, no
                          generator, and drifted from the server
  hmi/                    displays and their generator
  ciserver-backup-2026-08/  outdated exports, evidence only, never import
  QUICKLOAD.md            dssqld export/import, the 5 classes, the import
                          order, and why an item import kills every display
  README.md               the chain end to end, and the not-updating triage

Verified during the move - the whole chain is reproducible:
  scada-points.csv and all three .qli regenerate byte-identically
  all six displays build clean

Removed the K offset machinery from build_display.py, item-ids.meta.json
and DEPLOY.md. K was a consistency check on measured ids, not a source of
them, and diagnosing a dead screen by arithmetic is wasted effort when
validating the display in CI Server's Editor Module fixes it outright.
The guidance now leads with that one action.

Recorded, not fixed: the repo's historian config disagrees with the
2026-08 CI Server export - WRPS_THIRTY_SEC and a FIVE_SECONDS group exist
on the server and not here. cicore1 was unreachable during this audit, so
which is correct is unknown.

Not carried across: __pycache__, out/*.xml, and the top-level
WRPS_Overview.xml that was tracked despite .gitignore declaring the
display XMLs to be build output.
2026-09-02 17:16:18 +10:00

225 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Overlap audit for the generated displays.
python check_layout.py [display.xml ...]
Reads the built XML and reports text that collides with other text, or
that escapes its panel. Written after a units relabel pushed the wet
well caption into the top ladder label and nobody noticed until the
screens were on a monitor - the generator places components by
arithmetic, so a collision is arithmetic too, and worth catching here
rather than in CI View.
Text width is estimated, not measured: the value font is Consolas, which
is monospaced at ~0.55 em, and the label font is close enough at this
size. So the check is approximate by construction - it is a net for
gross collisions, not a typesetter.
Some components no longer HAVE one position. A level mark bound to its
setpoint through `<kind>.y` sits wherever the operator last typed, so
checking it where the generator happened to draw it checks the one
arrangement that is guaranteed not to be the problem. Those are swept
across their whole input range instead - see `sweep`.
"""
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
CHAR_W = 0.55 # em, Consolas advance width
PAD = 2.0 # px of slack before two boxes count as colliding
def components(xml):
"""(kind, x1, y1, x2, y2, value, size) for everything positioned."""
out = []
for kind, body in re.findall(
r'<(rectangle|text|number|ellipse) type="componentData">(.*?)</\1>',
xml, re.S):
def num(tag):
m = re.search(r"<%s>(-?[\d.]+)</%s>" % (tag, tag), body)
return float(m.group(1)) if m else None
cx, cy = num("x"), num("y")
top, bot, left, right = num("top"), num("bottom"), num("left"), num("right")
if None in (cx, cy, top, bot, left, right):
continue
val = re.search(r"<value>(.*?)</value>", body)
size = re.search(r'size="(\d+)"', body)
out.append((kind, cx - left, cy - top, cx + right, cy + bot,
val.group(1) if val else "", int(size.group(1)) if size else 0,
"<data ref=" in body, round(cx, 1), round(cy, 1)))
return out
def stacked(a, b):
"""True if two texts are alternative states of one thing.
A state stack - OFF / IDLE / PUMPING - is deliberately drawn at ONE
position with one member visible at a time. That is not a collision,
and reporting it as one buries the real ones. The signature is:
both carry a visibility binding, and both sit at the same anchor.
"""
return (a[7] and b[7]
and round(a[1], 0) == round(b[1], 0) # same left edge
and round(a[9], 0) == round(b[9], 0)) # same centre line
def text_box(c):
"""A text component's *drawn* extent, which is not its stored box.
The stored left/right on a text component is its anchor box, not the
glyph run - a right-anchored label stores a wide box and draws inside
the right edge of it. Estimate the run instead.
"""
kind, x1, y1, x2, y2, val, size = c[:7]
w = len(val) * size * CHAR_W
# right-anchored labels store the anchor at their right edge
if x2 - x1 > w * 1.5:
x1 = x2 - w
else:
x2 = x1 + w
return x1, y1, x2, y2
def overlaps(a, b):
ax1, ay1, ax2, ay2 = text_box(a)
bx1, by1, bx2, by2 = text_box(b)
return not (ax2 - PAD <= bx1 or bx2 - PAD <= ax1
or ay2 - PAD <= by1 or by2 - PAD <= ay1)
def line_boxes(xml):
"""Process lines as thin boxes - the obstacles text must not sit on.
THE GAP THIS CLOSES. The first version of this checker compared text
against text only, so it passed a screen whose top row of values was
sitting on the inlet pipe and whose data column ran across the
discharge manifold. It measured what was easy, not what was wrong.
"""
out = []
for body in re.findall(r'<line type="componentData">(.*?)</line>', xml, re.S):
pts = [float(v) for v in re.findall(r"<(?:x|y)>(-?[\d.]+)</(?:x|y)>", body)]
if len(pts) < 2:
continue
cx, cy = pts[0], pts[1]
def num(tag):
m = re.search(r"<%s>(-?[\d.]+)</%s>" % (tag, tag), body)
return float(m.group(1)) if m else 0.0
l, r_, t, b = num("left"), num("right"), num("top"), num("bottom")
out.append(("line", cx - l, cy - t, cx + r_, cy + b, "", 0, False, 0, 0))
return out
def shared_data(xml):
"""globalSection data objects by id - where a component's actions live."""
return dict(re.findall(
r'<intRef id="(\d+)"><sharedObject type="data">(.*?)</sharedObject></intRef>',
xml, re.S))
def movers(xml):
"""Components whose y is bound to an item, with the law that moves them.
Yields (component, y_at_input_0, y_at_input_end, input_end, item). The
component tuple is the one `components()` produces, so it drops straight
into the same overlap test.
"""
shared = shared_data(xml)
out = []
for kind, body in re.findall(
r'<(rectangle|text|number) type="componentData">(.*?)</\1>', xml, re.S):
ref = re.search(r'<data ref="(\d+)"/>', body)
if not ref or ref.group(1) not in shared:
continue
m = re.search(r'name="%s\.y"/>.*?<inputValueEnd>([\d.]+)</inputValueEnd>'
r"<outputValueStart>(-?[\d.]+)</outputValueStart>"
r"<outputValueEnd>(-?[\d.]+)</outputValueEnd>.*?"
r"<itemName>([^<]+)</itemName>" % kind,
shared[ref.group(1)], re.S)
if not m:
continue
end, y0, y1 = float(m.group(1)), float(m.group(2)), float(m.group(3))
# a number carries no <value>; its mask is what it will be as wide as
val = re.search(r"<value>(.*?)</value>", body)
fmt = re.search(r"<format>([^<]*)</format>", body)
text = val.group(1) if val else (fmt.group(1) if fmt else "")
for c in components("<%s type=\"componentData\">%s</%s>" % (kind, body, kind)):
out.append((c[:5] + (text,) + c[6:], y0, y1, end, m.group(4)))
return out
def sweep(xml, statics, step=0.5):
"""Drive every moving component across its range against what stays put.
The invariant being enforced is that a mark which can be moved and a
mark which cannot never share a column - because a mark that moves will
eventually be driven to the height of one that does not. Checking the
positions the generator drew proves nothing about that; only the sweep
does.
Moving-against-moving is deliberately NOT reported. Two setpoints set
close together really do put their marks close together, and that is the
screen telling the truth about the thresholds, not a layout fault.
"""
hits = {}
for c, y0, y1, end, item in movers(xml):
cy = (c[2] + c[4]) / 2.0
v = 0.0
while v <= end + 1e-9:
dy = (y0 + (y1 - y0) * v / end) - cy
moved = (c[0], c[1], c[2] + dy, c[3], c[4] + dy) + c[5:]
for s in statics:
if overlaps(moved, s):
key = (item, c[5] or c[0], s[5] or s[0])
lo, hi = hits.get(key, (v, v))
hits[key] = (min(lo, v), max(hi, v))
v += step
return hits
def audit(path):
xml = path.read_text(encoding="utf-8")
comps = components(xml)
texts = [c for c in comps if c[0] == "text" and c[5].strip()]
lines = line_boxes(xml)
hits = []
for i, a in enumerate(texts):
for b in texts[i + 1:]:
if overlaps(a, b) and not stacked(a, b):
hits.append((a[5], b[5]))
for a in texts:
for b in lines:
ax1, ay1, ax2, ay2 = text_box(a)
if not (ax2 <= b[1] or b[3] <= ax1 or ay2 <= b[2] or b[4] <= ay1):
hits.append((a[5], "<process line at %d,%d>" % (b[1], b[2])))
# things that stay put, which is what a moving mark must never reach
moving_at = {(round(c[1], 1), round(c[2], 1)) for c, _, _, _, _ in movers(xml)}
statics = [t for t in texts if (round(t[1], 1), round(t[2], 1)) not in moving_at]
swept = sweep(xml, statics + lines)
n = len(hits) + len(swept)
print("%-28s %3d components, %3d text, %2d moving"
% (path.name, len(comps), len(texts), len(moving_at)), end="")
if n:
print(" -> %d COLLISIONS" % n)
for a, b in hits:
print(" %-40s x %s" % (a[:40], b[:40]))
for (item, a, b), (lo, hi) in sorted(swept.items()):
print(" %-22s %-16s x %-22s at %g-%g%%"
% (item.replace("AID.WRPS.", ""), a[:16], b[:22], lo, hi))
else:
print(" -> clear")
return n
def main():
args = sys.argv[1:]
files = [Path(a) for a in args] if args else sorted((HERE / "out").glob("WRPS_*.xml"))
bad = sum(audit(f) for f in files if f.is_file())
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()