diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/resource.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/view.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/view.json new file mode 100644 index 0000000..4c3089f --- /dev/null +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/view.json @@ -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" + } +} diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Dashboard/view.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Dashboard/view.json index 18947ce..c6eb696 100644 --- a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Dashboard/view.json +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Dashboard/view.json @@ -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": { diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/Areas/resource.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/Areas/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/Areas/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/Areas/view.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/Areas/view.json new file mode 100644 index 0000000..f9dae4d --- /dev/null +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/Areas/view.json @@ -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" + } +} diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/BadActors/view.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/BadActors/view.json index b231024..fbd100e 100644 --- a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/BadActors/view.json +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Tabs/BadActors/view.json @@ -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" } ], diff --git a/ignition/gateway/projects/PrimeBAT/ignition/script-python/PrimeControls/calc/code.py b/ignition/gateway/projects/PrimeBAT/ignition/script-python/PrimeControls/calc/code.py index 4720525..a695471 100644 --- a/ignition/gateway/projects/PrimeBAT/ignition/script-python/PrimeControls/calc/code.py +++ b/ignition/gateway/projects/PrimeBAT/ignition/script-python/PrimeControls/calc/code.py @@ -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, diff --git a/ignition/gateway/projects/SimHarness/ignition/timer/AlarmSimTick/resource.json b/ignition/gateway/projects/SimHarness/ignition/timer/AlarmSimTick/resource.json index c5b6a70..5487d82 100644 --- a/ignition/gateway/projects/SimHarness/ignition/timer/AlarmSimTick/resource.json +++ b/ignition/gateway/projects/SimHarness/ignition/timer/AlarmSimTick/resource.json @@ -10,6 +10,6 @@ "sharedThread": false, "delay": 5000, "fixedDelay": true, - "enabled": true + "enabled": false } } diff --git a/tests/test_calc.py b/tests/test_calc.py index 92b0a2e..58e1ca2 100644 --- a/tests/test_calc.py +++ b/tests/test_calc.py @@ -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)