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.
1591 lines
76 KiB
Python
1591 lines
76 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the CI Server display XML for the WRPS demo.
|
|
|
|
python build_display.py -> ./out/*.xml
|
|
|
|
Emits three displays:
|
|
|
|
WRPS_Overview.xml the single operator display, 1920 x 1080
|
|
WRPS_FP_Pump.xml pump faceplate (opened per pump)
|
|
WRPS_FP_Setpoints.xml station setpoint faceplate
|
|
|
|
Everything here follows the file format as observed in the live
|
|
deployment copied to 99-reference/ciserver-hmi-deployment, not a manual:
|
|
|
|
geometry x,y is the CENTRE; left/right/top/bottom are half-extents
|
|
colours <intRef> shared paints, referenced as ref="NN"
|
|
live value <number> + actionConnectTo -> property number.value
|
|
-> connection {direction 1, itemName, itemAttribute ItemValue}
|
|
write actionSetItemAttribute + filter (the value) + connection
|
|
{direction 2, itemName, itemAttribute ItemValue}
|
|
navigation actionActivateDisplay + <display>NAME</display>
|
|
|
|
Style tokens and component rules come from component-kit.md. Nothing is
|
|
hand-placed twice: a value box is one function, so every value box on
|
|
every display is identical by construction.
|
|
|
|
KNOWN GAP - state colouring is static in this version. Driving a fill
|
|
colour from a value needs either item alarm limits (deferred by the user
|
|
along with trends and alarms) or a threshold/transformation set up in the
|
|
editor, which is not safe to guess at. Numbers, navigation and writes
|
|
are live. See the README section "What still needs doing in the editor".
|
|
"""
|
|
|
|
import datetime
|
|
import pathlib
|
|
from pathlib import Path
|
|
|
|
import point_format as pf
|
|
|
|
OUT = Path(__file__).resolve().parent / "out"
|
|
W, H = 1920, 1080
|
|
|
|
# ------------------------------------------------------------- item ids
|
|
# A connection needs BOTH itemName and itemId; with the name alone every
|
|
# value reads 0 - the whole screen looks alive and means nothing. Getting
|
|
# this wrong is the single failure mode that has cost this project the most
|
|
# time, so the rules below are enforced by the build, not by memory.
|
|
#
|
|
# THE POLICY: EVERY ID IS MEASURED. NOTHING IS INFERRED.
|
|
# -------------------------------------------------------
|
|
# An id comes from CI Server or the build stops. There is no rule, no
|
|
# default and no fallback, because an id we invented that happens to be
|
|
# wrong produces exactly the failure that is hardest to see: a screen full
|
|
# of live-looking zeroes.
|
|
#
|
|
# Ids are measured by saving a display in CI View - it resolves every
|
|
# connection by NAME on save - and reading them back:
|
|
#
|
|
# python build_display.py --harvest <saved.xml>
|
|
#
|
|
# WRPS_TagTest binds every project item, so saving that ONE display
|
|
# measures all 49 in a single pass. That is the intended route.
|
|
#
|
|
# An earlier version derived the ids it had not measured, by arithmetic on
|
|
# the item NSIDs. It no longer does: an id we invent that happens to be
|
|
# wrong produces the failure that is hardest to see - a screen full of
|
|
# live-looking zeroes. Every id is measured or the build stops.
|
|
#
|
|
# WHY THEY GO STALE
|
|
# -----------------
|
|
# **Every .qli item import renumbers every item.** On 2026-08-14 the items
|
|
# were re-imported to change engineering units, keeping every NAME, NSID and
|
|
# ID_NUMBER byte-identical - and every id still moved. CI Server recreates
|
|
# items on import rather than updating them in place. Every value on every
|
|
# screen went dead at once.
|
|
#
|
|
# The cure is not arithmetic. It is: VALIDATE the display in CI Server's
|
|
# Editor Module. That resolves every connection by name and heals the file.
|
|
# Then re-harvest, so the next build ships the new ids.
|
|
#
|
|
# THE GUARDS - all hard failures, none is a warning
|
|
# ------------------------------------------------
|
|
# 1. Every bound item must have a measured id. check_ids() lists all the
|
|
# gaps at once, since one harvest fixes them together.
|
|
# 2. item-ids.meta.json pins the SHA of the wrps_item_df.qli the ids were
|
|
# harvested against. If that file has changed, the items have been or
|
|
# will be re-imported, so the ids are presumed stale.
|
|
# 3. --verify <saved display> compares CI Server's own resolved ids against
|
|
# what this build emits, and exits 1 naming every item that would read 0.
|
|
#
|
|
# The cure for all of them is the same one command, and every error says so.
|
|
IDS_FILE = Path(__file__).resolve().parent / "item-ids.csv"
|
|
IDS_META = Path(__file__).resolve().parent / "item-ids.meta.json"
|
|
ITEM_FILE = Path(__file__).resolve().parents[1] / "modbus_points" / "wrps_item_df.qli"
|
|
|
|
HARVEST_ALL = (
|
|
" Fix: get the ids from CI Server - do not infer them.\n"
|
|
" 1. in CI View open WRPS_TagTest, which binds EVERY project item\n"
|
|
" 2. link any one value by hand and save; CI View then resolves and\n"
|
|
" writes the id of every connection in the file\n"
|
|
" 3. python build_display.py --harvest <the saved WRPS_TagTest.xml>\n"
|
|
" That measures all 49 in one pass. Any display works, but only\n"
|
|
" WRPS_TagTest covers the whole item list."
|
|
)
|
|
REHARVEST = HARVEST_ALL
|
|
|
|
_ids = {}
|
|
_missing = set()
|
|
|
|
|
|
def item_file_sha():
|
|
import hashlib
|
|
if not ITEM_FILE.is_file():
|
|
return ""
|
|
return hashlib.sha256(ITEM_FILE.read_bytes()).hexdigest()
|
|
|
|
|
|
def item_nsids():
|
|
"""{item name: NSID} from the item export - the axis ids are built on."""
|
|
import re
|
|
if not ITEM_FILE.is_file():
|
|
return {}
|
|
q = ITEM_FILE.read_text(encoding="utf-8")
|
|
return {m.group(1): int(m.group(2))
|
|
for m in re.finditer(r'^"(AID\.WRPS\.[A-Z0-9_.]+)",(\d+),', q, re.M)}
|
|
|
|
|
|
def item_reps():
|
|
import re
|
|
if not ITEM_FILE.is_file():
|
|
return {}
|
|
q = ITEM_FILE.read_text(encoding="utf-8")
|
|
return dict(re.findall(r'^"(AID\.WRPS\.[A-Z0-9_.]+)".*?"(Real|Boolean)"',
|
|
q, re.M | re.S))
|
|
|
|
|
|
def measured_ids():
|
|
import csv
|
|
if not IDS_FILE.is_file():
|
|
return {}
|
|
return {r["item"]: r["itemId"]
|
|
for r in csv.DictReader(IDS_FILE.open(encoding="utf-8"))}
|
|
|
|
|
|
def load_ids():
|
|
"""The measured ids, checked for internal consistency. No derivation."""
|
|
import json
|
|
import sys
|
|
ids = measured_ids()
|
|
if not item_nsids():
|
|
return
|
|
|
|
if not ids:
|
|
sys.exit("BUILD FAILED: no item ids have ever been measured.\n"
|
|
" Every value would render as 0 on a screen that looks fine.\n"
|
|
+ REHARVEST)
|
|
|
|
if IDS_META.is_file():
|
|
meta = json.loads(IDS_META.read_text(encoding="utf-8"))
|
|
if meta.get("item_file_sha") != item_file_sha():
|
|
sys.exit("BUILD FAILED: %s has changed since the ids were harvested.\n"
|
|
" Harvested against : %s\n"
|
|
" Now : %s\n"
|
|
" The items have been regenerated, so they will be (or have\n"
|
|
" been) re-imported - and every import moves every id.\n"
|
|
% (ITEM_FILE.name, meta.get("item_file_sha", "?")[:16],
|
|
item_file_sha()[:16]) + REHARVEST)
|
|
else:
|
|
sys.exit("BUILD FAILED: %s is missing, so the ids cannot be shown to\n"
|
|
" match the current items.\n" % IDS_META.name + REHARVEST)
|
|
|
|
_ids.update(ids)
|
|
|
|
|
|
def item_id(item):
|
|
"""The MEASURED id for an item. Nothing is derived.
|
|
|
|
An earlier version filled gaps from `NSID + K`. The rule held every
|
|
time it was checked, but it is still an inference about someone
|
|
else's numbering, and an inferred id that is wrong produces a screen
|
|
that looks alive and reads 0 - the exact failure this project keeps
|
|
paying for. So: if CI Server has not told us an id, the build stops.
|
|
Every item can be measured at once - see check_ids().
|
|
"""
|
|
if not _ids:
|
|
load_ids()
|
|
if item not in _ids:
|
|
# Recorded, not raised: check_ids() reports every missing item in
|
|
# one list, and nothing is written to disk before it runs.
|
|
_missing.add(item)
|
|
return "UNMEASURED"
|
|
return _ids[item]
|
|
|
|
# ---------------------------------------------------------------- style
|
|
# component-kit.md. Kept as (r,g,b) so they land straight in colorSet.
|
|
C = {
|
|
"BG": (237, 240, 244),
|
|
"SF": (255, 255, 255),
|
|
"SF2": (245, 248, 251),
|
|
"BD": (195, 204, 216),
|
|
"BD2": (223, 229, 236),
|
|
"T1": (14, 22, 33),
|
|
"T2": (70, 84, 104),
|
|
"AC": (11, 125, 168),
|
|
"AC2": (18, 165, 217),
|
|
"RUN": (15, 138, 69),
|
|
"RUNBG": (228, 246, 234),
|
|
"WRN": (168, 91, 0),
|
|
"WRNBG": (255, 239, 210),
|
|
"ALM": (211, 32, 41),
|
|
"ALMBG": (253, 232, 233),
|
|
"ACBG": (222, 240, 248),
|
|
"ALMBD": (150, 19, 26),
|
|
"PRC": (43, 63, 82),
|
|
"WTR": (159, 207, 230),
|
|
"WHITE": (255, 255, 255),
|
|
}
|
|
|
|
LAB = "Segoe UI"
|
|
VAL = "Consolas"
|
|
|
|
# item namespace
|
|
NS = "AID.WRPS"
|
|
|
|
|
|
def text_width(value, size, font=LAB):
|
|
"""Rough rendered width of a string, in px.
|
|
|
|
Only an estimate - the components keep their original size and CI View
|
|
lays them out itself. It is shared rather than inlined because the
|
|
ladder marks place a live number immediately left of a label, and if the
|
|
two disagreed about how wide the label is they would drift apart.
|
|
"""
|
|
return len(value) * size * (0.62 if font == VAL else 0.55)
|
|
|
|
|
|
# ------------------------------------------------------------- plumbing
|
|
class Display:
|
|
"""Accumulates shared objects and components, then serialises."""
|
|
|
|
def __init__(self, title, width=W, height=H):
|
|
self.title = title
|
|
self.w, self.h = width, height
|
|
self.refs = [] # (id, xml)
|
|
self.ref_cache = {}
|
|
self.parts = []
|
|
self.next_ref = 1000
|
|
self.next_html = 100
|
|
|
|
# --- shared objects ------------------------------------------------
|
|
def _ref(self, key, xml):
|
|
if key in self.ref_cache:
|
|
return self.ref_cache[key]
|
|
rid = self.next_ref
|
|
self.next_ref += 1
|
|
self.refs.append((rid, xml))
|
|
self.ref_cache[key] = rid
|
|
return rid
|
|
|
|
def paint(self, rgb):
|
|
r, g, b = rgb
|
|
return self._ref(("paint", rgb),
|
|
'<sharedObject type="paint">'
|
|
'<paint type="colorSet" r="%d" g="%d" b="%d"/>'
|
|
'</sharedObject>' % (r, g, b))
|
|
|
|
def conn(self, item, attribute="ItemValue", direction=1):
|
|
"""Write connection, as a shared object - the form the editor saves."""
|
|
return self._ref(("conn", item, attribute, direction),
|
|
'<sharedObject type="connection">'
|
|
'<direction>%d</direction>'
|
|
'<itemName>%s</itemName>'
|
|
'<itemId>%s</itemId>'
|
|
'<itemAttribute>%s</itemAttribute>'
|
|
'</sharedObject>' % (direction, item, item_id(item), attribute))
|
|
|
|
def read_conn(self, item):
|
|
"""Read connection, inline and without itemAttribute.
|
|
|
|
This is exactly what CI View wrote when the item was linked by hand;
|
|
the earlier version omitted itemId and every value read 0.
|
|
"""
|
|
return ('<connection type="connection">'
|
|
"<direction>1</direction>"
|
|
"<itemName>%s</itemName>"
|
|
"<itemId>%s</itemId>"
|
|
"</connection>" % (item, item_id(item)))
|
|
|
|
def filt(self, value="0.0"):
|
|
return self._ref(("filter", value),
|
|
'<sharedObject type="filter"><value>%s</value>'
|
|
'</sharedObject>' % value)
|
|
|
|
def direct(self):
|
|
return self._ref(("tdirect",),
|
|
'<sharedObject type="transformationDirect">'
|
|
'<inputType>java.lang.Double</inputType>'
|
|
'<outputType>java.lang.Double</outputType>'
|
|
'</sharedObject>')
|
|
|
|
def named_colour(self, name):
|
|
"""A colour CI View names rather than mixes - `gainsboro` and friends.
|
|
|
|
The rest of the kit gives colours as RGB; the microTrend background is
|
|
the one place CI View wrote a name, so it is kept as one.
|
|
"""
|
|
return self._ref(("colorSet", name),
|
|
'<sharedObject type="colorSet"><name>%s</name>'
|
|
"</sharedObject>" % name)
|
|
|
|
def comp_prop(self, name, value=None):
|
|
"""A componentProperty default, shared. Empty value stays empty."""
|
|
return self._ref(("cprop", name, value),
|
|
'<sharedObject type="componentProperty"><name>%s</name>'
|
|
"%s</sharedObject>"
|
|
% (name, "<value/>" if value is None else
|
|
"<value>%s</value>" % value))
|
|
|
|
def plain_conn(self, item):
|
|
"""Read connection as a SHARED object, with no itemAttribute.
|
|
|
|
`conn()` always writes an itemAttribute and `read_conn()` is inline;
|
|
the microTrend needs the third shape - shared and bare - which is what
|
|
CI View wrote for the trend's realTime/fillWithHistory bindings.
|
|
"""
|
|
return self._ref(("plainconn", item),
|
|
'<sharedObject type="connection">'
|
|
"<direction>1</direction>"
|
|
"<itemName>%s</itemName><itemId>%s</itemId>"
|
|
"</sharedObject>" % (item, item_id(item)))
|
|
|
|
def hid(self):
|
|
self.next_html += 1
|
|
return self.next_html
|
|
|
|
# --- state: making the screen show what the plant is doing ----------
|
|
#
|
|
# All three of these were copied from the site's own displays, which
|
|
# use them heavily - rectangle.fillPaint 276 times, rectangle.visible
|
|
# 29, line.visible 25. The note in DEPLOY.md that said driving a
|
|
# shape from a value needed item alarm limits was simply wrong, and
|
|
# it is why this screen showed a grey pump whether it was running,
|
|
# stopped or tripped.
|
|
|
|
def parameter(self, name, kind="Boolean", default="false"):
|
|
"""A display parameter: the wire between a function and a shape."""
|
|
self.parts.append(
|
|
'<visualizationParameter type="componentData">'
|
|
'<config type="graphicInfo"><name>%s</name>'
|
|
'<type type="parameterType" value="ParameterType%s"/>'
|
|
"<value>%s</value></config></visualizationParameter>"
|
|
% (name, kind, default))
|
|
return name
|
|
|
|
def param_conn(self, name, direction=1):
|
|
return ('<connection type="connection"><direction>%d</direction>'
|
|
"<parameter>%s</parameter>%s</connection>"
|
|
% (direction, name,
|
|
"<itemAttribute>ItemValue</itemAttribute>" if direction == 2 else ""))
|
|
|
|
def equals(self, item, value, pname):
|
|
"""item == value -> a boolean parameter, via genericFunctions.
|
|
|
|
The only way to test an enum. STATE 2 means "Pumping" and the
|
|
operator should read that word, not the number 2.
|
|
"""
|
|
self.parameter(pname)
|
|
n = len([p for p in self.parts if "genericFunctions" in p])
|
|
gx, gy = 40 + (n % 30) * 62, 1068 + (n // 30) * 10
|
|
self.parts.append(
|
|
'<genericFunctions type="componentData">'
|
|
"<name>%s</name>"
|
|
"<x>%.1f</x><y>%.1f</y><top>4.0</top><bottom>4.0</bottom>"
|
|
"<left>28.0</left><right>28.0</right>"
|
|
"<rotation>0.0</rotation><shear>0.0</shear>"
|
|
"<numberArgument2>%.1f</numberArgument2>"
|
|
'<function type="genericFunctionType" value="GenericFunctionTypeEqual"/>'
|
|
'<data type="data">'
|
|
'<action type="actionConnectTo">'
|
|
'<property type="property" name="genericFunctions.numberArgument1"/>'
|
|
'<filter ref="%d"/>%s</action>'
|
|
'<action type="actionConnectTo">'
|
|
'<property type="property" name="genericFunctions.booleanResult"/>'
|
|
'<filter ref="%d"/>%s</action>'
|
|
"</data></genericFunctions>"
|
|
% (pname, gx, gy, float(value), self.filt(), self.read_conn(item),
|
|
self.filt(), self.param_conn(pname, 2)))
|
|
return pname
|
|
|
|
def vis_action(self, kind, item=None, param=None):
|
|
"""The action alone, so it can share a data block with another one."""
|
|
conn = self.read_conn(item) if item else self.param_conn(param, 1)
|
|
return ('<action type="actionConnectTo">'
|
|
'<property type="property" name="%s.visible"/>'
|
|
'<filter ref="%d"/>%s</action>' % (kind, self.filt(), conn))
|
|
|
|
def vis(self, kind, item=None, param=None):
|
|
"""Shared data object binding <kind>.visible to a bool item/param."""
|
|
return self._ref(
|
|
("vis", kind, item, param),
|
|
'<sharedObject type="data">%s</sharedObject>'
|
|
% self.vis_action(kind, item, param))
|
|
|
|
def moves_y(self, kind, item, y_at_0, y_at_full, full=100.0):
|
|
"""Bind <kind>.y to an item, so a mark sits at the level it means.
|
|
|
|
A setpoint that can be typed into and a line drawn at a fixed height
|
|
are two claims about the same number, and only one of them updates.
|
|
This binds the second to the first.
|
|
|
|
The `transformationLinear` shape is the one CI View wrote when the
|
|
user moved the STOP ALL mark by hand on 2026-08-17, including the
|
|
absent `inputValueStart` - the editor omits it because it defaults to
|
|
0, and every scale here does start at 0.
|
|
|
|
`x`/`y` are declared properties on Rectangle, Text and Number alike
|
|
(checked in components/*.xml), so the line, its live number and its
|
|
label can all ride the same item. All three carry `html="noConnect"`
|
|
though: in CI Server's WEB client these bindings may not animate. The
|
|
desktop CI View client is what this project targets.
|
|
"""
|
|
return ('<action type="actionConnectTo">'
|
|
'<property type="property" name="%s.y"/>'
|
|
'<filter ref="%d"/>'
|
|
'<transformation type="transformationLinear">'
|
|
"<inputType>java.lang.Double</inputType>"
|
|
"<outputType>java.lang.Double</outputType>"
|
|
"<inputValueEnd>%.1f</inputValueEnd>"
|
|
"<outputValueStart>%.1f</outputValueStart>"
|
|
"<outputValueEnd>%.1f</outputValueEnd>"
|
|
"</transformation>%s</action>"
|
|
% (kind, self.filt(), full, y_at_0, y_at_full, self.read_conn(item)))
|
|
|
|
def data(self, actions):
|
|
"""Register actions as a shared data object and return its ref.
|
|
|
|
Actions in globalSection as <sharedObject type="data">, referenced
|
|
with <data ref="N"/>. This is a preference, not a requirement:
|
|
WRPS_TagTest bound all 49 items both this way and inline as
|
|
<data type="data">, and both columns updated identically. Shared is
|
|
kept because it is what CI View writes on save and it de-duplicates
|
|
the repeated actions.
|
|
|
|
(An earlier note here claimed inline bindings never update and that
|
|
this was why values read 0. That was wrong - see the <format> mask
|
|
note on number() for the actual cause.)
|
|
"""
|
|
return self._ref(("data", actions),
|
|
'<sharedObject type="data">%s</sharedObject>' % actions)
|
|
|
|
def open_display(self, name, event="Clicked b1"):
|
|
"""Shared data object that opens a display AS A POPUP.
|
|
|
|
ActivateSpecific + layout FixedPopup is what the site's own screens
|
|
use; without it the display replaces the current one full-screen.
|
|
"""
|
|
return self._ref(("open", name, event),
|
|
'<sharedObject type="data">'
|
|
'<action type="actionActivateDisplay">'
|
|
'<event type="event" event="%s"/>'
|
|
'<type type="displayActivationType" value="ActivateSpecific"/>'
|
|
'<display>%s</display>'
|
|
'<layout>FixedPopup</layout>'
|
|
'<layoutFrame>default</layoutFrame>'
|
|
'</action></sharedObject>' % (event, name))
|
|
|
|
def hit_area(self, x, y, w, h, display):
|
|
"""Invisible click target: a fully transparent rectangle.
|
|
|
|
Using a real button here drew a grey button face over the symbol
|
|
underneath, which is exactly what happened on the first deploy.
|
|
"""
|
|
cx, cy = x + w / 2.0, y + h / 2.0
|
|
clear = self._ref(("paint", "clear"),
|
|
'<sharedObject type="paint">'
|
|
'<paint type="colorSet" r="0" g="0" b="0" a="0"/>'
|
|
"</sharedObject>")
|
|
s = ['<rectangle type="componentData">', "<htmlId>%d</htmlId>" % self.hid(),
|
|
"<x>%.1f</x><y>%.1f</y>" % (cx, cy),
|
|
"<top>%.1f</top><bottom>%.1f</bottom>" % (h / 2.0, h / 2.0),
|
|
"<left>%.1f</left><right>%.1f</right>" % (w / 2.0, w / 2.0),
|
|
"<rotation>0.0</rotation><shear>0.0</shear>",
|
|
'<stroke type="stroke" width="1.0"/>',
|
|
'<strokePaint ref="%d"/>' % clear,
|
|
'<fillPaint ref="%d"/>' % clear,
|
|
'<data ref="%d"/>' % self.open_display(display),
|
|
"</rectangle>"]
|
|
self.parts.append("".join(s))
|
|
|
|
# --- primitives ----------------------------------------------------
|
|
def rect(self, x, y, w, h, fill, stroke=None, width=1.0,
|
|
show_item=None, show_param=None, move=None):
|
|
"""Rectangle by top-left + size; converted to centre/half-extent.
|
|
|
|
show_item / show_param bind `visible`, so a shape can appear only
|
|
when a pump is running, a state is active, or a mode is selected.
|
|
`move` takes a moves_y() action and rides in the SAME data block -
|
|
a component carries one, so the two cannot be separate.
|
|
"""
|
|
cx, cy = x + w / 2.0, y + h / 2.0
|
|
s = ['<rectangle type="componentData">', "<htmlId>%d</htmlId>" % self.hid(),
|
|
"<x>%.1f</x><y>%.1f</y>" % (cx, cy),
|
|
"<top>%.1f</top><bottom>%.1f</bottom>" % (h / 2.0, h / 2.0),
|
|
"<left>%.1f</left><right>%.1f</right>" % (w / 2.0, w / 2.0),
|
|
"<rotation>0.0</rotation><shear>0.0</shear>",
|
|
'<stroke type="stroke" width="%.1f"/>' % width,
|
|
'<strokePaint ref="%d"/>' % self.paint(stroke or fill),
|
|
'<fillPaint ref="%d"/>' % self.paint(fill)]
|
|
acts = (self.vis_action("rectangle", show_item, show_param)
|
|
if (show_item or show_param) else "") + (move or "")
|
|
if acts:
|
|
s.append('<data ref="%d"/>' % self.data(acts))
|
|
s.append("</rectangle>")
|
|
self.parts.append("".join(s))
|
|
|
|
def line(self, x1, y1, x2, y2, colour, width=2.0, dash=False, move=None):
|
|
"""Orthogonal line drawn as a thin rectangle.
|
|
|
|
The `line` component positions itself with curvePoints/curveSegments,
|
|
whose anchor convention could not be determined unambiguously from
|
|
the one example in the deployment. A filled rectangle is exact,
|
|
needs no guessing, and is what the site's own screens use for rules
|
|
and pipes. Diagonals are therefore not available - which is no loss,
|
|
because a P&ID is drawn orthogonally anyway.
|
|
"""
|
|
if dash:
|
|
# a dashed line is a run of rectangles, so there is no single one
|
|
# to move; nothing needs a moving dashed line, so this is a bug
|
|
assert move is None, "a dashed line cannot be moved by an item"
|
|
# dashed instrument signal: a run of short rectangles
|
|
if y1 == y2:
|
|
x = min(x1, x2)
|
|
while x < max(x1, x2):
|
|
self.rect(x, y1 - width / 2.0, min(7.0, max(x1, x2) - x), width, colour)
|
|
x += 12.0
|
|
else:
|
|
y = min(y1, y2)
|
|
while y < max(y1, y2):
|
|
self.rect(x1 - width / 2.0, y, width, min(7.0, max(y1, y2) - y), colour)
|
|
y += 12.0
|
|
return
|
|
if y1 == y2:
|
|
self.rect(min(x1, x2), y1 - width / 2.0, abs(x2 - x1), width, colour,
|
|
move=move)
|
|
elif x1 == x2:
|
|
self.rect(x1 - width / 2.0, min(y1, y2), width, abs(y2 - y1), colour,
|
|
move=move)
|
|
else:
|
|
assert move is None, "an L-shaped line is two rectangles; move one"
|
|
# route orthogonally: across, then down
|
|
self.line(x1, y1, x2, y1, colour, width)
|
|
self.line(x2, y1, x2, y2, colour, width)
|
|
|
|
def ellipse(self, cx, cy, r, fill, stroke, width=2.0,
|
|
show_item=None, show_param=None):
|
|
s = ['<ellipse type="componentData">', "<htmlId>%d</htmlId>" % self.hid(),
|
|
"<x>%.1f</x><y>%.1f</y>" % (cx, cy),
|
|
"<top>%.1f</top><bottom>%.1f</bottom>" % (r, r),
|
|
"<left>%.1f</left><right>%.1f</right>" % (r, r),
|
|
"<rotation>0.0</rotation><shear>0.0</shear>",
|
|
'<stroke type="stroke" width="%.1f"/>' % width,
|
|
'<strokePaint ref="%d"/>' % self.paint(stroke),
|
|
'<fillPaint ref="%d"/>' % self.paint(fill)]
|
|
if show_item or show_param:
|
|
s.append('<data ref="%d"/>' % self.vis("ellipse", show_item, show_param))
|
|
s.append("</ellipse>")
|
|
self.parts.append("".join(s))
|
|
|
|
def text(self, x, y, value, size=14, colour=None, bold=False, font=LAB,
|
|
anchor="left", show_item=None, show_param=None, move=None):
|
|
colour = colour or C["T1"]
|
|
# width estimate: enough for the editor to place it; it keeps original size
|
|
tw = text_width(value, size, font)
|
|
cx = x + tw / 2.0 if anchor == "left" else (x - tw / 2.0 if anchor == "right" else x)
|
|
s = ['<text type="componentData">', "<htmlId>%d</htmlId>" % self.hid(),
|
|
"<value>%s</value>" % esc(value),
|
|
'<font type="font" name="%s" bold="%s" size="%d" underline="false" '
|
|
'strikethrough="false"/>' % (font, "true" if bold else "false", size),
|
|
'<fillPaint ref="%d"/>' % self.paint(colour),
|
|
'<stroke type="stroke" width="1.0"/>',
|
|
"<keepOriginalSize>true</keepOriginalSize>",
|
|
"<x>%.1f</x><y>%.1f</y>" % (cx, y),
|
|
"<top>%.1f</top><bottom>%.1f</bottom>" % (size * 0.7, size * 0.7),
|
|
"<left>%.1f</left><right>%.1f</right>" % (tw / 2.0, tw / 2.0),
|
|
"<rotation>0.0</rotation><shear>0.0</shear>"]
|
|
acts = (self.vis_action("text", show_item, show_param)
|
|
if (show_item or show_param) else "") + (move or "")
|
|
if acts:
|
|
s.append('<data ref="%d"/>' % self.data(acts))
|
|
s.append("</text>")
|
|
self.parts.append("".join(s))
|
|
|
|
def number(self, x, y, item, size=22, colour=None, bold=True, decimals=0,
|
|
width=110, move=None):
|
|
"""A live value. This is the only way a number reaches the screen.
|
|
|
|
<format> is a DIGIT MASK, not a Java DecimalFormat pattern. The first
|
|
build emitted "0" and "0.0" - masks one digit wide - so nothing wider
|
|
than a single digit had anywhere to render, and every value read 0.
|
|
Confirmed by CI View itself: setting the FIT-201 INLET value to two
|
|
decimals by hand wrote <format>99.99</format>, immediately after
|
|
<htmlId> and with no <value>. That element order is copied here.
|
|
|
|
Masks are per point and live in point_format.py, sized to each
|
|
point's engineering range. `decimals` is ignored - the mask decides -
|
|
but is kept in the signature because callers pass it.
|
|
"""
|
|
colour = colour or C["T1"]
|
|
cx = x + width / 2.0
|
|
s = ['<number type="componentData">', "<htmlId>%d</htmlId>" % self.hid(),
|
|
"<format>%s</format>" % pf.mask(item),
|
|
'<font type="font" name="%s" bold="%s" size="%d" underline="false" '
|
|
'strikethrough="false"/>' % (VAL, "true" if bold else "false", size),
|
|
'<fillPaint ref="%d"/>' % self.paint(colour),
|
|
'<stroke type="stroke" width="1.0"/>',
|
|
"<keepOriginalSize>true</keepOriginalSize>",
|
|
"<x>%.1f</x><y>%.1f</y>" % (cx, y),
|
|
"<top>%.1f</top><bottom>%.1f</bottom>" % (size * 0.7, size * 0.7),
|
|
"<left>%.1f</left><right>%.1f</right>" % (width / 2.0, width / 2.0),
|
|
"<rotation>0.0</rotation><shear>0.0</shear>",
|
|
'<data ref="%d"/>' % self.data(
|
|
'<action type="actionConnectTo">'
|
|
'<property type="property" name="number.value"/>'
|
|
'<filter ref="%d"/>%s</action>'
|
|
% (self.filt(), self.read_conn(item)) + (move or "")),
|
|
"</number>"]
|
|
self.parts.append("".join(s))
|
|
|
|
def number_field(self, x, y, w, h, item, size=14):
|
|
"""An EDITABLE value - the operator types into it and it writes.
|
|
|
|
Copied from what CI View itself wrote when the user added a level
|
|
setpoint field by hand on 2026-08-14. Two things differ from
|
|
every other component here and both are load-bearing:
|
|
|
|
- geometry is on the ELEMENT as attributes, not child elements
|
|
- the connection carries <itemAttribute>ItemValue</itemAttribute>
|
|
and NO <direction>; a numberField is read-write by nature
|
|
|
|
It also takes no <format>: the field shows what the operator can
|
|
type, and a mask would fight the editing.
|
|
"""
|
|
cx, cy = x + w / 2.0, y + h / 2.0
|
|
s = ['<numberField type="componentData" x="%.1f" y="%.1f" '
|
|
'top="%.1f" bottom="%.1f" left="%.1f" right="%.1f" '
|
|
'rotation="0.0" shear="0.0">' % (cx, cy, h / 2.0, h / 2.0, w / 2.0, w / 2.0),
|
|
"<htmlId>%d</htmlId>" % self.hid(),
|
|
'<font type="font" name="%s" size="%d" underline="false" '
|
|
'strikethrough="false"/>' % (LAB, size),
|
|
'<data type="data"><action type="actionConnectTo">'
|
|
'<property type="property" name="numberField.value"/>'
|
|
'<filter ref="%d"/>'
|
|
'<connection type="connection">'
|
|
"<itemName>%s</itemName><itemId>%s</itemId>"
|
|
"<itemAttribute>ItemValue</itemAttribute>"
|
|
"</connection></action></data>" % (self.filt(), item, item_id(item)),
|
|
"</numberField>"]
|
|
self.parts.append("".join(s))
|
|
|
|
def data_bar(self, x, y, w, h, item, low=0.0, high=100.0,
|
|
fill=None, back=None, up=True, opens=None):
|
|
"""A filled bar driven by an item - the live level in the well.
|
|
|
|
The range lives on the COMPONENT (`lowLimit`/`highLimit`), not on
|
|
the item, so this needs no alarm or scale limits configured in CI
|
|
Server - which is what kept the well fill static until now. The
|
|
site's own Straddle_Detail uses exactly this for a fuel level %,
|
|
with highLimit 100.
|
|
|
|
`opens` puts an actionActivateDisplay in the SAME data block as the
|
|
value binding, so the bar is both the reading and the way in to the
|
|
numbers behind it. A hit_area over the well would have done the job
|
|
too, but this is the mechanism the user built by hand in CI View on
|
|
2026-08-17 and proved works, and an invisible rectangle laid over the
|
|
well would sit on top of every level mark in it.
|
|
"""
|
|
cx, cy = x + w / 2.0, y + h / 2.0
|
|
s = ['<dataBar type="componentData" x="%.1f" y="%.1f" '
|
|
'top="%.1f" bottom="%.1f" left="%.1f" right="%.1f" '
|
|
'rotation="0.0" shear="0.0">' % (cx, cy, h / 2.0, h / 2.0, w / 2.0, w / 2.0),
|
|
"<htmlId>%d</htmlId>" % self.hid(),
|
|
"<fillOn>true</fillOn>",
|
|
'<background type="paint"><paint type="colorSet" r="%d" g="%d" b="%d"/>'
|
|
"</background>" % (back or C["SF"]),
|
|
'<foreground type="paint"><paint type="colorSet" r="%d" g="%d" b="%d"/>'
|
|
"</foreground>" % (fill or C["WTR"]),
|
|
"<lowLimit>%.1f</lowLimit><highLimit>%.1f</highLimit>" % (low, high),
|
|
'<growthDirection type="growthDirection" value="GrowthDirection%s"/>'
|
|
% ("Up" if up else "Down"),
|
|
'<data type="data"><action type="actionConnectTo">'
|
|
'<property type="property" name="dataBar.value"/>'
|
|
'<filter ref="%d"/>%s</action>%s</data>'
|
|
% (self.filt(), self.read_conn(item),
|
|
'<action type="actionActivateDisplay">'
|
|
'<event type="event" event="Clicked b1"/>'
|
|
'<type type="displayActivationType" value="ActivateSpecific"/>'
|
|
"<display>%s</display>"
|
|
"<layout>%s</layout><layoutFrame>default</layoutFrame>"
|
|
"</action>"
|
|
% (opens, "FixedPopup" if opens.startswith("WRPS_FP") else "default")
|
|
if opens else ""),
|
|
"</dataBar>"]
|
|
self.parts.append("".join(s))
|
|
|
|
def micro_trend(self, x, y, w, h, item, scale_max):
|
|
"""A strip trend of one item, under or over the box that reads it.
|
|
|
|
Transcribed from the two the user drew by hand in CI View on
|
|
2026-08-17, so the generated ones are the same component and not a
|
|
near miss. Three things in here are CI View's shape, not a choice:
|
|
|
|
- the item arrives through `microTrend.itemName[0]` with
|
|
`<itemAttribute>ItemName</itemAttribute>` - the trend is told
|
|
WHICH item to pull history for, so it wants the name, not the
|
|
value. Every other component here binds ItemValue.
|
|
- `realTime` and `fillWithHistory` are bound to the same item, as
|
|
a bare shared connection with no itemAttribute. They read as
|
|
booleans, which is odd for a flow in m3/h; it is what the editor
|
|
wrote for a trend that works, so it is what is written here.
|
|
- `actionInternalServiced` leads the data block and takes no body.
|
|
|
|
`scaleMax` is the only scale set; the minimum stays at the component
|
|
default of 0, which is right for a level in % and a flow in m3/h.
|
|
"""
|
|
cx, cy = x + w / 2.0, y + h / 2.0
|
|
src = self.plain_conn(item)
|
|
bind = ('<action type="actionConnectTo">'
|
|
'<property type="property" name="microTrend.%s"/>'
|
|
'<filter ref="%d"/><connection ref="%d"/></action>')
|
|
s = ['<microTrend type="componentData" x="%.1f" y="%.1f" '
|
|
'top="%.1f" bottom="%.1f" left="%.1f" right="%.1f" '
|
|
'rotation="0.0" shear="0.0">' % (cx, cy, h / 2.0, h / 2.0, w / 2.0, w / 2.0),
|
|
"<htmlId>%d</htmlId>" % self.hid(),
|
|
'<microTrendBackgroundFillColor ref="%d"/>' % self.named_colour("gainsboro"),
|
|
'<property ref="%d"/>' % self.comp_prop("itemName[0]"),
|
|
'<property type="componentProperty"><name>scaleMax[0]</name>'
|
|
"<value>%.1f</value></property>" % scale_max,
|
|
'<data type="data">',
|
|
'<action type="actionInternalServiced"/>',
|
|
'<action type="actionConnectTo">'
|
|
'<property type="property" name="microTrend.itemName[0]"/>'
|
|
'<filter ref="%d"/>'
|
|
'<connection type="connection"><direction>1</direction>'
|
|
"<itemName>%s</itemName><itemId>%s</itemId>"
|
|
"<itemAttribute>ItemName</itemAttribute></connection></action>"
|
|
% (self.filt(), item, item_id(item)),
|
|
bind % ("realTime", self.filt(), src),
|
|
bind % ("fillWithHistory", self.filt(), src),
|
|
"</data>",
|
|
"</microTrend>"]
|
|
self.parts.append("".join(s))
|
|
|
|
def button(self, x, y, w, h, label, item=None, write=None, display=None,
|
|
size=15, close=False):
|
|
"""WRPS_Button: writes a value, opens a display, or both."""
|
|
cx, cy = x + w / 2.0, y + h / 2.0
|
|
acts = []
|
|
if item is not None and write is not None:
|
|
acts.append('<action type="actionSetItemAttribute">'
|
|
'<event type="event" event="On action"/>'
|
|
'<filter ref="%d"/><connection ref="%d"/></action>'
|
|
% (self.filt(str(write)), self.conn(item, direction=2)))
|
|
if close:
|
|
# closes the popup it lives on, rather than activating another
|
|
# display over the top of it
|
|
acts.append('<action type="actionExitDisplay">'
|
|
'<event type="event" event="On action"/></action>')
|
|
if display:
|
|
acts.append('<action type="actionActivateDisplay">'
|
|
'<event type="event" event="On action"/>'
|
|
'<type type="displayActivationType" value="ActivateSpecific"/>'
|
|
"<display>%s</display>"
|
|
"<layout>%s</layout><layoutFrame>default</layoutFrame>"
|
|
"</action>" % (display, "FixedPopup" if display.startswith("WRPS_FP")
|
|
else "default"))
|
|
# Element is <button>, geometry as attributes, label in <label> -
|
|
# all three per the component definition and adtTrail.xml.
|
|
s = ['<button type="componentData" x="%.1f" y="%.1f" top="%.1f" bottom="%.1f" '
|
|
'left="%.1f" right="%.1f">' % (cx, cy, h / 2.0, h / 2.0, w / 2.0, w / 2.0),
|
|
"<htmlId>%d</htmlId>" % self.hid(),
|
|
"<label>%s</label>" % esc(label),
|
|
'<font type="font" name="%s" bold="true" size="%d" underline="false" '
|
|
'strikethrough="false"/>' % (LAB, size),
|
|
'<foregroundColor ref="%d"/>' % self.paint(C["T1"]),
|
|
'<backgroundColor ref="%d"/>' % self.paint(C["SF"]),
|
|
('<data ref="%d"/>' % self.data("".join(acts))) if acts else "",
|
|
"</button>"]
|
|
self.parts.append("".join(s))
|
|
|
|
# --- kit components -------------------------------------------------
|
|
def panel(self, x, y, w, h, header):
|
|
self.rect(x, y, w, h, C["SF"], C["BD"], 1.0)
|
|
self.rect(x, y, w, 34, C["SF"], C["BD2"], 1.0)
|
|
self.text(x + 12, y + 17, header, size=13, colour=C["T2"], font=VAL)
|
|
|
|
def value_box(self, x, y, w, tag, item, units="", decimals=0, big=False,
|
|
editable=False):
|
|
"""WRPS_Value. Every analog value on every display is this.
|
|
|
|
`editable` swaps the read-only number for a numberField, so the
|
|
box an operator sets a setpoint in looks like every other box on
|
|
the screen rather than a foreign widget.
|
|
"""
|
|
h = 74 if big else 56
|
|
self.rect(x, y, w, h, C["SF2"], C["BD2"], 1.0)
|
|
self.rect(x, y, 5, h, C["BD"], C["BD"], 1.0) # left rule
|
|
self.text(x + 14, y + 15, tag, size=12, colour=C["T2"], font=VAL)
|
|
if editable:
|
|
self.number_field(x + 12, y + 24, w - 70, 26, item)
|
|
else:
|
|
self.number(x + 14, y + (46 if big else 37), item,
|
|
size=30 if big else 21, decimals=decimals)
|
|
if units:
|
|
self.text(x + w - 12, y + (52 if big else 41), units,
|
|
size=12, colour=C["T2"], font=VAL, anchor="right")
|
|
return h
|
|
|
|
def state_box(self, x, y, w, label, colour, bg):
|
|
self.rect(x, y, w, 34, bg, colour, 1.5)
|
|
self.rect(x + 9, y + 12, 10, 10, colour, colour, 1.0)
|
|
self.text(x + 26, y + 17, label, size=14, colour=colour, bold=True, font=VAL)
|
|
|
|
def instrument(self, cx, cy, letters, loop, r=30):
|
|
self.ellipse(cx, cy, r, C["SF"], C["PRC"], 1.8)
|
|
self.text(cx, cy - 9, letters, size=13, colour=C["T1"], bold=True, font=VAL, anchor="centre")
|
|
self.text(cx, cy + 10, loop, size=13, colour=C["T1"], bold=True, font=VAL, anchor="centre")
|
|
|
|
# --- output ---------------------------------------------------------
|
|
def xml(self):
|
|
when = datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S.000 AEST")
|
|
refs = "".join('<intRef id="%d">%s</intRef>' % (i, x) for i, x in self.refs)
|
|
vis = "".join(
|
|
'<visibilityGroup type="componentData"><htmlId>%d</htmlId><name>%s</name>'
|
|
"<description>%s</description><minimumZoomEnabled>true</minimumZoomEnabled>"
|
|
"<minimumZoomFactor>%s</minimumZoomFactor></visibilityGroup>"
|
|
% (i + 2, n, d, z) for i, (n, d, z) in enumerate([
|
|
("Overview", "Always shown", "10.0"),
|
|
("Rough", "Shown when viewing a large area", "25.0"),
|
|
("Standard", "Shown when using the default view setting", "100.0"),
|
|
("Detail", "Shown only when viewing a small area", "400.0"),
|
|
("Intricacies", "Shown only when viewing a very small area", "1000.0"),
|
|
]))
|
|
head = (
|
|
'<?xml version="1.0" encoding="utf-8" ?>\n'
|
|
'<visualization protocolVersion="1.3.0.0">\n'
|
|
" <globalSection>%s</globalSection>\n"
|
|
' <coreObjectDefinition type="displayDefinition">\n'
|
|
' <version type="version" value="1.3.0.0"/>\n'
|
|
" <width>%d</width><height>%d</height>\n"
|
|
" <referenceCheck>3</referenceCheck>\n"
|
|
' <defaultBgColor type="colorSet" r="%d" g="%d" b="%d"/>\n'
|
|
' <defaultFgColor type="colorSet" r="%d" g="%d" b="%d"/>\n'
|
|
' <defaultFont type="font" name="%s" size="14" underline="false" strikethrough="false"/>\n'
|
|
' <defaultStroke type="stroke" width="1.0"/>\n'
|
|
' <grid type="grid" gridVisible="true" snappingActive="true" '
|
|
'verticalSnapInterval="4" horizontalSnapInterval="4" onTop="false">'
|
|
'<color type="colorSet" r="0" g="0" b="0"/></grid>\n'
|
|
' <revisionHistory type="revisionHistory">'
|
|
'<revision type="revision" who="ADMIN" when="%s" what="Generated by '
|
|
'build_display.py" where="PXiSEDev"/></revisionHistory>\n'
|
|
" <blinkDelay>700</blinkDelay><blinkEnabled>true</blinkEnabled>\n"
|
|
" <mousePassThrough>false</mousePassThrough>\n"
|
|
" %s\n"
|
|
' <visualizationLayer type="componentData"><htmlId>1</htmlId>'
|
|
"<name>Layer1</name></visualizationLayer>\n"
|
|
" <componentCountHint>%d</componentCountHint>\n"
|
|
) % (refs, self.w, self.h, C["BG"][0], C["BG"][1], C["BG"][2],
|
|
C["T1"][0], C["T1"][1], C["T1"][2], LAB, when, vis, len(self.parts))
|
|
return head + "\n".join(" " + p for p in self.parts) + \
|
|
"\n </coreObjectDefinition>\n</visualization>\n"
|
|
|
|
|
|
def esc(s):
|
|
return (s.replace("&", "&").replace("<", "<").replace(">", ">"))
|
|
|
|
|
|
# =====================================================================
|
|
# WRPS_Overview - the single operator display
|
|
# =====================================================================
|
|
PUMP_STATES = [(0, "OFF"), (1, "IDLE"), (2, "STARTING"), (3, "RUNNING"),
|
|
(4, "STOPPING"), (5, "TRIPPED"), (6, "LOCKED OUT")]
|
|
STN_STATES = [(0, "OFF", "T2"), (1, "IDLE", "T2"), (2, "PUMPING", "RUN"),
|
|
(3, "HIGH LEVEL", "ALM"), (4, "EMERGENCY - LSHH", "ALM"),
|
|
(5, "DRY RUN LOCKOUT", "ALM"), (6, "FAULT", "ALM")]
|
|
# ladder marks: % of the spill weir, label, is it an alarm level, and the
|
|
# setpoint behind it - None where the level is PHYSICAL and cannot be typed.
|
|
# The spill weir is concrete and the two float switches are bolted to the
|
|
# wall at a height; no operator moves those, so those three marks stay put
|
|
# and stay a plain string. The other five are numbers someone can change,
|
|
# and a mark drawn at a height that no longer matches its setpoint is a
|
|
# screen telling a lie, so those five follow their item.
|
|
LADDER = [(100, "SPILL WEIR", 1, None),
|
|
(91.7, "LSHH-102", 1, None),
|
|
(86.7, "HIGH ALARM", 1, ".SP.HIGH_ALARM"),
|
|
(83.3, "START P3", 0, ".SP.START_P3"),
|
|
(75, "START P2", 0, ".SP.START_P2"),
|
|
(66.7, "START DUTY", 0, ".SP.START_DUTY"),
|
|
(16.7, "STOP ALL", 0, ".SP.STOP_ALL"),
|
|
(10, "LSLL-103", 0, None)]
|
|
|
|
|
|
def station_header(d):
|
|
"""The title bar, shared by every full-size display.
|
|
|
|
Lifted out of overview() when WRPS_Tank was added. It is the same bar
|
|
on both screens by construction rather than by someone remembering to
|
|
copy a change across, which is the failure this file keeps having.
|
|
|
|
DUTY stays on the tank screen even though no pump is drawn there: it
|
|
says which pump the levels are about to start, which is exactly what
|
|
someone editing those levels wants to know.
|
|
"""
|
|
d.rect(0, 0, W, 76, C["SF"], C["BD"], 2.0)
|
|
d.text(28, 40, "WATERLOO ROAD PUMP STATION", size=26, bold=True)
|
|
d.text(520, 44, "WW-101", size=14, colour=C["T2"], font=VAL)
|
|
|
|
d.text(1180, 26, "STATION", size=11, colour=C["T2"], font=VAL)
|
|
for value, label, tone in STN_STATES:
|
|
p = d.equals(NS + ".STN.STATE", value, "STATE_%d" % value)
|
|
d.rect(1176, 38, 230, 30, C["SF2"], C["BD2"], 1.0, show_param=p)
|
|
d.text(1186, 53, label, size=15, bold=True, colour=C[tone], font=VAL,
|
|
show_param=p)
|
|
|
|
d.text(1430, 26, "MODE", size=11, colour=C["T2"], font=VAL)
|
|
for value, label, tone in [(1, "AUTO", "RUN"), (2, "OFF", "T2")]:
|
|
p = d.equals(NS + ".SP.MODE", value, "MODE_%d" % value)
|
|
d.rect(1426, 38, 120, 30, C["SF2"], C["BD2"], 1.0, show_param=p)
|
|
d.text(1436, 53, label, size=15, bold=True, colour=C[tone], font=VAL,
|
|
show_param=p)
|
|
|
|
d.text(1570, 26, "DUTY", size=11, colour=C["T2"], font=VAL)
|
|
d.rect(1566, 38, 130, 30, C["SF2"], C["BD2"], 1.0)
|
|
for n in (1, 2, 3):
|
|
p = d.equals(NS + ".STN.DUTY_PUMP", n, "DUTY_%d" % n)
|
|
d.text(1576, 53, "PU-30%d" % n, size=15, bold=True, font=VAL, show_param=p)
|
|
d.text(1576, 53, "NONE", size=15, colour=C["T2"], font=VAL,
|
|
show_param=d.equals(NS + ".STN.DUTY_PUMP", 0, "DUTY_0"))
|
|
|
|
# exceptions only: an operator should see these, not hunt for a 0
|
|
for item, label in [(".STN.HIGH_LEVEL", "HIGH LEVEL"), (".STN.SPILL_ACTIVE", "SPILLING")]:
|
|
d.rect(1726, 38, 170, 30, C["ALMBG"], C["ALM"], 1.5, show_item=NS + item)
|
|
d.text(1736, 53, label, size=13, bold=True, colour=C["ALM"], font=VAL,
|
|
show_item=NS + item)
|
|
|
|
|
|
|
|
def draw_well(d, WX, WY, WW, WH, opens=None):
|
|
"""The wet well and its level ladder - the one drawing both screens share.
|
|
|
|
WRPS_Tank is the overview's well with the plant either side of it taken
|
|
away, so the well is drawn from here on both. Duplicating it would mean
|
|
the marks agreeing today and drifting the first time a percentage moves;
|
|
the ladder is the CONTRACT between what the PLC does and what the screen
|
|
claims, and it can only be in one place.
|
|
|
|
`opens` makes the water itself the click target for another display.
|
|
"""
|
|
PER = WH / 100.0
|
|
PER = WH / 100.0
|
|
d.rect(WX, WY, WW, WH, C["SF"], C["PRC"], 3.0)
|
|
# the well is the way in to the setpoints: there is no SETPOINTS button,
|
|
# and clicking the thing the levels are ABOUT is a better target than one
|
|
d.data_bar(WX + 3, WY + 3, WW - 6, WH - 6, NS + ".STN.LEVEL", 0.0, 100.0,
|
|
opens=opens)
|
|
d.text(WX, WY - 14, "WET WELL WW-101 . 120 m2", size=13, bold=True)
|
|
# 0% is the well floor at WY+WH, 100% the spill weir at WY - the two ends
|
|
# the marks are interpolated between, and what a moving mark rides on
|
|
Y0, Y100 = WY + WH, WY
|
|
LBLY = 13 # label sits this far under its own line
|
|
for pct, label, alarm, item in LADDER:
|
|
y = WY + WH - pct * PER
|
|
tone = C["ALM"] if alarm else C["T2"]
|
|
if item is None: # physical: concrete and float switches
|
|
# LEFT wall, while everything adjustable is on the RIGHT. An
|
|
# adjustable mark can be driven to any height, so sooner or later
|
|
# it lands on a fixed one; the two kinds therefore never share a
|
|
# column. Outside the well was the other option and is not
|
|
# available - the FIT-201 trend runs straight across the 100% line.
|
|
d.line(WX, y, WX + WW, y, C["BD"], 1.0)
|
|
d.text(WX + 8, y + LBLY, "%g%% %s" % (pct, label), size=11,
|
|
colour=tone, font=VAL)
|
|
continue
|
|
# adjustable: line, live number and label all ride the same setpoint.
|
|
# The label keeps the right edge it has always had; the number sits
|
|
# immediately left of it, so the reading grows leftwards and the
|
|
# column stays flush against the wall of the well.
|
|
it = NS + item
|
|
tail = "%% %s" % label # "% START DUTY" - the number supplies 86.7
|
|
numw = text_width("999.9", 11, VAL)
|
|
numx = WX + WW - 8 - text_width(tail, 11, VAL) - numw
|
|
d.line(WX, y, WX + WW, y, C["BD"], 1.0,
|
|
move=d.moves_y("rectangle", it, Y0, Y100))
|
|
d.number(numx, y + LBLY, it, size=11, colour=tone, bold=False,
|
|
width=numw, move=d.moves_y("number", it, Y0 + LBLY, Y100 + LBLY))
|
|
d.text(WX + WW - 8, y + LBLY, tail, size=11, colour=tone, font=VAL,
|
|
anchor="right", move=d.moves_y("text", it, Y0 + LBLY, Y100 + LBLY))
|
|
# 34 below the floor, not 22: a mark driven to 0% puts its label at the
|
|
# floor + 13, and at 22 the two interleave. Found by the sweep in
|
|
# check_layout.py, which is the only thing that can see it.
|
|
d.text(WX, WY + WH + 34, "0 - 6.00 m . 100% = SPILL WEIR", size=11,
|
|
colour=C["T2"], font=VAL)
|
|
|
|
|
|
def overview():
|
|
"""The operator display.
|
|
|
|
Laid out against a rendered preview rather than in a diff, one region
|
|
at a time. The rules that came out of that:
|
|
|
|
- a reading belongs beside the instrument that produces it, not in
|
|
a rail on the far side of the screen
|
|
- nothing sits on a pipe, and the inlet main runs INTO the well
|
|
rather than stopping at the wall
|
|
- the ladder is computed from the percentages, so 10% is at a tenth
|
|
of the height, and every label sits the same distance below its
|
|
own line
|
|
- state is shown as a word and a colour, never as an enum number
|
|
"""
|
|
d = Display("WRPS Overview")
|
|
|
|
station_header(d)
|
|
|
|
# ---- process --------------------------------------------------------
|
|
d.panel(24, 96, 1872, 812, "PROCESS")
|
|
|
|
# inlet, with FIT-201 and its reading clear of everything else
|
|
d.instrument(88, 272, "FIT", "201", r=27)
|
|
d.line(88, 299, 88, 390, C["T2"], 1.6, True)
|
|
d.value_box(140, 240, 250, "FIT-201 INLET FLOW", NS + ".STN.INFLOW", "m3/h", 1)
|
|
d.micro_trend(140, 306, 250, 56, NS + ".STN.INFLOW", 1100)
|
|
d.line(60, 390, 470, 390, C["PRC"], 3.0) # runs INTO the well
|
|
d.text(140, 418, "DN600 INLET GRAVITY MAIN", size=12, colour=C["T2"])
|
|
|
|
WX, WY, WW, WH = 440, 330, 300, 500
|
|
draw_well(d, WX, WY, WW, WH, opens="WRPS_Tank")
|
|
|
|
# LIT-101 beside its own readings; leader drops and runs in underneath
|
|
d.instrument(88, 488, "LIT", "101", r=27)
|
|
d.line(88, 515, 88, 668, C["T2"], 1.6, True)
|
|
d.line(88, 668, WX, 668, C["T2"], 1.6, True)
|
|
d.value_box(140, 448, 280, "LIT-101 WET WELL LEVEL", NS + ".STN.LEVEL", "%", 1,
|
|
big=True)
|
|
d.micro_trend(140, 528, 280, 56, NS + ".STN.LEVEL", 100)
|
|
# 598, not 564: the level trend above it needs the 34 px
|
|
d.value_box(140, 598, 280, "LEVEL SETPOINT", NS + ".SP.LEVEL_SP", "%", 1,
|
|
editable=True)
|
|
|
|
# overflow: plain text, not a bordered box - a box reads as a button
|
|
d.line(WX + WW, 344, WX + WW + 70, 344, C["PRC"], 3.0)
|
|
d.text(818, 349, "OVERFLOW TO ERS", size=11, colour=C["T2"], font=VAL)
|
|
|
|
# pumps, left of the header so no reading sits on a pipe
|
|
CXP, R, BX, BW, HDR = 900, 34, 980, 210, 1300
|
|
for i, cy in enumerate((450, 590, 730)):
|
|
n = i + 1
|
|
run = "%s.PU30%d.RUNNING" % (NS, n)
|
|
trip = "%s.PU30%d.TRIPPED" % (NS, n)
|
|
d.line(WX + WW, cy, CXP - R, cy, C["PRC"], 3.0)
|
|
d.line(CXP + R, cy, HDR, cy, C["PRC"], 3.0)
|
|
d.ellipse(CXP, cy, R, C["SF2"], C["PRC"], 2.4)
|
|
d.ellipse(CXP, cy, R, C["RUNBG"], C["RUN"], 3.0, show_item=run)
|
|
d.ellipse(CXP, cy, R, C["ALMBG"], C["ALM"], 3.0, show_item=trip)
|
|
d.line(CXP - R, cy, CXP + R, cy, C["PRC"], 1.2)
|
|
d.line(CXP, cy - R, CXP, cy + R, C["PRC"], 1.2)
|
|
d.text(CXP - 32, cy + 58, "PU-30%d" % n, size=14, bold=True, font=VAL)
|
|
d.text(CXP - 32, cy + 76, "DUTY", size=11, bold=True, colour=C["AC"], font=VAL,
|
|
show_param=d.equals(NS + ".STN.DUTY_PUMP", n, "DUTYP_%d" % n))
|
|
|
|
d.rect(BX, cy - 64, BW, 52, C["SF2"], C["BD2"], 1.0)
|
|
d.rect(BX, cy - 64, 5, 52, C["RUN"], C["RUN"], 1.0)
|
|
d.text(BX + 14, cy - 46, "PU-30%d" % n, size=11, colour=C["T2"], font=VAL)
|
|
for value, label in PUMP_STATES: # one word, never an enum
|
|
p = d.equals("%s.PU30%d.STATE" % (NS, n), value, "P%dSTATE_%d" % (n, value))
|
|
tone = C["RUN"] if value == 3 else (C["ALM"] if value in (5, 6) else C["T1"])
|
|
d.text(BX + 14, cy - 24, label, size=14, bold=True, colour=tone, font=VAL,
|
|
show_param=p)
|
|
d.number(BX + BW - 70, cy - 30, "%s.PU30%d.RUN_HOURS" % (NS, n), size=12, width=44)
|
|
d.text(BX + BW - 10, cy - 24, "h", size=11, colour=C["T2"], font=VAL, anchor="right")
|
|
d.hit_area(CXP - R - 2, cy - R - 2, 2 * R + 4, 2 * R + 4, "WRPS_FP_PU30%d" % n)
|
|
|
|
# speed and discharge trends sit ABOVE their boxes: below the speed box is
|
|
# the dashed leader down to the pumps, and below the discharge box is the
|
|
# rising main at y=590. Nothing else has to move to make room.
|
|
d.micro_trend(BX, 170, BW, 56, NS + ".STN.SPEED", 100)
|
|
d.value_box(BX, 240, BW, "VSD-301/2/3 SPEED", NS + ".STN.SPEED", "%", 1)
|
|
d.line(1085, 304, 1085, 386, C["T2"], 1.6, True)
|
|
|
|
# discharge header and FIT-301
|
|
d.line(HDR, 450, HDR, 730, C["PRC"], 3.0)
|
|
d.line(HDR, 590, 1820, 590, C["PRC"], 3.0)
|
|
d.text(1560, 614, "RISING MAIN . DN400", size=12, colour=C["T2"])
|
|
d.instrument(1500, 512, "FIT", "301", r=27)
|
|
d.line(1500, 539, 1500, 590, C["T2"], 1.6, True)
|
|
d.micro_trend(1542, 418, 240, 56, NS + ".STN.DISCHARGE", 1400)
|
|
d.value_box(1542, 480, 240, "FIT-301 DISCHARGE", NS + ".STN.DISCHARGE", "m3/h", 1)
|
|
|
|
# ---- simulation band ------------------------------------------------
|
|
SY = 928
|
|
d.rect(0, SY, W, H - SY, C["SF"], C["BD"], 1.0)
|
|
d.rect(0, SY, W, 4, C["AC"], C["AC"], 1.0)
|
|
d.text(28, SY + 30, "SIMULATION", size=14, bold=True, colour=C["AC"], font=VAL)
|
|
d.text(28, SY + 52, "NOT PLANT CONTROL", size=11, colour=C["AC"], font=VAL)
|
|
d.text(28, SY + 78, "ACTIVE:", size=11, colour=C["T2"], font=VAL)
|
|
|
|
SCEN = [(0, "MANUAL", 206, ["inflow set by the", "box beside it"]),
|
|
(1, "DIURNAL", 612, ["dry weather day,", "144-396 m3/h over 24 h"]),
|
|
(2, "WET WEATHER", 770, ["storm: ramps to", "1080 m3/h, then decays"]),
|
|
(3, "DEMO REF", 928, ["held at 594 m3/h,", "starts at 4.00 m"])]
|
|
d.text(206, SY + 24, "SCENARIO - inflow the wet well sees", size=11,
|
|
colour=C["T2"], font=VAL)
|
|
for value, label, x, desc in SCEN:
|
|
p = d.equals(NS + ".SIM.SCENARIO", value, "SCEN_%d" % value)
|
|
d.rect(x - 4, SY + 34, 154, 54, C["ACBG"], C["AC"], 2.5, show_param=p)
|
|
d.button(x, SY + 38, 146, 46, label, NS + ".SIM.SCENARIO", value, size=13)
|
|
for k, line in enumerate(desc):
|
|
d.text(x, SY + 104 + k * 13, line, size=10, colour=C["T2"], font=VAL)
|
|
d.text(88, SY + 78, label, size=11, bold=True, colour=C["AC"], font=VAL,
|
|
show_param=p)
|
|
|
|
# the manual inflow lives next to MANUAL: nothing else uses it
|
|
d.value_box(366, SY + 32, 230, "SIM INFLOW", NS + ".SIM.INFLOW", "m3/h", 1,
|
|
editable=True)
|
|
d.text(366, SY + 104, "type an inflow, used", size=10, colour=C["T2"], font=VAL)
|
|
d.text(366, SY + 117, "by MANUAL only", size=10, colour=C["T2"], font=VAL)
|
|
|
|
d.text(1124, SY + 24, "TIME SCALE - simulated clock", size=11, colour=C["T2"], font=VAL)
|
|
for i, (s, desc) in enumerate([(1, "real time"), (10, "10x clock"),
|
|
(30, "30x clock"), (60, "60x clock")]):
|
|
x = 1124 + i * 96
|
|
p = d.equals(NS + ".SIM.TIME_SCALE", s, "TS_%d" % s)
|
|
d.rect(x - 4, SY + 34, 92, 54, C["ACBG"], C["AC"], 2.5, show_param=p)
|
|
d.button(x, SY + 38, 84, 46, "%dx" % s, NS + ".SIM.TIME_SCALE", s, size=13)
|
|
d.text(x, SY + 104, desc, size=10, colour=C["T2"], font=VAL)
|
|
|
|
d.button(1528, SY + 38, 180, 46, "RESET SCENARIO", NS + ".SIM.RESET", 1, size=13)
|
|
d.text(1528, SY + 104, "restart the sim clock,", size=10, colour=C["T2"], font=VAL)
|
|
d.text(1528, SY + 117, "level back to 3.50 m", size=10, colour=C["T2"], font=VAL)
|
|
return d
|
|
|
|
|
|
def tank():
|
|
"""WRPS_Tank - the well at full size, with the numbers that shape it.
|
|
|
|
The overview screen with the plant either side of the well taken away:
|
|
no inlet, no rising main, no pumps. What replaces them is the setpoint
|
|
faceplate's own idea, at the size it deserves - the levels are marks on
|
|
the well and the entries sit beside it, so the number being typed and
|
|
the height it means are one glance apart.
|
|
|
|
The faceplate draws a small well of its own to carry those marks. Here
|
|
that would be the same ladder twice at two scales, so the panel is rows
|
|
only and the marks are the real well's.
|
|
"""
|
|
d = Display("WRPS Tank")
|
|
station_header(d)
|
|
|
|
# ---- the well -------------------------------------------------------
|
|
d.panel(24, 96, 780, 812, "WET WELL")
|
|
# no `opens`: this IS the screen the overview's well opens
|
|
WX, WY, WW, WH = 440, 330, 300, 500
|
|
draw_well(d, WX, WY, WW, WH)
|
|
|
|
# LIT-101 beside its own readings; leader drops and runs in underneath
|
|
d.instrument(88, 488, "LIT", "101", r=27)
|
|
d.line(88, 515, 88, 668, C["T2"], 1.6, True)
|
|
d.line(88, 668, WX, 668, C["T2"], 1.6, True)
|
|
d.value_box(140, 448, 280, "LIT-101 WET WELL LEVEL", NS + ".STN.LEVEL", "%", 1,
|
|
big=True)
|
|
d.micro_trend(140, 528, 280, 56, NS + ".STN.LEVEL", 100)
|
|
# the overview's LEVEL SETPOINT box is deliberately NOT repeated here:
|
|
# it is a row in the panel opposite, and two entry fields writing one
|
|
# item on one screen is an invitation to wonder which one is real
|
|
d.button(48, 836, 200, 48, "OVERVIEW", display="WRPS_Overview")
|
|
|
|
# ---- the setpoints --------------------------------------------------
|
|
PX = 828
|
|
d.panel(PX, 96, 1068, 812, "STATION SETPOINTS")
|
|
d.text(PX + 40, 152, "levels in % of the 6.00 m spill weir . type a value to change it",
|
|
size=12, colour=C["T2"], font=VAL)
|
|
|
|
LX, FX, UX = PX + 40, PX + 652, PX + 808
|
|
LEVELS = [(".SP.HIGH_ALARM", "HIGH LEVEL ALARM", "ALM",
|
|
"annunciates only - it starts no pump"),
|
|
(".SP.START_P3", "START PUMP 3", "T1",
|
|
"the third pump joins duty and lag at this level"),
|
|
(".SP.START_P2", "START PUMP 2", "T1",
|
|
"the second pump joins the duty pump at this level"),
|
|
(".SP.LEVEL_SP", "LEVEL CONTROL SETPOINT", "AC",
|
|
"the level the drive holds while pumping"),
|
|
(".SP.START_DUTY", "START DUTY", "T1",
|
|
"the duty pump starts at this level"),
|
|
(".SP.STOP_ALL", "STOP ALL", "T1",
|
|
"every pump stops at this level")]
|
|
for i, (item, label, tone, why) in enumerate(LEVELS):
|
|
ry = 186 + i * 68
|
|
d.text(LX, ry, label, size=15, bold=True, colour=C[tone])
|
|
d.text(LX, ry + 20, why, size=11, colour=C["T2"])
|
|
d.number_field(FX, ry - 16, 140, 32, NS + item)
|
|
d.text(UX, ry, "%", size=12, colour=C["T2"], font=VAL)
|
|
|
|
d.line(LX, 582, PX + 1028, 582, C["BD2"], 1.0)
|
|
d.text(LX, 608, "NOT A LEVEL", size=11, colour=C["T2"], font=VAL)
|
|
for i, (item, label, unit, why) in enumerate(
|
|
[(".SP.MIN_SPEED", "MINIMUM DRIVE SPEED", "%",
|
|
"38 Hz floor - below it the 22 m static lift gives no delivery"),
|
|
(".SP.SERVICE_HRS", "SERVICE INTERVAL", "h",
|
|
"run hours between services, counted per pump")]):
|
|
ry = 648 + i * 68
|
|
d.text(LX, ry, label, size=15, bold=True)
|
|
d.text(LX, ry + 20, why, size=11, colour=C["T2"])
|
|
d.number_field(FX, ry - 16, 140, 32, NS + item)
|
|
d.text(UX, ry, unit, size=12, colour=C["T2"], font=VAL)
|
|
|
|
d.text(LX, 792, "a start level above 100% - the spill weir - is rejected by the PLC",
|
|
size=11, colour=C["T2"], font=VAL)
|
|
d.text(LX, 814, "writes go straight to the PLC, which clamps and rejects bad values",
|
|
size=11, colour=C["T2"], font=VAL)
|
|
return d
|
|
|
|
|
|
def fp_pump(n):
|
|
"""One faceplate per pump.
|
|
|
|
Generated three times rather than once, because a single display
|
|
cannot name the pump that opened it without binding an item through a
|
|
display parameter - a mechanism the AOG library uses but which I have
|
|
not verified. Three files cost nothing and cannot show PU-301's data
|
|
under PU-303's heading, which is what the old single faceplate did.
|
|
"""
|
|
tag = "PU-30%d" % n
|
|
P = "%s.PU30%d" % (NS, n)
|
|
d = Display("WRPS %s Faceplate" % tag, 760, 700)
|
|
d.rect(0, 0, 760, 700, C["SF"], C["BD"], 1.0)
|
|
d.rect(0, 70, 760, 4, C["AC"], C["AC"], 1.0)
|
|
d.text(24, 40, tag, size=26, bold=True)
|
|
d.text(150, 44, "TRANSFER PUMP %d . 75 kW . 120 L/s @ 32 m" % n, size=12,
|
|
colour=C["T2"], font=VAL)
|
|
d.rect(560, 20, 80, 30, C["SF2"], C["BD2"], 1.0,
|
|
show_param=d.equals(NS + ".STN.DUTY_PUMP", n, "ISDUTY_%d" % n))
|
|
d.text(570, 35, "DUTY", size=12, bold=True, colour=C["AC"], font=VAL,
|
|
show_param="ISDUTY_%d" % n)
|
|
d.rect(652, 20, 88, 30, C["RUNBG"], C["RUN"], 1.5, show_item=P + ".AVAILABLE")
|
|
d.text(662, 35, "AVAILABLE", size=12, bold=True, colour=C["RUN"], font=VAL,
|
|
show_item=P + ".AVAILABLE")
|
|
|
|
# state: the reason the faceplate was opened
|
|
d.rect(24, 96, 712, 78, C["SF2"], C["BD2"], 1.5)
|
|
d.rect(24, 96, 712, 78, C["RUNBG"], C["RUN"], 2.0, show_item=P + ".RUNNING")
|
|
d.rect(24, 96, 712, 78, C["ALMBG"], C["ALM"], 2.0, show_item=P + ".TRIPPED")
|
|
for value, label in PUMP_STATES:
|
|
p = d.equals(P + ".STATE", value, "FP%dSTATE_%d" % (n, value))
|
|
tone = C["RUN"] if value == 3 else (C["ALM"] if value in (5, 6) else C["T1"])
|
|
d.text(56, 130, label, size=26, bold=True, colour=tone, font=VAL, show_param=p)
|
|
d.text(56, 156, "pump state from the PLC", size=12, colour=C["T2"], font=VAL)
|
|
d.text(716, 122, "RUN HOURS", size=11, colour=C["T2"], font=VAL, anchor="right")
|
|
d.number(600, 152, P + ".RUN_HOURS", size=24, width=116)
|
|
|
|
d.text(24, 200, "THIS PUMP", size=11, colour=C["T2"], font=VAL)
|
|
d.rect(24, 210, 228, 58, C["SF2"], C["BD2"], 1.0)
|
|
d.text(38, 229, "RUN COMMAND", size=11, colour=C["T2"], font=VAL)
|
|
d.text(38, 253, "ON", size=16, bold=True, colour=C["RUN"], font=VAL,
|
|
show_item=P + ".RUN_CMD")
|
|
d.rect(266, 210, 228, 58, C["SF2"], C["BD2"], 1.0)
|
|
d.text(280, 229, "TRIPPED", size=11, colour=C["T2"], font=VAL)
|
|
d.text(280, 253, "TRIPPED", size=16, bold=True, colour=C["ALM"], font=VAL,
|
|
show_item=P + ".TRIPPED")
|
|
d.value_box(508, 210, 228, "DRIVE SPEED . common", NS + ".STN.SPEED", "%", 1)
|
|
|
|
d.text(24, 298, "STATION", size=11, colour=C["T2"], font=VAL)
|
|
d.rect(24, 308, 228, 52, C["SF2"], C["BD2"], 1.0)
|
|
d.text(38, 327, "STATION STATE", size=11, colour=C["T2"], font=VAL)
|
|
for value, label, tone in STN_STATES:
|
|
d.text(38, 350, label, size=14, bold=True, colour=C[tone], font=VAL,
|
|
show_param="STATE_%d" % value if False else
|
|
d.equals(NS + ".STN.STATE", value, "FP%dSTN_%d" % (n, value)))
|
|
d.rect(266, 308, 228, 52, C["SF2"], C["BD2"], 1.0)
|
|
d.text(280, 327, "DUTY PUMP", size=11, colour=C["T2"], font=VAL)
|
|
for k in (1, 2, 3):
|
|
d.text(280, 350, "PU-30%d" % k, size=14, bold=True, font=VAL,
|
|
show_param=d.equals(NS + ".STN.DUTY_PUMP", k, "FP%dDUTY_%d" % (n, k)))
|
|
d.value_box(508, 308, 228, "PUMPS RUNNING", NS + ".STN.PUMPS_RUNNING", "of 3", 0)
|
|
|
|
# manual control only exists when the station is OFF, so say so
|
|
d.text(24, 400, "MANUAL CONTROL", size=11, colour=C["T2"], font=VAL)
|
|
auto = d.equals(NS + ".SP.MODE", 1, "FP%dAUTO" % n)
|
|
off = d.equals(NS + ".SP.MODE", 2, "FP%dOFF" % n)
|
|
d.rect(24, 410, 712, 72, C["WRNBG"], C["WRN"], 1.5, show_param=auto)
|
|
d.text(44, 438, "STATION IS IN AUTO - manual commands do nothing", size=14,
|
|
bold=True, colour=C["WRN"], font=VAL, show_param=auto)
|
|
d.text(44, 462, "Put the station in OFF to start or stop a pump by hand.",
|
|
size=12, colour=C["WRN"], font=VAL, show_param=auto)
|
|
d.rect(24, 410, 348, 72, C["SF"], C["BD2"], 1.0, show_param=off)
|
|
d.rect(388, 410, 348, 72, C["SF"], C["BD2"], 1.0, show_param=off)
|
|
d.button(36, 422, 324, 48, "START", NS + ".SP.CMD_WORD", 2)
|
|
d.button(400, 422, 324, 48, "STOP", NS + ".SP.CMD_WORD", 3)
|
|
|
|
d.text(24, 514, "MAINTENANCE", size=11, colour=C["T2"], font=VAL)
|
|
d.button(24, 524, 228, 50, "LOCK OUT", NS + ".SP.CMD_WORD", 4)
|
|
d.button(266, 524, 228, 50, "RESET TRIP", NS + ".SP.CMD_WORD", 1)
|
|
d.button(508, 524, 228, 50, "RESET RUN HOURS", NS + ".SP.CMD_WORD", 5)
|
|
d.text(24, 592, "lock out and trip reset apply to %s . reset hours clears all three" % tag,
|
|
size=11, colour=C["T2"], font=VAL)
|
|
|
|
d.text(24, 630, "LAST COMMAND ACKNOWLEDGED", size=11, colour=C["T2"], font=VAL)
|
|
d.number(24, 660, NS + ".STN.CMD_ACK", size=16, width=60)
|
|
d.button(596, 632, 140, 46, "CLOSE", close=True)
|
|
return d
|
|
|
|
|
|
def fp_setpoints():
|
|
"""Setpoints drawn as levels, because that is what they are.
|
|
|
|
Eight identical boxes hid the one thing that matters about these
|
|
numbers: they are a stack of thresholds that has to stay in order.
|
|
Each one is now a mark on the well at its own height, against the
|
|
live level, with its entry field on the same line.
|
|
"""
|
|
d = Display("WRPS Setpoints", 900, 700)
|
|
d.rect(0, 0, 900, 700, C["SF"], C["BD"], 1.0)
|
|
d.rect(0, 70, 900, 4, C["AC"], C["AC"], 1.0)
|
|
d.text(24, 40, "STATION SETPOINTS", size=22, bold=True)
|
|
d.text(290, 44, "levels in % of the 6.00 m spill weir . type a value to change it",
|
|
size=12, colour=C["T2"], font=VAL)
|
|
|
|
LX, LY, LW, LH = 60, 120, 120, 440
|
|
PER = LH / 100.0
|
|
d.text(24, 104, "WET WELL", size=11, colour=C["T2"], font=VAL)
|
|
d.rect(LX, LY, LW, LH, C["SF"], C["PRC"], 2.5)
|
|
d.data_bar(LX + 2, LY + 2, LW - 4, LH - 4, NS + ".STN.LEVEL", 0.0, 100.0)
|
|
# the spill weir is the one level on this screen nobody can type, so it is
|
|
# the one tick that stays put - and it moves to the RIGHT of the well, out
|
|
# of the left column the six adjustable ticks slide up and down
|
|
d.line(LX - 14, LY, LX + LW + 14, LY, C["ALM"], 1.5)
|
|
d.text(LX + LW + 20, LY + 4, "100 SPILL WEIR", size=10, colour=C["ALM"], font=VAL)
|
|
|
|
# item, label, its live level for the mark, row y, tone
|
|
ROWS = [(".SP.HIGH_ALARM", "HIGH LEVEL ALARM", 86.7, 160, "ALM"),
|
|
(".SP.START_P3", "START PUMP 3", 83.3, 206, "T1"),
|
|
(".SP.START_P2", "START PUMP 2", 75.0, 252, "T1"),
|
|
(".SP.LEVEL_SP", "LEVEL CONTROL SETPOINT", 70.0, 298, "AC"),
|
|
(".SP.START_DUTY", "START DUTY", 66.7, 344, "T1"),
|
|
(".SP.STOP_ALL", "STOP ALL", 16.7, 390, "T1")]
|
|
# every mark here is adjustable - the fixed 100 above is the spill weir,
|
|
# and it is the only level on this screen nobody can type
|
|
Y0, Y100 = LY + LH, LY
|
|
TICKY = 4 # the tick number's offset from its line
|
|
numw = text_width("999.9", 10, VAL)
|
|
for item, label, pct, ry, tone in ROWS:
|
|
my = LY + LH - pct * PER
|
|
it = NS + item
|
|
colour = C[tone] if tone != "T1" else C["T2"]
|
|
# solid, not dashed: a dashed line here is 30 small rectangles and
|
|
# the dash convention belongs to instrument signals, not to a leader
|
|
d.line(LX - 14, my, 420, my, colour, 1.0,
|
|
move=d.moves_y("rectangle", it, Y0, Y100))
|
|
# the tick was a hard-coded "86.7"; it is the setpoint, so it reads it
|
|
d.number(LX - 20 - numw, my + TICKY, it, size=10, colour=colour, bold=False,
|
|
width=numw, move=d.moves_y("number", it, Y0 + TICKY, Y100 + TICKY))
|
|
# the ROW does not move - the entry fields are a list to work down,
|
|
# and a list that reorders itself as you type in it is unusable
|
|
d.text(440, ry + 16, label, size=13, bold=True, colour=C[tone])
|
|
d.number_field(700, ry, 110, 28, it)
|
|
d.text(826, ry + 20, "%", size=11, colour=C["T2"], font=VAL)
|
|
d.text(440, 440, "a start level above 100% - the spill weir - is rejected by the PLC",
|
|
size=11, colour=C["T2"], font=VAL)
|
|
|
|
d.line(440, 464, 860, 464, C["BD2"], 1.0)
|
|
d.text(440, 492, "NOT A LEVEL", size=11, colour=C["T2"], font=VAL)
|
|
d.text(440, 524, "MINIMUM DRIVE SPEED", size=13, bold=True)
|
|
d.number_field(700, 508, 110, 28, NS + ".SP.MIN_SPEED")
|
|
d.text(826, 528, "%", size=11, colour=C["T2"], font=VAL)
|
|
d.text(440, 552, "38 Hz floor - below it the 22 m static lift gives no delivery",
|
|
size=11, colour=C["T2"], font=VAL)
|
|
d.text(440, 588, "SERVICE INTERVAL", size=13, bold=True)
|
|
d.number_field(700, 572, 110, 28, NS + ".SP.SERVICE_HRS")
|
|
d.text(826, 592, "h", size=11, colour=C["T2"], font=VAL)
|
|
|
|
d.text(24, 661, "writes go straight to the PLC, which clamps and rejects bad values",
|
|
size=11, colour=C["T2"], font=VAL)
|
|
d.button(700, 632, 140, 46, "CLOSE", close=True)
|
|
return d
|
|
|
|
|
|
ITEM_FILE = Path(__file__).resolve().parents[1] / "modbus_points" / "wrps_item_df.qli"
|
|
|
|
|
|
def known_items():
|
|
"""Every item the CI Server import actually creates."""
|
|
import re
|
|
if not ITEM_FILE.is_file():
|
|
return None
|
|
return set(re.findall(r'"(AID\.WRPS\.[A-Z0-9_.]+)"', ITEM_FILE.read_text(encoding="utf-8")))
|
|
|
|
|
|
def report_unplaced(displays):
|
|
"""Say plainly what is NOT on any screen.
|
|
|
|
Kept honest by computing it from the built XML against the item
|
|
export, not from a list someone maintains by hand - a hand list is
|
|
exactly the thing that goes stale and lets a point vanish quietly.
|
|
"""
|
|
import re
|
|
bound = set()
|
|
for _, d in displays:
|
|
bound |= set(re.findall(r"<itemName>(AID\.WRPS\.[A-Z0-9_.]+)</itemName>", d.xml()))
|
|
everything = set(item_nsids())
|
|
missing = sorted(everything - bound)
|
|
print("")
|
|
print("items on a display : %d of %d" % (len(bound & everything), len(everything)))
|
|
if missing:
|
|
print("NOT DISPLAYED ANYWHERE (%d):" % len(missing))
|
|
for m in missing:
|
|
print(" %-22s %s" % (m.replace("AID.WRPS.", ""), UNPLACED_WHY.get(m, "")))
|
|
if UNPLACED_BUTTONS:
|
|
print("BUTTONS NOT PLACED (%d):" % len(UNPLACED_BUTTONS))
|
|
for b in UNPLACED_BUTTONS:
|
|
print(" %s" % b)
|
|
|
|
|
|
# Why each one is still off the screen, so the list is a decision record
|
|
# rather than a shrug. Emptied as they are placed.
|
|
UNPLACED_WHY = {
|
|
"AID.WRPS.STN.NET_ACCUM": "inflow minus discharge - no instrument to sit beside",
|
|
"AID.WRPS.STN.VOL_TO_SPILL": "derived from level - awaiting a home",
|
|
"AID.WRPS.STN.TIME_TO_SPILL": "derived from level - awaiting a home",
|
|
"AID.WRPS.STN.TIME_TO_LSHH": "derived from level - awaiting a home",
|
|
"AID.WRPS.STN.ALARM_WORD": "bitmask; needs decoding into named alarms, not a number",
|
|
"AID.WRPS.STN.IN_AUTO": "duplicated by SP.MODE, which the header already shows",
|
|
"AID.WRPS.SP.CMD_PARAM": "command plumbing - written with the command word, not read",
|
|
}
|
|
|
|
UNPLACED_BUTTONS = [
|
|
"AUTO / OFF - station mode, no device to sit beside",
|
|
"RESET TRIPS - station wide",
|
|
"RESET RUN HOURS - station wide (also on each pump faceplate)",
|
|
"SETPOINTS... - no button; the WELL opens WRPS_Tank, which carries them",
|
|
]
|
|
|
|
|
|
def check_ids(displays):
|
|
"""Every item a display binds must have a MEASURED id. All at once.
|
|
|
|
Reported as one list rather than failing on the first, because the
|
|
remedy is a single harvest that fixes all of them - being told about
|
|
them one build at a time would be its own small hell.
|
|
"""
|
|
import re
|
|
import sys
|
|
if not _ids:
|
|
load_ids()
|
|
used = set()
|
|
for _, d in displays:
|
|
used |= set(re.findall(r"<itemName>([^<]+)</itemName>", d.xml()))
|
|
missing = sorted(set(u for u in used if u not in _ids) | _missing)
|
|
if missing:
|
|
msg = ["BUILD FAILED: %d of %d bound items have no measured id."
|
|
% (len(missing), len(used)),
|
|
" These would render as 0 on a screen that looks healthy:"]
|
|
msg += [" " + m.replace("AID.WRPS.", "") for m in missing]
|
|
msg.append(HARVEST_ALL)
|
|
sys.exit(chr(10).join(msg))
|
|
print("ids %d/%d bound items measured" % (len(used), len(used)))
|
|
|
|
|
|
def check_items(displays):
|
|
"""Fail loudly if a display binds to an item that will not exist.
|
|
|
|
Cheap to check, and the failure mode otherwise is a display full of
|
|
silently dead values that looks fine in the editor.
|
|
"""
|
|
import re
|
|
have = known_items()
|
|
if have is None:
|
|
print("WARN %s not found - item names not checked" % ITEM_FILE.name)
|
|
return
|
|
bad = {}
|
|
for name, d in displays:
|
|
used = set(re.findall(r"<itemName>([^<]+)</itemName>", d.xml()))
|
|
missing = sorted(used - have)
|
|
if missing:
|
|
bad[name] = missing
|
|
if bad:
|
|
import sys
|
|
msg = ["BUILD FAILED: displays bind to items that do not exist."]
|
|
for name, items in bad.items():
|
|
msg.append(" %s:" % name)
|
|
msg += [" " + i for i in items]
|
|
msg.append(" Known items come from %s" % ITEM_FILE.name)
|
|
sys.exit(chr(10).join(msg))
|
|
|
|
|
|
def harvest(path):
|
|
"""Read itemName/itemId pairs out of a display CI View has saved.
|
|
|
|
Ids from an earlier generation are DROPPED, not merged. A re-import
|
|
renumbers every item, so a file holding 24 fresh ids and 25 stale
|
|
ones is worse than one holding 24 - the stale ones bind silently to
|
|
nothing, and the derived-base check cannot tell which generation is
|
|
current. Anything the fresh harvest disagrees with goes.
|
|
"""
|
|
import csv
|
|
import re
|
|
import sys
|
|
txt = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
|
|
found = dict(re.findall(r"<itemName>([^<]+)</itemName>\s*<itemId>([^<]+)</itemId>", txt))
|
|
if not found:
|
|
sys.exit("no itemId pairs in %s - has CI View saved it?" % path)
|
|
have = {}
|
|
if IDS_FILE.is_file():
|
|
have = {r["item"]: r["itemId"] for r in csv.DictReader(IDS_FILE.open(encoding="utf-8"))}
|
|
changed = {k: v for k, v in found.items() if have.get(k) != v}
|
|
|
|
have.update(found)
|
|
with IDS_FILE.open("w", newline="", encoding="utf-8") as fh:
|
|
w = csv.writer(fh); w.writerow(["item", "itemId"])
|
|
for k in sorted(have):
|
|
w.writerow([k, have[k]])
|
|
import datetime
|
|
import json
|
|
IDS_META.write_text(json.dumps({
|
|
"harvested_from": str(path),
|
|
"harvested_at": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
"item_file": ITEM_FILE.name,
|
|
"item_file_sha": item_file_sha(),
|
|
"measured": len(have),
|
|
"note": "Every .qli item import renumbers every item. Validate the "
|
|
"displays in CI Server's Editor Module, then re-harvest - "
|
|
"otherwise the screens go dead.",
|
|
}, indent=2) + "\n", encoding="utf-8")
|
|
|
|
print("harvested %d ids from %s (%d new or changed) -> %s"
|
|
% (len(found), path, len(changed), IDS_FILE.name))
|
|
print(" provenance -> %s" % IDS_META.name)
|
|
|
|
|
|
def verify(path):
|
|
"""Check a CI View-saved display against the ids this build would emit.
|
|
|
|
This is the check that makes a dead screen impossible to miss. CI View
|
|
resolves every connection by NAME when it saves, so the ids in a saved
|
|
file are CI Server's own truth. If they match what we generate, the
|
|
screens are live; if they do not, they are dead, and this says which
|
|
items and by how much - without anyone having to look at a display and
|
|
judge whether a 0 is a real 0.
|
|
"""
|
|
import re
|
|
import sys
|
|
txt = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
|
|
found = dict(re.findall(r"<itemName>([^<]+)</itemName>\s*<itemId>([^<]+)</itemId>", txt))
|
|
if not found:
|
|
sys.exit("no itemId pairs in %s - has CI View saved it?" % path)
|
|
load_ids()
|
|
bad = {n: (v, _ids.get(n)) for n, v in found.items() if _ids.get(n) != v}
|
|
print("verified %d bindings in %s" % (len(found), pathlib.Path(path).name))
|
|
if not bad:
|
|
print(" all match - the screens are bound to the live items")
|
|
return 0
|
|
print(" %d MISMATCH(ES) - these values would read 0:" % len(bad))
|
|
for n, (theirs, ours) in sorted(bad.items()):
|
|
print(" %-30s CI Server %s generated %s"
|
|
% (n.replace("AID.WRPS.", ""), theirs, ours))
|
|
print(REHARVEST)
|
|
return 1
|
|
|
|
|
|
def main():
|
|
import sys
|
|
if len(sys.argv) > 2 and sys.argv[1] == "--harvest":
|
|
harvest(sys.argv[2])
|
|
return
|
|
if len(sys.argv) > 2 and sys.argv[1] == "--verify":
|
|
sys.exit(verify(sys.argv[2]))
|
|
OUT.mkdir(exist_ok=True)
|
|
displays = [("WRPS_Overview", overview()),
|
|
("WRPS_Tank", tank()),
|
|
("WRPS_FP_PU301", fp_pump(1)),
|
|
("WRPS_FP_PU302", fp_pump(2)),
|
|
("WRPS_FP_PU303", fp_pump(3)),
|
|
("WRPS_FP_Setpoints", fp_setpoints())]
|
|
check_items(displays)
|
|
check_ids(displays)
|
|
report_unplaced(displays)
|
|
for name, disp in displays:
|
|
path = OUT / (name + ".xml")
|
|
path.write_text(disp.xml(), encoding="utf-8")
|
|
print("OK %-22s %5d components, %3d shared objects, %6d bytes"
|
|
% (path.name, len(disp.parts), len(disp.refs), path.stat().st_size))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|