Add per-area alarm breakdown (Areas tab + AreaCard), disable sim tick

New area_breakdown() calc groups top-5 alarm sources by plant area for
a new Areas tab / AreaCard component. Disabled AlarmSimTick so it stops
generating data during dev.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:38:02 -05:00
parent 4b07272556
commit 5f8c06ca80
9 changed files with 439 additions and 2 deletions

View File

@@ -0,0 +1,10 @@
{
"scope": "G",
"version": 1,
"restricted": false,
"overridable": true,
"files": [
"view.json"
],
"attributes": {}
}

View File

@@ -0,0 +1,168 @@
{
"custom": {},
"params": {
"area": null,
"top": null,
"total": null
},
"propConfig": {
"params.area": {
"paramDirection": "input",
"persistent": false
},
"params.top": {
"paramDirection": "input",
"persistent": false
},
"params.total": {
"paramDirection": "input",
"persistent": false
}
},
"props": {
"defaultSize": {
"height": 240,
"width": 340
}
},
"root": {
"children": [
{
"children": [
{
"meta": {
"name": "lblArea"
},
"position": {
"grow": 1,
"shrink": 1
},
"propConfig": {
"props.text": {
"binding": {
"config": {
"path": "view.params.area"
},
"transforms": [
{
"code": "\treturn value or ''",
"type": "script"
}
],
"type": "property"
}
}
},
"props": {
"style": {
"fontSize": "15px",
"fontWeight": "bold",
"overflow": "hidden",
"textOverflow": "ellipsis",
"whiteSpace": "nowrap"
}
},
"type": "ia.display.label"
},
{
"meta": {
"name": "chipTotal"
},
"position": {
"shrink": 0
},
"propConfig": {
"props.text": {
"binding": {
"config": {
"path": "view.params.total"
},
"transforms": [
{
"code": "\ttry:\n\t\treturn '%s activations' % PrimeControls.fmt.num(int(value or 0))\n\texcept:\n\t\treturn ''",
"type": "script"
}
],
"type": "property"
}
}
},
"props": {
"style": {
"classes": "PrimeControls/Chip/Flat",
"padding": "2px 8px"
}
},
"type": "ia.display.label"
}
],
"meta": {
"name": "headerRow"
},
"position": {
"basis": "26px",
"shrink": 0
},
"props": {
"alignItems": "center",
"style": {
"gap": "8px"
}
},
"type": "ia.container.flex"
},
{
"meta": {
"name": "repTop"
},
"position": {
"grow": 1
},
"propConfig": {
"props.instances": {
"binding": {
"config": {
"path": "view.params.top"
},
"transforms": [
{
"code": "\tout = []\n\tfor r in (value or []):\n\t\ttry:\n\t\t\tout.append({'rank': int(r['rank']), 'source': r['source'], 'label': r['label'], 'count': int(r['count']), 'pct': float(r['pct'])})\n\t\texcept:\n\t\t\tpass\n\treturn out",
"type": "script"
}
],
"type": "property"
}
}
},
"props": {
"direction": "column",
"elementPosition": {
"basis": "34px",
"grow": 0,
"shrink": 0
},
"path": "PrimeControls/Components/SourceRow",
"style": {
"gap": "4px",
"overflow": "hidden"
},
"useDefaultViewHeight": false,
"useDefaultViewWidth": false
},
"type": "ia.display.flex-repeater"
}
],
"meta": {
"name": "root"
},
"props": {
"direction": "column",
"style": {
"classes": "PrimeControls/Card",
"gap": "8px",
"overflow": "hidden"
}
},
"type": "ia.container.flex"
}
}

View File

@@ -128,6 +128,23 @@
"path": "PrimeControls/Components/TabButton"
},
"type": "ia.display.view"
},
{
"meta": {
"name": "tabBtn4"
},
"position": {
"basis": "140px",
"shrink": 0
},
"props": {
"params": {
"index": 4,
"title": "Areas"
},
"path": "PrimeControls/Components/TabButton"
},
"type": "ia.display.view"
}
],
"meta": {
@@ -301,6 +318,36 @@
"path": "PrimeControls/Tabs/Journal"
},
"type": "ia.display.view"
},
{
"meta": {
"name": "tabAreas"
},
"position": {
"grow": 1
},
"propConfig": {
"position.display": {
"binding": {
"config": {
"expression": "{session.custom.PrimeControls.ui.selectedTab} = 4"
},
"type": "expr"
}
},
"props.params.bundle": {
"binding": {
"config": {
"path": "view.custom.bundle"
},
"type": "property"
}
}
},
"props": {
"path": "PrimeControls/Tabs/Areas"
},
"type": "ia.display.view"
}
],
"meta": {

View File

@@ -0,0 +1,10 @@
{
"scope": "G",
"version": 1,
"restricted": false,
"overridable": true,
"files": [
"view.json"
],
"attributes": {}
}

View File

@@ -0,0 +1,141 @@
{
"custom": {},
"params": {
"bundle": null
},
"propConfig": {
"params.bundle": {
"paramDirection": "input",
"persistent": false
}
},
"props": {
"defaultSize": {
"height": 700,
"width": 1600
}
},
"root": {
"children": [
{
"meta": {
"name": "lblTitle"
},
"position": {
"shrink": 0
},
"props": {
"style": {
"classes": "PrimeControls/Text/Muted",
"fontWeight": "bold",
"letterSpacing": "1px"
},
"text": "TOP 5 ALARMS BY AREA"
},
"type": "ia.display.label"
},
{
"meta": {
"name": "repAreas"
},
"position": {
"grow": 1
},
"propConfig": {
"position.display": {
"binding": {
"config": {
"path": "view.params.bundle"
},
"transforms": [
{
"code": "\ttry:\n\t\treturn len(value['areas_top'] or []) > 0\n\texcept:\n\t\treturn False",
"type": "script"
}
],
"type": "property"
}
},
"props.instances": {
"binding": {
"config": {
"path": "view.params.bundle"
},
"transforms": [
{
"code": "\ttry:\n\t\treturn [{'area': a['area'], 'total': a['total'], 'top': a['top']} for a in (value['areas_top'] or [])]\n\texcept:\n\t\treturn []",
"type": "script"
}
],
"type": "property"
}
}
},
"props": {
"alignContent": "flex-start",
"direction": "row",
"elementPosition": {
"basis": "340px",
"grow": 1,
"shrink": 0
},
"path": "PrimeControls/Components/AreaCard",
"style": {
"gap": "10px",
"overflow": "auto"
},
"useDefaultViewHeight": false,
"useDefaultViewWidth": false,
"wrap": "wrap",
"elementStyle": {
"maxWidth": "460px"
}
},
"type": "ia.display.flex-repeater"
},
{
"meta": {
"name": "emptyAreas"
},
"position": {
"grow": 1
},
"propConfig": {
"position.display": {
"binding": {
"config": {
"path": "view.params.bundle"
},
"transforms": [
{
"code": "\ttry:\n\t\treturn len(value['areas_top'] or []) == 0\n\texcept:\n\t\treturn True",
"type": "script"
}
],
"type": "property"
}
}
},
"props": {
"params": {
"icon": "material/layers_clear",
"message": "No alarm activity in range"
},
"path": "PrimeControls/Components/EmptyState"
},
"type": "ia.display.view"
}
],
"meta": {
"name": "root"
},
"props": {
"direction": "column",
"style": {
"gap": "8px",
"padding": "10px"
}
},
"type": "ia.container.flex"
}
}

View File

@@ -139,7 +139,7 @@
},
"transforms": [
{
"code": "\ttry:\n\t\trows = value['bundle']['pareto']['rows'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\ttotal = value['bundle']['pareto']['total_activations'] or 0\n\texcept:\n\t\ttotal = 0\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\trank = 0\n\tfor r in rows[:10]:\n\t\ttry:\n\t\t\trank = rank + 1\n\t\t\tsrc = r['source'] or ''\n\t\t\tcnt = r['count'] or 0\n\t\t\td = {}\n\t\t\td['rank'] = rank\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['count'] = cnt\n\t\t\tif total > 0:\n\t\t\t\td['share'] = round(cnt * 100.0 / total, 1)\n\t\t\telse:\n\t\t\t\td['share'] = 0\n\t\t\ttry:\n\t\t\t\td['cum'] = PrimeControls.fmt.pct(r['cum_pct'])\n\t\t\texcept:\n\t\t\t\td['cum'] = '--'\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'count', 'cum'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'PrimeControls/PrioritySoft/' + pname}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out",
"code": "\ttry:\n\t\trows = value['bundle']['pareto']['rows'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\ttotal = value['bundle']['pareto']['total_activations'] or 0\n\texcept:\n\t\ttotal = 0\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\trank = 0\n\tfor r in rows[:10]:\n\t\ttry:\n\t\t\trank = rank + 1\n\t\t\tsrc = r['source'] or ''\n\t\t\tcnt = r['count'] or 0\n\t\t\td = {}\n\t\t\td['rank'] = rank\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['count'] = cnt\n\t\t\tif total > 0:\n\t\t\t\td['share'] = round(cnt * 100.0 / total, 1)\n\t\t\telse:\n\t\t\t\td['share'] = 0\n\t\t\ttry:\n\t\t\t\td['cum'] = PrimeControls.fmt.pct(r['cum_pct'])\n\t\t\texcept:\n\t\t\t\td['cum'] = '--'\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'count', 'cum', 'share'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'PrimeControls/PrioritySoft/' + pname}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out",
"type": "script"
}
],

View File

@@ -536,6 +536,40 @@ def heatmap(instances, opts=None):
"max_count": mx, "total": total}
def area_breakdown(instances, opts=None, per_area=5):
"""Top-N most active alarm sources per plant area.
Returns [{area, total, top: [{rank, source, label, priority_name, count,
pct}]}] sorted by area total desc; pct is share of the AREA's activations."""
groups = {}
for inst in instances or []:
if inst.get("active_ms") is None:
continue
a = inst.get("area") or "unknown"
if a not in groups:
groups[a] = {"total": 0, "counts": {}, "refs": {}}
g = groups[a]
g["total"] += 1
src = inst["source"]
g["counts"][src] = g["counts"].get(src, 0) + 1
if src not in g["refs"]:
g["refs"][src] = inst
out = []
for a in groups:
g = groups[a]
ordered = sorted(g["counts"].items(),
key=lambda kv: (-kv[1], g["refs"][kv[0]]["label"]))[:per_area]
top = []
for i in range(len(ordered)):
src, c = ordered[i]
ref = g["refs"][src]
top.append({"rank": i + 1, "source": src, "label": ref["label"],
"priority_name": ref["priority_name"], "count": c,
"pct": 100.0 * c / g["total"] if g["total"] else 0.0})
out.append({"area": a, "total": g["total"], "top": top})
out.sort(key=lambda x: (-x["total"], x["area"]))
return out
def priority_distribution(instances, opts=None):
o = opts_merged(opts)
raw_counts = {}
@@ -727,6 +761,7 @@ def _assemble(instances, active_now, shelved_count, start_ms, end_ms, now_ms,
par = pareto(current, o)
prio = priority_distribution(current, o)
heat = heatmap(current, o)
areas_top = area_breakdown(current, o)
# prior mirrors (cheap subset for deltas)
p_start = start_ms - (end_ms - start_ms)
@@ -800,6 +835,7 @@ def _assemble(instances, active_now, shelved_count, start_ms, end_ms, now_ms,
"heatmap": heat,
"mtta_mttr": mm,
"pareto": par,
"areas_top": areas_top,
"top_sources": top_sources(par, 5),
"chattering": chat,
"fleeting": fleet,

View File

@@ -10,6 +10,6 @@
"sharedThread": false,
"delay": 5000,
"fixedDelay": true,
"enabled": true
"enabled": false
}
}

View File

@@ -310,6 +310,31 @@ def test_build_bundle_filters():
assert 0 < b["meta"]["event_count"] < b_all["meta"]["event_count"]
def test_area_breakdown_top5_per_area():
rows = []
# AreaA: 3 sources with 7/2/1 activations; AreaB: 6 sources with 6..1
for i, n in enumerate((7, 2, 1)):
src = "prov:default:/tag:Plant/AreaA/T%d:/alm:T%d" % (i, i)
for j in range(n):
rows += lifecycle("a%d_%d" % (i, j), src, T0 + (i * 50 + j) * M, dur_ms=M)
for i in range(6):
src = "prov:default:/tag:Plant/AreaB/S%d:/alm:S%d" % (i, i)
for j in range(6 - i):
rows += lifecycle("b%d_%d" % (i, j), src, T0 + (i * 40 + j) * M, dur_ms=M)
out = calc.area_breakdown(calc.correlate_instances(rows)[0])
assert [a["area"] for a in out] == ["AreaB", "AreaA"] # 21 vs 10 activations
b = out[0]
assert b["total"] == 21 and len(b["top"]) == 5 # capped at 5 of 6
assert [t["count"] for t in b["top"]] == [6, 5, 4, 3, 2]
assert b["top"][0]["rank"] == 1
assert abs(b["top"][0]["pct"] - 100.0 * 6 / 21) < 0.01 # share of AREA total
a = out[1]
assert a["total"] == 10 and len(a["top"]) == 3
# bundle carries it
bundle = calc.build_bundle(rows, [], None, T0, T0 + 4 * H, T0 + 4 * H)
assert [x["area"] for x in bundle["areas_top"]] == ["AreaB", "AreaA"]
def test_build_bundle_overview_stays_unfiltered():
start, end = T0, T0 + 4 * H
b_all = calc.build_bundle(synth_rows(), [], None, start, end, end)