wrps-demo-kit/04-scada/hmi/DEPLOY.md
Clio Liu 0927323e13 fix(scada): PS_* placeholder tags out, CI Server item names in
scada-points.csv led with a scada_tag column of PS_* names derived from
the PLC register map - PS_STN_WET_WELL_LEVEL and the like. CI Server never
adopted them. They named nothing that exists, nothing in this repo read
them, and read as if authoritative they have already caused a real defect
in a downstream project.

Replaced with ci-server-points.csv, the as-built delivery from the CI
Server side (copied from yau-plant-assistant, not re-derived). It is keyed
on the CI Server item, with the station and point name beside it:

    ci_item,ci_station,ci_point,description,poll_group,...

gen_scada_points.py now reproduces that file byte-identically, taking the
names from the LEAF table in gen_ciserver_qli.py so the CSV and the .qli
cannot disagree. tag_for() is gone. Verified: the CSV and all three .qli
regenerate unchanged, and all six displays still build.

The four poll groups keep their PS_ names - PS_STATUS_BITS, PS_PUBLISHED,
PS_SETPOINTS, PS_SIM_CONTROL. Those are live configuration, and are the
only legitimate PS_ names.

modbus_points/README.md gains a 'four namespaces' section - instrument tag,
PLC address, CI Server point, CI Server item - since confusing them is what
produced the placeholder column in the first place.

Also settles the historian drift left open in the previous commit. The
live export is the authority: the server runs WRPS_ONE_SEC at 5 SECONDS,
not 1, and WRPS_THIRTY_SEC at 30 seconds rather than a 60 second
WRPS_ONE_MIN. Code assuming 60 s was wrong by twelvefold. Recorded in
historian/README.md with a caution never to hardcode a sample interval.

Every .qli in the delivery was byte-identical to ours - only the CSV
differed.
2026-09-02 17:21:57 +10:00

18 KiB
Raw Blame History

Deploying the WRPS displays

Six displays are generated into 04-scada/hmi/out/ by build_display.py, plus WRPS_TagTest.xml by build_tagtest.py:

File What Size Components
WRPS_Overview.xml The operator display 1920 × 1080 374
WRPS_Tank.xml Wet well detail; carries the setpoint ladder, opened from the WELL 1920 × 1080 180
WRPS_FP_PU301.xml PU-301 pump faceplate 760 × 700 109
WRPS_FP_PU302.xml PU-302 pump faceplate 760 × 700 109
WRPS_FP_PU303.xml PU-303 pump faceplate 760 × 700 109
WRPS_FP_Setpoints.xml Station setpoint faceplate 900 × 700 51
WRPS_TagTest.xml Diagnostic list — every project item, plain vs masked 49 items, 98 bindings

Sizes and counts are what the build printed on 2026-08-20. It prints them on every run — trust that over this table.

04-scada/hmi/WRPS_Overview.xml — at the root, not in out/ — is not one of these. It is the copy CI View itself saved during the id harvest, kept as evidence for the <format> finding below (it carries exactly one <format>, the 99.99 set by hand). Do not deploy it and do not rebuild over it.

Regenerate with:

python 04-scada/hmi/build_display.py && python 04-scada/hmi/build_tagtest.py

Important

After any .qli import, re-harvest the item ids before rebuilding — an import moves every id and the screens go dead without one visible symptom. The build now refuses to run when it cannot prove the ids are current; see Item ids below for the four-line ritual.

This applies to a .qli import on the same server. Moving a display to a different CI Server instance is a different case and needs none of it — see Moving a display to another CI Server instance.

Never edit the XML by hand and never edit anything under C:\Users\Public\Yokogawa\tls. Change build_display.py and rebuild — otherwise the next build silently discards the edit.

WHERE / WHAT / WHAT FOR

WHERE: the CI Server machine (PXiSEDev).

WHAT: copy the files into the displays directory of the deployment — alongside the existing Straddle_*.xml, i.e. the same folder the copy in 99-reference/ciserver-hmi-deployment/displays/ came from. Then open WRPS_Overview in the CI View editor.

WHAT FOR: CI Server loads displays from that directory by file name; WRPS_Overview is what actionActivateDisplay targets from the faceplates, and the faceplate names are what the pump symbols and the SETPOINTS… button target.

Import order

The items must exist before the displays can bind to them:

  1. 04-scada/modbus_points/wrps_section_df.qli — sections
  2. 04-scada/modbus_points/wrps_modbus_point_df.qli — Modbus points
  3. 04-scada/modbus_points/wrps_item_df.qli — items
  4. these displays

What is live, and what is not

Live on first open:

  • every number, bound to its item through number.valueItemValue
  • every button that writes — station mode, reset trips, reset hours, all four simulation scenarios, time scale, reset scenario — via actionSetItemAttribute with the value carried in the filter
  • navigation — pump symbols open WRPS_FP_Pump, SETPOINTS… opens WRPS_FP_Setpoints, both faceplates return to WRPS_Overview
  • the wet well fill, a dataBar bound to STN.LEVEL, 0100%
  • every setpoint is editable — a numberField on the eight boxes of WRPS_FP_Setpoints and on SETPOINT in the overview rail

Not yet, and deliberately:

  • State colouring is static. The pump bodies and status boxes are drawn in their normal colours. Driving a colour from a value still needs item alarm limits (which you deferred along with trends and alarms) or a threshold configuration I could not confirm from the deployment, and guessing would have produced silently wrong colours. The layout has the right shapes in the right places, so this is a bind, not a redraw. The level fill is no longer in this category: a dataBar carries its own lowLimit/highLimit, so it needs no item limits at all. That was a wrong assumption here, corrected after you added one by hand.
  • The alarm banner is not in this build, for the same reason — it exists in the preview and in component-kit.md, and follows once alarm limits are set.
  • Blink likewise: the display sets blinkEnabled and blinkDelay 700 ms, so the mechanism is ready, but nothing drives it yet.

Item ids — the one thing that makes a screen lie

A connection needs both itemName and itemId. With the name alone every value reads 0, and the screen looks perfectly healthy while meaning nothing. This has bitten twice. It is now enforced by the build rather than by memory.

If a display's values are not updating, do this first

Open the display in CI Server's Editor Module and validate it.

Validation resolves every connection by name and rewrites the ids in the file. It fixes the common case in seconds. Investigate nothing else until it has been ruled out.

Every item import kills every display

On 2026-08-14 the items were re-imported to change engineering units, keeping every NAME, NSID and ID_NUMBER byte-identical. Every itemId still moved. CI Server recreates items on import rather than updating them in place. Every value on every screen went dead at once — that simultaneity is the signature.

So the sequence after any dssqld -i of items is always: import → validate the displays → re-harvest, so the next build ships the current ids.

Nothing is inferred — every id is measured

An id comes from CI Server or the build stops. There is no rule, no default and no fallback. An id we invent that happens to be wrong produces the failure that is hardest to see: a screen full of live-looking zeroes.

WRPS_TagTest binds every project item, which makes it the measuring instrument: save it once in CI View and all 49 ids come back at once.

CI View repairs ids on save — that is what makes this workable

Confirmed 2026-08-14. WRPS_TagTest was deployed with 25 of its 49 ids as placeholders; those 25 rows read 0 while the other 24 read correctly. One binding was then added by hand in CI View and the file saved — and CI View rewrote every connection in the file with the right id, resolving them by name. All 49 rows went live, and the saved file harvested all 49 in one pass.

Two things follow, and they are not in tension:

  • At runtime the id is what binds. A wrong or placeholder id reads 0, name notwithstanding. That is why the build refuses to emit an unmeasured id.
  • In the editor the name is authoritative. So a display can always be healed by opening and saving it, and that same save is the measurement.

This is why the bootstrap works at all: ship placeholders, let CI View resolve them, harvest the result.

Moving a display to another CI Server instance — import, then validate

Observed 2026-08-20, migrating to node CORE_0001.

The ids in out/*.xml are PXiSEDev's. A different instance is a different import, so a different K, so every one of them is wrong on the target. That looks like it needs the full harvest ritual below. It does not. Importing the display into the target resolves every connection by name — the same repair described above, applied on the way in. The ids are healed by the import itself.

So moving displays between instances is import, then validate. No re-harvest, no rebuild, no .qli round-trip, no regenerated XML. The files built for one server are the files you ship to the next.

Validate after importing — the screen reading plausible live values rather than a field of 0 is the signal. For the rigorous check, save the imported display in CI View on the target and run --verify against it.

Warning

--verify compares against item-ids.csv, which still holds the source server's numbering, so expect it to flag every item on a freshly migrated target. That is the id file being stale for this server, not the display being broken. --harvest the saved file to re-pin it — but note item-ids.csv describes exactly one server at a time, so harvesting against CORE_0001 makes it wrong for PXiSEDev. Branch the file per instance before doing this if both servers still matter.

Two things this depends on:

  • Item names must be identical. The binding survives the move because the name does. AID.WRPS.* is built from ROOT, INSTALL and STATION in gen_ciserver_qli.py:53-54 — if the target imported under different values, the names differ and nothing resolves.
  • The node name is not in any display. FRONT_END_NODE (UNLICENCEDCORE_0001) is a .qli field only, gen_ciserver_qli.py:289. No display carries a node reference, so a node rename needs no edit to out/*.xml.

The ritual — after ANY .qli import

python build_tagtest.py                                     # the measuring display
# deploy WRPS_TagTest.xml, open it in CI View, link one value, save
python build_display.py --harvest <the saved WRPS_TagTest.xml>   # all 49 measured
python build_display.py && python build_tagtest.py          # now the real screens
# deploy out/*.xml, open WRPS_Overview in CI View, save
python build_display.py --verify <that saved display>       # prove it

build_tagtest.py is the one build allowed to run with unmeasured ids — it has to be, or there would be no way to measure the first one. It prints exactly how many are placeholders, and those read 0 by design until harvested. Every other display refuses to build until all of its items are measured.

--verify is the part 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. The check compares them against what the build emits and names any item that would read 0, exiting non-zero. Nobody has to look at a screen and decide whether a 0 is a real zero.

The guards, all hard failures

Guard Catches
Every bound item must have a measured idcheck_ids() lists all gaps at once an inferred id, which is a guess about someone else's numbering
item-ids.meta.json records the SHA of the wrps_item_df.qli harvested against items regenerated, so an import is coming or has happened
No measured ids at all → build fails an uncalibrated build, which is a dead screen
--verify mismatch → exit 1 ids that are already wrong, item by item

None is a warning. Each error names the same one-command cure.

<format> is a digit mask — the reason values read 0

<format> on a number is a digit mask, not a Java DecimalFormat pattern:

<format>99.99</format>   <!-- two integer digits, always two decimals -->

Each 9 is one digit position; the positions after the point are the decimals always shown. This is what CI View itself wrote when the FIT-201 INLET value was set to two decimals by hand, and it matches the VALUE_FORMAT masks in the item exports ("99999" on our items, "99.99" on the reference analog IO).

The first build emitted <format>0</format> and <format>0.0</format>DecimalFormat patterns, which read as masks one digit wide. Nothing wider than a single digit had anywhere to render, so every level, flow and timer came up empty. That is the whole bug.

A mask must therefore be wide enough for the point's full engineering range. The masks live in point_format.py, one per item, sized from the register map and shared between points of the same kind so the screens stay consistent:

Kind Mask Why
level & level setpoints (%) 999.9 % of the 6.000 m spill weir
flows, net accumulation (m³/h) 9999.9 three pumps reach ~1300
drive speed, min speed (%) 999.9 % of 50 Hz
durations (s), hour counters (h) 99999 32767 = "drawing down" sentinel
volume to spill (m³) 9999
alarm bitmask 99999 unsigned 16-bit
enums, counts, modes, booleans 9 single digit by definition

Decimals follow each point's real resolution rather than a fixed two. Level in % of the spill weir resolves to 0.017% because the register underneath is mm, and a flow in m³/h to 0.1 — so both get one decimal, and a second would be invented precision. Integer registers get none. point_format.check() fails the build if a new item has no mask, and the masks are cross-checked against format_mask in 04-scada/modbus_points/ci-server-points.csv, which is what CI Server imports as VALUE_FORMAT.

Element order matters to match what CI View saves: <htmlId>, <format>, <font>, …, <data>. A masked number carries no <value>.

Two earlier conclusions here were wrong

  • "Actions must be shared objects." Not true. WRPS_TagTest round 1 bound all 49 items twice — once as <data ref> in globalSection, once inline as <data type="data"> — and both columns updated identically. The generator still emits shared objects because that is what CI View writes on save and it de-duplicates, but it is a preference.
  • "A bound number needs <value> and must not have <format>." Also not true, and it only looked true because every <format> seen up to that point was a one-digit DecimalFormat pattern. A correctly sized mask works, and <value> is just the placeholder shown before the first update.

What round 1 did establish and still holds: the itemIds are right, including all 23 that were only ever derived from the creation-index rule, and the CI Server side — items, sections, Modbus points — is fine.

Fixed after the first deploy

Three things the screenshot showed, all now corrected in the generator:

  • Popups opened full-screen. actionActivateDisplay needs <type value="ActivateSpecific"/> plus <layout>FixedPopup</layout> and <layoutFrame>default</layoutFrame>; without them it replaces the current display. Faceplate CLOSE buttons now use actionExitDisplay, which closes the popup rather than activating another display over it.
  • Grey blobs over the pump symbols. I had used a real button as the click target, so it drew its own face. Replaced with a fully transparent rectangle (a="0" paint) carrying the action — which is how the Straddle screens do invisible hit areas.
  • Value boxes sitting on the pipes. The per-pump boxes were at fixed positions that crossed the discharge lines. Pump data now sits in its own column at x=1380, the ladder labels moved outside the well, and the mimic was re-spaced around a wider well.

Other things I could not verify

  1. Text placement is approximate. keepOriginalSize is set and the width is estimated from character count, so a label may sit a few pixels off. The editor will normalise on save.
  2. Pipes are rectangles, not line components. The line component positions itself with curvePoints/curveSegments, and the single example in the deployment did not make its anchor convention unambiguous. Filled rectangles are exact and are what the site's own screens use. Cost: no diagonals, which a P&ID does not need.
  3. number.format is emitted as a Java DecimalFormat pattern (0, 0.0). If the editor shows a different precision, that is the property to adjust.

If the display opens empty or unbound

Deploy WRPS_TagTest.xml and open it — it takes graphics, layout and every other display out of the picture, and shows all 49 items twice: unmasked on the left, masked on the right. A blank on the right beside a live value on the left is a mask that is too narrow. If it is live and another display is not, the fault is in that display, not in the items or the Modbus chain.

Otherwise check in this order: the <format> mask width (above) → items imported (step 3 above) → item names match AID.WRPS.* exactly → the itemId note above. build_display.py refuses to build if any display binds to an item that is not in wrps_item_df.qli, so a name mismatch cannot be the cause unless the items were imported under a different root.

Editable fields and bars — the two components CI View taught us

Both were added by hand in CI View on 2026-08-14 and copied out of the saved file, rather than guessed from the component definitions:

numberField dataBar
Property numberField.value dataBar.value
Geometry element attributes, not child elements same
Connection <itemAttribute>ItemValue</itemAttribute>, no <direction> ordinary read connection
Range n/a <lowLimit> / <highLimit> on the component
Format none — a mask fights the editing n/a

The geometry difference is the trap: every other component here stores x, y, top, bottom, left, right as child elements, and these two store them as attributes on the opening tag.

dataBar needing no item configuration is what makes the live well fill possible — lowLimit 0, highLimit 100 against a level that now reads in % of the spill weir, so the bar maps onto the well with no scaling anywhere. Straddle_Detail.xml in the reference deployment does the same for a fuel level.

Layout is audited, not eyeballed

python check_layout.py

Estimates each text run's drawn extent and reports collisions. It caught the wet well caption running into the top ladder label after the units relabel — the 100% mark is the well top, so the caption and that label share a line. Approximate by construction (it estimates glyph widths), so it is a net for gross collisions, not a substitute for looking at the screen.