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.
225 lines
8.8 KiB
Python
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()
|