Compare commits

..

2 Commits

Author SHA1 Message Date
Joseph Doherty dce6f83488 loader: add populate-equipment (company-shape Equipment overlay) + scope verify-equipment
populate-equipment loads the Northwind Enterprise/Site/Area/Line/Equipment/Signal
shape from company-uns.json as a second Equipment-kind namespace (nw-uns) alongside
the galaxy mirror — 3 areas / 8 lines / 40 equipment / 1036 signals. Friendly
DisplayName, stable logical-Id NodeId. verify-equipment now scopes to the nw-area-*
overlay by default (--all for the whole tree). Verified live on :4840 against OtOpcUa
master's Equipment-namespace materialization (structure-only; leaves are
BadWaitingForInitialData). clean now drops the overlay too.
2026-06-06 16:19:53 -04:00
Joseph Doherty fd34e25cb1 feat(uns-loader): verify-equipment — recursive Equipment UNS tree browse + leaf count
browse_summary assumes the flat 2-level Galaxy hierarchy; the Equipment tree is deep
(Area/Line/Equipment/[FolderPath]/Signal). Add browse_tree (recursive leaf descent) + a
verify-equipment subcommand that reports/asserts the leaf signal count (--expect N), for
verifying OtOpcUa equipment-namespace structure materialisation. Smoke-tested against a live
:4840 (40 folders / 396 leaf signals).
2026-06-06 15:25:17 -04:00
2 changed files with 209 additions and 13 deletions
+22 -9
View File
@@ -109,13 +109,26 @@ Defaults target docker-dev; override via flags or env:
- `../galaxy-hierarchy.json` — the source of truth, pulled live from the gateway - `../galaxy-hierarchy.json` — the source of truth, pulled live from the gateway
- `requirements.txt`, `.venv/` - `requirements.txt`, `.venv/`
## Scope note — company-UNS shape ## Company-shape overlay (`populate-equipment`)
This tool loads the galaxy in its **native hierarchy** Besides the galaxy-native mirror, the tool can load the **Northwind company
(`OtOpcUa/TestMachine_NNN/<signal>`), which is the only shape that can carry live shape** (`filling / line-1 / rinser-01 / speed-rpm`) as a second, **Equipment**-kind
Galaxy values: OtOpcUa forbids the `GalaxyMxGateway` driver in an `Equipment` namespace (`nw-uns`, in cluster `MAIN`) from `../company-uns.json`. This needs
namespace, so a custom `Enterprise/Site/Area/Line/Equipment` UNS (e.g. the OtOpcUa `master` ≥ the Equipment-namespace structure milestone
Northwind model in `../company-uns.json`) must be a separate **Equipment** (`febe462…9a67ebc`), which materialises Equipment `Tag`/`VirtualTag` rows on
namespace fed by an `OpcUaClient` driver + `UnsMappingTable` that remaps this deploy and added a **headless deploy** endpoint.
mirror. That overlay is the designed next layer; `../company-uns.json` already
carries the area/line/equipment → galaxy-ref mapping it needs. ```bash
./.venv/bin/python otopcua_uns.py populate-equipment # 3 areas / 8 lines / 40 equipment / 1036 signals
curl -s -X POST http://localhost:9200/api/deployments -H 'X-Api-Key: docker-dev-deploy-key' # headless deploy
./.venv/bin/python otopcua_uns.py verify-equipment --expect 1036 # browse the company tree (nw-area-* scope)
```
UNS folders carry the friendly **DisplayName** (`filling`); the BrowseName/NodeId
stay the stable logical Id (`nw-area-filling`) — standard OPC UA. **Structure-only:**
the company leaves materialise as `BadWaitingForInitialData` — live **values** in
the company shape are the next OtOpcUa milestone (driver/VirtualTag source), tracked
in `OtOpcUa/docs/plans/2026-06-06-equipment-namespace-materialization-scope.md` (WS-3).
The galaxy-native mirror (`populate`) still carries live values.
`clean` removes both the mirror tags and the company overlay.
+187 -4
View File
@@ -38,11 +38,13 @@ and clean print the reminder and `verify --wait` polls until it lands.
Deps: pymssql, asyncua (see requirements.txt; use the bundled .venv). Deps: pymssql, asyncua (see requirements.txt; use the bundled .venv).
""" """
import argparse import argparse
import hashlib
import json import json
import os import os
import re import re
import sys import sys
import time import time
import uuid
# ── config (overridable via env / flags) ─────────────────────────────────── # ── config (overridable via env / flags) ───────────────────────────────────
DEF_MSSQL = dict( DEF_MSSQL = dict(
@@ -58,9 +60,25 @@ DEF_GALAXY_JSON = os.environ.get(
"OTOPCUA_GALAXY_JSON", "OTOPCUA_GALAXY_JSON",
os.path.join(os.path.dirname(__file__), "..", "galaxy-hierarchy.json"), os.path.join(os.path.dirname(__file__), "..", "galaxy-hierarchy.json"),
) )
ID_PREFIX = "nw-mirror-" # all rows we own carry this TagId prefix DEF_COMPANY_JSON = os.environ.get(
"OTOPCUA_COMPANY_JSON",
os.path.join(os.path.dirname(__file__), "..", "company-uns.json"),
)
ID_PREFIX = "nw-mirror-" # SystemPlatform galaxy-mirror TagId prefix
LOAD_PLAN = os.path.join(os.path.dirname(__file__), "load-plan.json") LOAD_PLAN = os.path.join(os.path.dirname(__file__), "load-plan.json")
# Equipment-overlay (company-shape) object ids — all carry the nw- prefix so
# `clean` can remove them. The Equipment namespace is a SECOND namespace loaded
# alongside the galaxy mirror; its leaves stay BadWaitingForInitialData until the
# value milestone (scope doc WS-3) wires a driver/VirtualTag source.
EQ_CLUSTER = os.environ.get("OTOPCUA_EQ_CLUSTER", "MAIN")
EQ_NS = "nw-uns"
EQ_DRIVER = "nw-uns-modbus" # non-Galaxy FK driver (structure-only; doesn't stream)
# galaxy dataTypeName / gen_uns dtype → valid OtOpcUa DriverDataType
_DTYPE_FIX = {"Double": "Float64", "Float": "Float32"}
_ACCESS = {"ReadOnly": "0", "Read": "0", "ReadWrite": "1"}
# ── the value signals we mirror, per $TestMachine instance ────────────────── # ── the value signals we mirror, per $TestMachine instance ──────────────────
# (galaxy attribute name, OtOpcUa DriverDataType, access '0'=Read/'1'=ReadWrite) # (galaxy attribute name, OtOpcUa DriverDataType, access '0'=Read/'1'=ReadWrite)
SIGNALS = [ SIGNALS = [
@@ -194,13 +212,95 @@ def cmd_populate(args):
return 0 return 0
def cmd_populate_equipment(args):
"""Load the company-shape Equipment namespace from company-uns.json: a second
(Equipment-kind) namespace alongside the galaxy mirror, with the Northwind
Area/Line/Equipment/Signal tree. Structure-only — leaves materialise as
BadWaitingForInitialData (the value milestone is separate). Idempotent:
drop-and-recreate of the nw- overlay rows."""
with open(args.company_json) as f:
doc = json.load(f)
u = doc["uns"]
conn, cur = connect(args.mssql)
# Drop any prior overlay (child rows first), then recreate.
cur.execute("DELETE FROM dbo.Tag WHERE DriverInstanceId=%s", (EQ_DRIVER,))
cur.execute("DELETE FROM dbo.Equipment WHERE DriverInstanceId=%s", (EQ_DRIVER,))
cur.execute("DELETE FROM dbo.UnsLine WHERE UnsLineId LIKE 'nw-line-%'")
cur.execute("DELETE FROM dbo.UnsArea WHERE UnsAreaId LIKE 'nw-area-%'")
cur.execute("DELETE FROM dbo.DriverInstance WHERE DriverInstanceId=%s", (EQ_DRIVER,))
cur.execute("DELETE FROM dbo.Namespace WHERE NamespaceId=%s", (EQ_NS,))
cur.execute(
"INSERT INTO dbo.Namespace (NamespaceRowId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled) "
"VALUES (NEWID(), %s, %s, 'Equipment', %s, 1)",
(EQ_NS, EQ_CLUSTER, doc.get("namespace", {}).get("namespaceUri", "urn:northwind:birmingham:uns")))
cur.execute(
"INSERT INTO dbo.DriverInstance (DriverInstanceRowId, DriverInstanceId, ClusterId, NamespaceId, "
"Name, DriverType, Enabled, DriverConfig) VALUES (NEWID(), %s, %s, %s, 'Northwind UNS placeholder', 'Modbus', 1, '{}')",
(EQ_DRIVER, EQ_CLUSTER, EQ_NS))
for a in u["unsAreas"]:
cur.execute("INSERT INTO dbo.UnsArea (UnsAreaRowId, UnsAreaId, ClusterId, Name) VALUES (NEWID(), %s, %s, %s)",
("nw-" + a["unsAreaId"], EQ_CLUSTER, a["name"]))
for l in u["unsLines"]:
cur.execute("INSERT INTO dbo.UnsLine (UnsLineRowId, UnsLineId, UnsAreaId, Name) VALUES (NEWID(), %s, %s, %s)",
("nw-" + l["unsLineId"], "nw-" + l["unsAreaId"], l["name"]))
eq_n = tag_n = 0
for e in u["equipment"]:
eq_id = "nw-" + e["equipmentId"]
eq_uuid = str(uuid.uuid5(uuid.NAMESPACE_URL, "otopcua-nw-eq/" + e["equipmentId"]))
cur.execute(
"INSERT INTO dbo.Equipment (EquipmentRowId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId, "
"Name, MachineCode, Manufacturer, Model, Enabled) VALUES (NEWID(), %s, %s, %s, %s, %s, %s, %s, %s, 1)",
(eq_id, eq_uuid, EQ_DRIVER, "nw-" + e["unsLineId"], e["name"], e["machineCode"],
e.get("manufacturer"), e.get("model")))
eq_n += 1
for t in e["tags"]:
dtype = _DTYPE_FIX.get(t["dataType"], t["dataType"])
access = _ACCESS.get(t["accessLevel"], "0")
folder = t.get("folderPath") or None
# Local NodeId == TagConfig.FullName; prefix with nw: so it never collides with the
# galaxy-mirror SystemPlatform NodeIds (which use the bare MXAccess ref).
full = "nw:" + t["source"]["fullTagReference"]
# TagId is capped at 64 chars; a short stable hash keeps it unique. Cleanup is by
# DriverInstanceId (not TagId), so no prefix scan is needed.
tag_id = "nweq-" + hashlib.sha1(
f"{e['equipmentId']}|{folder}|{t['name']}".encode()).hexdigest()[:20]
cfg = json.dumps({"FullName": full, "DataType": dtype})
cur.execute(
"INSERT INTO dbo.Tag (TagRowId, TagId, DriverInstanceId, EquipmentId, Name, FolderPath, "
"DataType, AccessLevel, WriteIdempotent, TagConfig) VALUES (NEWID(), %s, %s, %s, %s, %s, %s, %s, 0, %s)",
(tag_id, EQ_DRIVER, eq_id, t["name"], folder, dtype, access, cfg))
tag_n += 1
conn.commit()
conn.close()
print(f"populated equipment overlay: namespace {EQ_NS} ({EQ_CLUSTER}), "
f"{len(u['unsAreas'])} areas, {len(u['unsLines'])} lines, {eq_n} equipment, {tag_n} signals")
print()
print(f">>> NEXT: deploy (headless) — curl -s -X POST {args.deploy_url.replace('/deployments','')}/api/deployments "
f"-H 'X-Api-Key: {args.deploy_key}'")
print(">>> then run: otopcua_uns.py verify-equipment")
return 0
def cmd_clean(args): def cmd_clean(args):
conn, cur = connect(args.mssql) conn, cur = connect(args.mssql)
cur.execute("DELETE FROM dbo.Tag WHERE TagId LIKE %s", (ID_PREFIX + "%",)) cur.execute("DELETE FROM dbo.Tag WHERE TagId LIKE %s", (ID_PREFIX + "%",))
n = cur.rowcount n = cur.rowcount
# Also drop the company-shape Equipment overlay (child rows first).
cur.execute("DELETE FROM dbo.Tag WHERE DriverInstanceId=%s", (EQ_DRIVER,))
cur.execute("DELETE FROM dbo.Equipment WHERE DriverInstanceId=%s", (EQ_DRIVER,))
cur.execute("DELETE FROM dbo.UnsLine WHERE UnsLineId LIKE 'nw-line-%'")
cur.execute("DELETE FROM dbo.UnsArea WHERE UnsAreaId LIKE 'nw-area-%'")
cur.execute("DELETE FROM dbo.DriverInstance WHERE DriverInstanceId=%s", (EQ_DRIVER,))
cur.execute("DELETE FROM dbo.Namespace WHERE NamespaceId=%s", (EQ_NS,))
conn.commit() conn.commit()
conn.close() conn.close()
print(f"removed {n} nw-* mirror tag(s). Deploy again at {args.deploy_url} to drop them from the address space.") print(f"removed {n} nw-* mirror tag(s) + the {EQ_NS} equipment overlay. "
f"Deploy again at {args.deploy_url} to drop them from the address space.")
return 0 return 0
@@ -298,6 +398,77 @@ def sample_values(endpoint, n):
return [("<browse error>", str(e), "?")] return [("<browse error>", str(e), "?")]
def browse_tree(endpoint, max_depth=8, top_prefix=None):
"""Recursively descend the OtOpcUa address space and count leaf variables, returning
(folder_count, leaf_count, leaf_paths). A node with no children is a leaf signal — this
correctly handles the DEEP Equipment UNS tree (Area/Line/Equipment/[FolderPath]/Signal),
unlike browse_summary which assumes the flat 2-level Galaxy hierarchy. When top_prefix is
set, only top-level OtOpcUa folders whose browse name starts with it are counted (e.g.
'nw-area-' scopes to the company Equipment overlay, excluding the Galaxy mirror folders)."""
import asyncio
from asyncua import Client
async def walk(node, path, depth, acc):
if depth >= max_depth:
return
for ch in await node.get_children():
try:
name = (await ch.read_browse_name()).Name
except Exception:
continue
child_path = path + "/" + name
grandkids = await ch.get_children()
if grandkids:
acc["folders"] += 1
await walk(ch, child_path, depth + 1, acc)
else:
acc["leaves"] += 1
acc["paths"].append(child_path)
async def run():
acc = {"folders": 0, "leaves": 0, "paths": []}
async with Client(endpoint) as c:
for k in await c.nodes.objects.get_children():
if (await k.read_browse_name()).Name != "OtOpcUa":
continue
for top in await k.get_children():
tn = (await top.read_browse_name()).Name
if top_prefix and not tn.startswith(top_prefix):
continue
if await top.get_children():
acc["folders"] += 1
await walk(top, "OtOpcUa/" + tn, 1, acc)
else:
acc["leaves"] += 1
acc["paths"].append("OtOpcUa/" + tn)
return acc["folders"], acc["leaves"], acc["paths"]
try:
return asyncio.run(run())
except Exception as e:
return (f"<{type(e).__name__}: {e}>", 0, [])
def cmd_verify_equipment(args):
"""Browse the full UNS tree by friendly Area/Line/Equipment/Signal names and report the leaf
signal count. With --expect N, exit non-zero unless exactly N leaf signals are present (the
equipment-namespace structure-materialisation check)."""
top_prefix = None if args.all else "nw-area-"
folders, leaves, paths = browse_tree(args.opcua_endpoint, top_prefix=top_prefix)
scope = "whole address space" if args.all else "company overlay (nw-area-*)"
print(f"equipment tree : {folders} folder(s), {leaves} leaf signal(s) on {args.opcua_endpoint} [{scope}]")
for p in sorted(paths)[:args.show]:
print(f" {p}")
if len(paths) > args.show:
print(f" … and {len(paths) - args.show} more")
if args.expect is not None:
passed = leaves == args.expect
print("VERIFY-EQUIPMENT:",
f"PASS ({leaves} == {args.expect})" if passed
else f"FAIL (expected {args.expect}, found {leaves})")
return 0 if passed else 1
return 0
# ── arg parsing ───────────────────────────────────────────────────────────── # ── arg parsing ─────────────────────────────────────────────────────────────
def main(argv): def main(argv):
p = argparse.ArgumentParser(description="Reloadable populate + verify for the OtOpcUa galaxy UNS.") p = argparse.ArgumentParser(description="Reloadable populate + verify for the OtOpcUa galaxy UNS.")
@@ -305,6 +476,9 @@ def main(argv):
p.add_argument("--driver", default=DEF_DRIVER, help="SystemPlatform GalaxyMxGateway driver instance id") p.add_argument("--driver", default=DEF_DRIVER, help="SystemPlatform GalaxyMxGateway driver instance id")
p.add_argument("--opcua-endpoint", default=DEF_OPCUA) p.add_argument("--opcua-endpoint", default=DEF_OPCUA)
p.add_argument("--deploy-url", default="http://localhost:9200/deployments") p.add_argument("--deploy-url", default="http://localhost:9200/deployments")
p.add_argument("--deploy-key", default=os.environ.get("OTOPCUA_DEPLOY_KEY", "docker-dev-deploy-key"),
help="X-Api-Key for the headless POST /api/deployments endpoint")
p.add_argument("--company-json", default=DEF_COMPANY_JSON)
p.add_argument("--sql-host", default=DEF_MSSQL["host"]) p.add_argument("--sql-host", default=DEF_MSSQL["host"])
p.add_argument("--sql-port", type=int, default=DEF_MSSQL["port"]) p.add_argument("--sql-port", type=int, default=DEF_MSSQL["port"])
p.add_argument("--sql-user", default=DEF_MSSQL["user"]) p.add_argument("--sql-user", default=DEF_MSSQL["user"])
@@ -313,18 +487,27 @@ def main(argv):
sub = p.add_subparsers(dest="cmd", required=True) sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("generate") sub.add_parser("generate")
sub.add_parser("populate") sub.add_parser("populate")
sub.add_parser("populate-equipment",
help="load the company-shape Equipment namespace from company-uns.json (structure-only)")
sub.add_parser("clean") sub.add_parser("clean")
sub.add_parser("status") sub.add_parser("status")
vp = sub.add_parser("verify") vp = sub.add_parser("verify")
vp.add_argument("--wait", action="store_true", help="poll until the deploy lands") vp.add_argument("--wait", action="store_true", help="poll until the deploy lands")
vp.add_argument("--wait-seconds", type=int, default=120) vp.add_argument("--wait-seconds", type=int, default=120)
ep = sub.add_parser("verify-equipment",
help="recursively browse the Equipment UNS tree + count leaf signals")
ep.add_argument("--expect", type=int, default=None, help="assert exactly N leaf signals")
ep.add_argument("--show", type=int, default=20, help="how many leaf paths to print")
ep.add_argument("--all", action="store_true",
help="count the whole address space (default: only the nw-area-* company overlay)")
a = p.parse_args(argv) a = p.parse_args(argv)
a.mssql = dict(host=a.sql_host, port=a.sql_port, user=a.sql_user, a.mssql = dict(host=a.sql_host, port=a.sql_port, user=a.sql_user,
password=a.sql_password, database=a.sql_db) password=a.sql_password, database=a.sql_db)
return { return {
"generate": cmd_generate, "populate": cmd_populate, "clean": cmd_clean, "generate": cmd_generate, "populate": cmd_populate,
"status": cmd_status, "verify": cmd_verify, "populate-equipment": cmd_populate_equipment, "clean": cmd_clean,
"status": cmd_status, "verify": cmd_verify, "verify-equipment": cmd_verify_equipment,
}[a.cmd](a) }[a.cmd](a)