Phase 1 data layer: calc/fmt/alarms + 31 CPython tests + live gateway P1 smoke
pytest 31/31. In-gateway end-to-end: 559 activations correlated (uuid mode), 3 chatterers, 34 fleeting, 1 flood, health F(22) on the deliberately-bad sim plant. Gate G1 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,396 @@
|
||||
# PrimeControls.alarms - gateway adapter (the ONLY impure module).
|
||||
# Journal access via system.alarm.queryJournal/queryStatus exclusively - no SQL,
|
||||
# no journal name assumptions, every query bounded by the caller's date range.
|
||||
# Jython 2.7. Bare except is used on defensive paths deliberately: java exceptions
|
||||
# do not subclass Python Exception and would bypass 'except Exception'.
|
||||
from __future__ import division, print_function
|
||||
|
||||
import sys
|
||||
|
||||
from PrimeControls import calc, fmt
|
||||
|
||||
|
||||
def _err_text():
|
||||
try:
|
||||
return "%s" % (sys.exc_info()[1],)
|
||||
except:
|
||||
return "unknown error"
|
||||
|
||||
|
||||
def _now_ms():
|
||||
try:
|
||||
return system.date.toMillis(system.date.now())
|
||||
except:
|
||||
import time
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _ms_of(java_date):
|
||||
try:
|
||||
if java_date is None:
|
||||
return None
|
||||
return java_date.getTime()
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def _norm_event(evt):
|
||||
try:
|
||||
source = str(evt.getSource() or "")
|
||||
state = None
|
||||
try:
|
||||
if evt.getAckData() is not None:
|
||||
state = "ack"
|
||||
elif evt.getClearedData() is not None:
|
||||
state = "clear"
|
||||
elif evt.getActiveData() is not None:
|
||||
state = "active"
|
||||
except:
|
||||
state = None
|
||||
if state is None:
|
||||
s = ""
|
||||
try:
|
||||
s = str(evt.getState() or "")
|
||||
except:
|
||||
pass
|
||||
if s.startswith("Cleared"):
|
||||
state = "clear"
|
||||
elif "Acknowledged" in s and not s.startswith("Active,"):
|
||||
state = "ack"
|
||||
else:
|
||||
state = "active"
|
||||
ts = None
|
||||
try:
|
||||
ts = _ms_of(evt.get("eventTime"))
|
||||
except:
|
||||
ts = None
|
||||
if ts is None:
|
||||
return None
|
||||
prio_raw = None
|
||||
try:
|
||||
p = evt.getPriority()
|
||||
try:
|
||||
prio_raw = int(p.ordinal())
|
||||
except:
|
||||
prio_raw = str(p)
|
||||
except:
|
||||
prio_raw = None
|
||||
lvl, name = calc.normalize_priority(prio_raw)
|
||||
display_path = ""
|
||||
try:
|
||||
display_path = str(evt.getDisplayPath() or "")
|
||||
except:
|
||||
pass
|
||||
is_system = source.startswith("evt:") or ":/alm:" not in source
|
||||
try:
|
||||
if evt.get("isSystemEvent"):
|
||||
is_system = True
|
||||
except:
|
||||
pass
|
||||
ack_user = None
|
||||
try:
|
||||
u = evt.get("ackUserName")
|
||||
if u:
|
||||
ack_user = str(u)
|
||||
except:
|
||||
pass
|
||||
event_id = None
|
||||
try:
|
||||
event_id = str(evt.getId())
|
||||
except:
|
||||
pass
|
||||
return {"event_id": event_id, "source": source, "display_path": display_path,
|
||||
"priority": lvl, "priority_name": name, "state": state, "ts": ts,
|
||||
"is_system": is_system, "ack_user": ack_user}
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_journal(start_ms, end_ms, max_events, include_data=False):
|
||||
"""Returns (rows, dropped, truncated, error)."""
|
||||
rows = []
|
||||
dropped = 0
|
||||
truncated = False
|
||||
try:
|
||||
kwargs = {"startDate": int(start_ms), "endDate": int(end_ms)}
|
||||
result = system.alarm.queryJournal(**kwargs)
|
||||
for evt in result:
|
||||
r = _norm_event(evt)
|
||||
if r is None:
|
||||
dropped += 1
|
||||
continue
|
||||
rows.append(r)
|
||||
if len(rows) >= max_events:
|
||||
truncated = True
|
||||
break
|
||||
return rows, dropped, truncated, None
|
||||
except:
|
||||
return rows, dropped, truncated, _err_text()
|
||||
|
||||
|
||||
def _fetch_status():
|
||||
out = []
|
||||
try:
|
||||
result = system.alarm.queryStatus(state=["ActiveUnacked", "ActiveAcked"])
|
||||
for evt in result:
|
||||
try:
|
||||
source = str(evt.getSource() or "")
|
||||
if source.startswith("evt:") or ":/alm:" not in source:
|
||||
continue
|
||||
active_ms = None
|
||||
try:
|
||||
data = evt.getActiveData()
|
||||
if data is not None:
|
||||
active_ms = _ms_of(data.get("eventTime"))
|
||||
except:
|
||||
active_ms = None
|
||||
if active_ms is None:
|
||||
try:
|
||||
active_ms = _ms_of(evt.get("eventTime"))
|
||||
except:
|
||||
pass
|
||||
prio_raw = None
|
||||
try:
|
||||
p = evt.getPriority()
|
||||
try:
|
||||
prio_raw = int(p.ordinal())
|
||||
except:
|
||||
prio_raw = str(p)
|
||||
except:
|
||||
pass
|
||||
lvl, name = calc.normalize_priority(prio_raw)
|
||||
unacked = False
|
||||
try:
|
||||
unacked = "Unacknowledged" in str(evt.getState() or "")
|
||||
except:
|
||||
pass
|
||||
display_path = ""
|
||||
try:
|
||||
display_path = str(evt.getDisplayPath() or "")
|
||||
except:
|
||||
pass
|
||||
out.append({"source": source, "display_path": display_path,
|
||||
"priority": lvl, "priority_name": name,
|
||||
"active_ms": active_ms, "unacked": unacked})
|
||||
except:
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_shelved():
|
||||
try:
|
||||
paths = system.alarm.getShelvedPaths()
|
||||
return len(list(paths)) if paths is not None else 0
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def _clean_filters(options):
|
||||
f = {"priorities": [], "areas": [], "states": [], "search": ""}
|
||||
if not options:
|
||||
return f
|
||||
try:
|
||||
for k in ("priorities", "areas", "states"):
|
||||
v = options.get(k)
|
||||
if v:
|
||||
f[k] = [x for x in list(v)]
|
||||
f["priorities"] = [int(x) for x in f["priorities"]]
|
||||
f["areas"] = [str(x) for x in f["areas"]]
|
||||
f["states"] = [str(x) for x in f["states"]]
|
||||
f["search"] = str(options.get("search") or "")
|
||||
except:
|
||||
pass
|
||||
return f
|
||||
|
||||
|
||||
# ---------------- Perspective entry points ----------------
|
||||
|
||||
|
||||
def getDashboardBundle(startMs, endMs, options=None):
|
||||
try:
|
||||
s = int(startMs)
|
||||
e = int(endMs)
|
||||
opts = calc.opts_merged(None)
|
||||
filters = _clean_filters(options)
|
||||
span = max(e - s, 1)
|
||||
rows, dropped, truncated, err = _fetch_journal(
|
||||
s - span, e, int(opts["max_events"]))
|
||||
active_now = _fetch_status()
|
||||
shelved = _fetch_shelved()
|
||||
bundle = calc.build_bundle(rows, active_now, shelved, s, e, _now_ms(),
|
||||
{"filters": filters})
|
||||
bundle["meta"]["dropped_rows"] = dropped
|
||||
bundle["meta"]["truncated"] = truncated
|
||||
if err:
|
||||
bundle["meta"]["error"] = err
|
||||
bundle["insights"] = fmt.insights(bundle)
|
||||
return bundle
|
||||
except:
|
||||
try:
|
||||
bundle = calc.build_bundle([], [], None, int(startMs or 0),
|
||||
int(endMs or 0), _now_ms(), None)
|
||||
except:
|
||||
bundle = calc.build_bundle([], [], None, 0, 0, 0, None)
|
||||
bundle["meta"]["error"] = _err_text()
|
||||
bundle["insights"] = []
|
||||
return bundle
|
||||
|
||||
|
||||
STATE_LABELS = {"active": "Active", "clear": "Cleared", "ack": "Acked"}
|
||||
|
||||
|
||||
def _journal_rows(start_ms, end_ms, filters, max_rows):
|
||||
rows, dropped, truncated, err = _fetch_journal(start_ms, end_ms, max_rows)
|
||||
out = []
|
||||
prios = set(filters.get("priorities") or [])
|
||||
areas = set(filters.get("areas") or [])
|
||||
states = set(filters.get("states") or [])
|
||||
search = (filters.get("search") or "").strip().lower()
|
||||
for r in rows:
|
||||
if r["is_system"]:
|
||||
continue
|
||||
parsed = calc.parse_source(r["source"], r["display_path"])
|
||||
if prios and r["priority"] not in prios:
|
||||
continue
|
||||
if areas and parsed["area"] not in areas:
|
||||
continue
|
||||
if states:
|
||||
tag = {"active": "active", "clear": "cleared", "ack": "acked"}[r["state"]]
|
||||
if tag not in states and not ("unacked" in states and r["state"] == "active"):
|
||||
continue
|
||||
if search and search not in (r["source"] + " " + parsed["label"]).lower():
|
||||
continue
|
||||
out.append({"id": r["event_id"], "time_ms": r["ts"],
|
||||
"time_label": fmt.day_clock(r["ts"]),
|
||||
"source": r["source"], "label": parsed["label"],
|
||||
"area": parsed["area"], "state": r["state"],
|
||||
"state_label": STATE_LABELS.get(r["state"], r["state"]),
|
||||
"priority": r["priority"], "priority_name": r["priority_name"],
|
||||
"ack_user": r["ack_user"] or ""})
|
||||
out.sort(key=lambda r: -(r["time_ms"] or 0))
|
||||
return out, truncated, err
|
||||
|
||||
|
||||
def getJournalPage(startMs, endMs, filters=None, page=0, pageSize=50):
|
||||
try:
|
||||
f = _clean_filters(filters)
|
||||
rows, truncated, err = _journal_rows(int(startMs), int(endMs), f,
|
||||
int(calc.DEFAULTS["max_events"]))
|
||||
page = max(0, int(page or 0))
|
||||
page_size = max(1, int(pageSize or 50))
|
||||
start = page * page_size
|
||||
return {"rows": rows[start:start + page_size], "total": len(rows),
|
||||
"page": page, "page_size": page_size, "truncated": truncated,
|
||||
"error": err}
|
||||
except:
|
||||
return {"rows": [], "total": 0, "page": 0, "page_size": int(pageSize or 50),
|
||||
"truncated": False, "error": _err_text()}
|
||||
|
||||
|
||||
def journalCsv(startMs, endMs, filters=None, maxRows=10000):
|
||||
try:
|
||||
f = _clean_filters(filters)
|
||||
rows, truncated, err = _journal_rows(int(startMs), int(endMs), f, int(maxRows))
|
||||
headers = ["time", "state", "priority", "label", "area", "source", "ack_user"]
|
||||
data = [[r["time_label"], r["state_label"], r["priority_name"], r["label"],
|
||||
r["area"], r["source"], r["ack_user"]] for r in rows]
|
||||
ds = system.dataset.toDataSet(headers, data)
|
||||
return system.dataset.toCSV(ds)
|
||||
except:
|
||||
return "error,%s" % _err_text().replace(",", ";")
|
||||
|
||||
|
||||
def getSourceDetail(source, startMs, endMs):
|
||||
try:
|
||||
s = int(startMs)
|
||||
e = int(endMs)
|
||||
src = str(source or "")
|
||||
rows, dropped, truncated, err = _fetch_journal(s, e, int(calc.DEFAULTS["max_events"]))
|
||||
mine = [r for r in rows if r["source"] == src]
|
||||
instances, _mode = calc.correlate_instances(mine)
|
||||
parsed = calc.parse_source(src, mine[0]["display_path"] if mine else "")
|
||||
day_bins = calc.rate_bins(instances, s, e, {"bin_ms": 86400000, "max_bins": 400})
|
||||
daily = [{"t0": b["t0"], "count": b["count"]} for b in day_bins["bins"]]
|
||||
ttas = [i["tta_ms"] for i in instances if i.get("tta_ms") is not None]
|
||||
durs = [i["duration_ms"] for i in instances if i.get("duration_ms") is not None]
|
||||
fleet = len([i for i in instances
|
||||
if i.get("duration_ms") is not None
|
||||
and i["duration_ms"] < calc.DEFAULTS["fleeting_s"] * 1000])
|
||||
ack_users = {}
|
||||
for r in mine:
|
||||
if r["state"] == "ack" and r.get("ack_user"):
|
||||
ack_users[r["ack_user"]] = ack_users.get(r["ack_user"], 0) + 1
|
||||
top_ack = [{"user": u, "count": c} for u, c in
|
||||
sorted(ack_users.items(), key=lambda kv: -kv[1])[:5]]
|
||||
events = []
|
||||
for r in sorted(mine, key=lambda r: -r["ts"])[:100]:
|
||||
events.append({"id": r["event_id"], "time_ms": r["ts"],
|
||||
"time_label": fmt.day_clock(r["ts"]),
|
||||
"state_label": STATE_LABELS.get(r["state"], r["state"]),
|
||||
"priority_name": r["priority_name"],
|
||||
"ack_user": r["ack_user"] or ""})
|
||||
return {"label": parsed["label"], "area": parsed["area"], "events": events,
|
||||
"daily": daily,
|
||||
"stats": {"count": len([i for i in instances if i["active_ms"] is not None]),
|
||||
"avg_tta_ms": (sum(ttas) / len(ttas)) if ttas else None,
|
||||
"avg_active_ms": (sum(durs) / len(durs)) if durs else None,
|
||||
"fleeting_count": fleet, "top_ack_users": top_ack},
|
||||
"error": err}
|
||||
except:
|
||||
return {"label": str(source or ""), "area": "", "events": [], "daily": [],
|
||||
"stats": {"count": 0, "avg_tta_ms": None, "avg_active_ms": None,
|
||||
"fleeting_count": 0, "top_ack_users": []},
|
||||
"error": _err_text()}
|
||||
|
||||
|
||||
def getEventData(eventId, aroundMs, source):
|
||||
try:
|
||||
eid = str(eventId or "")
|
||||
mid = int(aroundMs)
|
||||
props = []
|
||||
seen = {}
|
||||
try:
|
||||
result = system.alarm.queryJournal(startDate=mid - 3600000,
|
||||
endDate=mid + 3600000,
|
||||
includeData=True)
|
||||
except:
|
||||
result = system.alarm.queryJournal(startDate=mid - 3600000,
|
||||
endDate=mid + 3600000)
|
||||
for evt in result:
|
||||
try:
|
||||
if str(evt.getId()) != eid:
|
||||
continue
|
||||
try:
|
||||
names = evt.getProperties()
|
||||
except:
|
||||
names = []
|
||||
for p in names or []:
|
||||
try:
|
||||
n = str(p)
|
||||
v = evt.get(p)
|
||||
if n not in seen:
|
||||
seen[n] = True
|
||||
props.append({"name": n, "value": str(v)})
|
||||
except:
|
||||
continue
|
||||
except:
|
||||
continue
|
||||
props.sort(key=lambda kv: kv["name"])
|
||||
return {"props": props, "error": None}
|
||||
except:
|
||||
return {"props": [], "error": _err_text()}
|
||||
|
||||
|
||||
def getShiftReportText(shiftHours=8, anchorHour=0, options=None):
|
||||
try:
|
||||
bounds = fmt.shift_bounds(_now_ms(), int(shiftHours), int(anchorHour))
|
||||
s, e = bounds["previous"]
|
||||
bundle = getDashboardBundle(s, e, options)
|
||||
report = fmt.shift_report(bundle, bounds["label"])
|
||||
return {"text": report["text"], "start_ms": s, "end_ms": e,
|
||||
"label": bounds["label"]}
|
||||
except:
|
||||
return {"text": "Shift report unavailable: %s" % _err_text(),
|
||||
"start_ms": 0, "end_ms": 0, "label": ""}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"scope": "A",
|
||||
"version": 1,
|
||||
"restricted": false,
|
||||
"overridable": true,
|
||||
"files": [
|
||||
"code.py"
|
||||
],
|
||||
"attributes": {
|
||||
"hintScope": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
# PrimeControls.calc - PURE alarm analytics computation (no system.*, no I/O, no clock).
|
||||
# Runs under Jython 2.7 (Ignition) AND CPython 3 (pytest). No f-strings.
|
||||
from __future__ import division, print_function
|
||||
|
||||
DEFAULTS = {
|
||||
"bin_ms": 600000, "flood_per_10min": 10, "flood_end_per_10min": 5,
|
||||
"target_per_hr": 6.0,
|
||||
"chatter_per_hr": 10.0, "chatter_gap_s": 120, "chatter_min_count": 5,
|
||||
"fleeting_s": 10, "fleeting_min_count": 3,
|
||||
"standing_hours": 24, "trend_buckets": 24,
|
||||
"max_events": 50000, "max_bins": 5000,
|
||||
"delta_flat_pct": 5.0, "insights_max": 5,
|
||||
"priority_target": {"low": 80.0, "medium": 15.0, "high": 5.0},
|
||||
"health_weights": {"rate": 0.30, "flood": 0.25, "chatter": 0.20,
|
||||
"standing": 0.15, "priority": 0.10},
|
||||
"grade_cuts": [(90, "A"), (80, "B"), (70, "C"), (60, "D"), (0, "F")],
|
||||
"list_cap": 50, "active_now_cap": 200,
|
||||
}
|
||||
|
||||
PRIORITY_NAMES = {0: "Diagnostic", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"}
|
||||
|
||||
|
||||
def opts_merged(opts):
|
||||
out = dict(DEFAULTS)
|
||||
if opts:
|
||||
for k in opts:
|
||||
out[k] = opts[k]
|
||||
return out
|
||||
|
||||
|
||||
def clamp(v, lo, hi):
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
def _median(values):
|
||||
vs = sorted(v for v in values if v is not None)
|
||||
n = len(vs)
|
||||
if n == 0:
|
||||
return None
|
||||
if n % 2:
|
||||
return float(vs[n // 2])
|
||||
return (vs[n // 2 - 1] + vs[n // 2]) / 2.0
|
||||
|
||||
|
||||
def _mean(values):
|
||||
vs = [v for v in values if v is not None]
|
||||
if not vs:
|
||||
return None
|
||||
return sum(vs) / float(len(vs))
|
||||
|
||||
|
||||
def _safe_div(a, b):
|
||||
try:
|
||||
if not b:
|
||||
return None
|
||||
return a / float(b)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------- path parsing ----------------
|
||||
|
||||
|
||||
def parse_source(source, display_path):
|
||||
source = source or ""
|
||||
display_path = display_path or ""
|
||||
tag_segments = []
|
||||
alarm_name = ""
|
||||
if ":/tag:" in source:
|
||||
tag_part = source.split(":/tag:", 1)[1]
|
||||
tag_path = tag_part.split(":", 1)[0]
|
||||
tag_segments = [s for s in tag_path.split("/") if s]
|
||||
if ":/alm:" in source:
|
||||
alarm_name = source.split(":/alm:", 1)[1].split(":", 1)[0]
|
||||
if display_path:
|
||||
segments = [s for s in display_path.split("/") if s]
|
||||
elif tag_segments:
|
||||
segments = list(tag_segments)
|
||||
if alarm_name and (not segments or alarm_name.lower() != segments[-1].lower()):
|
||||
segments.append(alarm_name)
|
||||
else:
|
||||
segments = [source] if source else ["unknown"]
|
||||
if len(segments) >= 2:
|
||||
label = "/".join(segments[-2:])
|
||||
else:
|
||||
label = segments[0]
|
||||
base = tag_segments if tag_segments else segments
|
||||
if len(base) >= 2:
|
||||
area = base[-2]
|
||||
else:
|
||||
area = base[0] if base else "unknown"
|
||||
return {"label": label, "area": area, "segments": segments}
|
||||
|
||||
|
||||
def normalize_priority(value):
|
||||
try:
|
||||
iv = int(value)
|
||||
if 0 <= iv <= 4:
|
||||
return iv, PRIORITY_NAMES[iv]
|
||||
except Exception:
|
||||
pass
|
||||
s = str(value).strip().lower() if value is not None else ""
|
||||
for lvl in PRIORITY_NAMES:
|
||||
if PRIORITY_NAMES[lvl].lower() in s and s:
|
||||
return lvl, PRIORITY_NAMES[lvl]
|
||||
return -1, (str(value) if value is not None else "Unknown")
|
||||
|
||||
|
||||
# ---------------- correlation ----------------
|
||||
|
||||
|
||||
def _new_instance(row):
|
||||
lvl, name = row.get("priority", -1), row.get("priority_name", "Unknown")
|
||||
parsed = parse_source(row.get("source"), row.get("display_path"))
|
||||
return {"event_id": row.get("event_id"), "source": row.get("source") or "",
|
||||
"label": parsed["label"], "area": parsed["area"],
|
||||
"priority": lvl, "priority_name": name,
|
||||
"active_ms": None, "clear_ms": None, "ack_ms": None,
|
||||
"duration_ms": None, "tta_ms": None, "open": True,
|
||||
"ack_user": None}
|
||||
|
||||
|
||||
def _finalize(inst):
|
||||
a, c, k = inst["active_ms"], inst["clear_ms"], inst["ack_ms"]
|
||||
inst["duration_ms"] = (c - a) if (a is not None and c is not None and c >= a) else None
|
||||
inst["tta_ms"] = (k - a) if (a is not None and k is not None and k >= a) else None
|
||||
inst["open"] = c is None
|
||||
return inst
|
||||
|
||||
|
||||
def correlate_instances(rows):
|
||||
rows = [r for r in (rows or []) if r and not r.get("is_system") and r.get("ts") is not None]
|
||||
rows.sort(key=lambda r: r["ts"])
|
||||
if not rows:
|
||||
return [], "uuid"
|
||||
with_id = [r for r in rows if r.get("event_id")]
|
||||
mode = "uuid" if len(with_id) >= 0.9 * len(rows) else "source-sequence"
|
||||
instances = []
|
||||
if mode == "uuid":
|
||||
groups, order = {}, []
|
||||
for i, r in enumerate(rows):
|
||||
k = r.get("event_id") or ("_noid_%d" % i)
|
||||
if k not in groups:
|
||||
groups[k] = []
|
||||
order.append(k)
|
||||
groups[k].append(r)
|
||||
for k in order:
|
||||
grp = groups[k]
|
||||
ident = None
|
||||
for r in grp:
|
||||
if r["state"] == "active":
|
||||
ident = r
|
||||
break
|
||||
inst = _new_instance(ident or grp[0])
|
||||
acts = [r["ts"] for r in grp if r["state"] == "active"]
|
||||
clears = [r["ts"] for r in grp if r["state"] == "clear"]
|
||||
acks = [r["ts"] for r in grp if r["state"] == "ack"]
|
||||
inst["active_ms"] = min(acts) if acts else None
|
||||
inst["clear_ms"] = min(clears) if clears else None
|
||||
inst["ack_ms"] = min(acks) if acks else None
|
||||
for r in grp:
|
||||
if r.get("ack_user"):
|
||||
inst["ack_user"] = r["ack_user"]
|
||||
break
|
||||
instances.append(_finalize(inst))
|
||||
else:
|
||||
open_by_src = {}
|
||||
for r in rows:
|
||||
src = r.get("source") or ""
|
||||
st = r.get("state")
|
||||
if st == "active":
|
||||
if src in open_by_src:
|
||||
instances.append(_finalize(open_by_src.pop(src)))
|
||||
inst = _new_instance(r)
|
||||
inst["active_ms"] = r["ts"]
|
||||
open_by_src[src] = inst
|
||||
else:
|
||||
inst = open_by_src.get(src)
|
||||
if inst is None:
|
||||
inst = _new_instance(r)
|
||||
open_by_src[src] = inst
|
||||
if st == "clear" and inst["clear_ms"] is None:
|
||||
inst["clear_ms"] = r["ts"]
|
||||
elif st == "ack" and inst["ack_ms"] is None:
|
||||
inst["ack_ms"] = r["ts"]
|
||||
if r.get("ack_user"):
|
||||
inst["ack_user"] = r["ack_user"]
|
||||
for src in open_by_src:
|
||||
instances.append(_finalize(open_by_src[src]))
|
||||
instances.sort(key=lambda i: i["active_ms"] if i["active_ms"] is not None else 0)
|
||||
return instances, mode
|
||||
|
||||
|
||||
def partition_instances(instances, start_ms, end_ms):
|
||||
prior_start = start_ms - (end_ms - start_ms)
|
||||
current, prior, orphans = [], [], []
|
||||
for inst in instances or []:
|
||||
a = inst.get("active_ms")
|
||||
if a is None:
|
||||
orphans.append(inst)
|
||||
elif start_ms <= a <= end_ms:
|
||||
current.append(inst)
|
||||
elif prior_start <= a < start_ms:
|
||||
prior.append(inst)
|
||||
else:
|
||||
orphans.append(inst)
|
||||
return current, prior, orphans
|
||||
|
||||
|
||||
def apply_filters(instances, filters):
|
||||
if not filters:
|
||||
return instances
|
||||
prios = set(filters.get("priorities") or [])
|
||||
areas = set(filters.get("areas") or [])
|
||||
states = set(filters.get("states") or [])
|
||||
search = (filters.get("search") or "").strip().lower()
|
||||
if not prios and not areas and not states and not search:
|
||||
return instances
|
||||
out = []
|
||||
for inst in instances:
|
||||
if prios and inst["priority"] not in prios:
|
||||
continue
|
||||
if areas and inst["area"] not in areas:
|
||||
continue
|
||||
if states:
|
||||
tags = set()
|
||||
if inst["open"]:
|
||||
tags.add("active")
|
||||
if inst["clear_ms"] is not None:
|
||||
tags.add("cleared")
|
||||
if inst["ack_ms"] is not None:
|
||||
tags.add("acked")
|
||||
else:
|
||||
tags.add("unacked")
|
||||
if not (states & tags):
|
||||
continue
|
||||
if search:
|
||||
hay = (inst["source"] + " " + inst["label"]).lower()
|
||||
if search not in hay:
|
||||
continue
|
||||
out.append(inst)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- rate / floods ----------------
|
||||
|
||||
|
||||
def rate_bins(instances, start_ms, end_ms, opts=None):
|
||||
o = opts_merged(opts)
|
||||
bin_ms = int(o["bin_ms"])
|
||||
empty = {"bins": [], "bin_ms": bin_ms,
|
||||
"target_per_bin": o["target_per_hr"] * bin_ms / 3600000.0,
|
||||
"flood_threshold": 1, "max_count": 0}
|
||||
if end_ms is None or start_ms is None or end_ms <= start_ms:
|
||||
return empty
|
||||
while (end_ms - start_ms) // bin_ms + 1 > o["max_bins"]:
|
||||
bin_ms *= 2
|
||||
origin = (start_ms // bin_ms) * bin_ms
|
||||
n = int((end_ms - origin) // bin_ms) + 1
|
||||
counts = {}
|
||||
for inst in instances or []:
|
||||
a = inst.get("active_ms")
|
||||
if a is None or a < origin or a > end_ms:
|
||||
continue
|
||||
idx = int((a - origin) // bin_ms)
|
||||
counts[idx] = counts.get(idx, 0) + 1
|
||||
flood_threshold = max(1, int(round(o["flood_per_10min"] * bin_ms / 600000.0)))
|
||||
bins = []
|
||||
max_count = 0
|
||||
for i in range(n):
|
||||
c = counts.get(i, 0)
|
||||
max_count = max(max_count, c)
|
||||
bins.append({"t0": origin + i * bin_ms, "t1": origin + (i + 1) * bin_ms,
|
||||
"count": c, "flood": c > flood_threshold})
|
||||
return {"bins": bins, "bin_ms": bin_ms,
|
||||
"target_per_bin": o["target_per_hr"] * bin_ms / 3600000.0,
|
||||
"flood_threshold": flood_threshold, "max_count": max_count}
|
||||
|
||||
|
||||
def flood_episodes(bins_result, instances, start_ms, end_ms, opts=None):
|
||||
o = opts_merged(opts)
|
||||
bins = (bins_result or {}).get("bins") or []
|
||||
bin_ms = (bins_result or {}).get("bin_ms") or o["bin_ms"]
|
||||
thr = (bins_result or {}).get("flood_threshold") or 1
|
||||
end_thr = max(1, int(round(o["flood_end_per_10min"] * bin_ms / 600000.0)))
|
||||
episodes = []
|
||||
cur = None
|
||||
for b in bins:
|
||||
if cur is None:
|
||||
if b["count"] > thr:
|
||||
cur = {"start_ms": b["t0"], "end_ms": b["t1"], "peak_bin_count": b["count"]}
|
||||
else:
|
||||
if b["count"] >= end_thr:
|
||||
cur["end_ms"] = b["t1"]
|
||||
cur["peak_bin_count"] = max(cur["peak_bin_count"], b["count"])
|
||||
else:
|
||||
episodes.append(cur)
|
||||
cur = None
|
||||
if cur is not None:
|
||||
episodes.append(cur)
|
||||
for ep in episodes:
|
||||
ep["duration_ms"] = ep["end_ms"] - ep["start_ms"]
|
||||
src_counts = {}
|
||||
area_counts = {}
|
||||
total = 0
|
||||
for inst in instances or []:
|
||||
a = inst.get("active_ms")
|
||||
if a is not None and ep["start_ms"] <= a < ep["end_ms"]:
|
||||
total += 1
|
||||
key = (inst["source"], inst["label"])
|
||||
src_counts[key] = src_counts.get(key, 0) + 1
|
||||
area_counts[inst["area"]] = area_counts.get(inst["area"], 0) + 1
|
||||
ep["event_count"] = total
|
||||
if src_counts:
|
||||
best = sorted(src_counts.items(), key=lambda kv: (-kv[1], kv[0][1]))[0]
|
||||
ep["top_source_label"] = best[0][1]
|
||||
ep["top_source_count"] = best[1]
|
||||
else:
|
||||
ep["top_source_label"] = ""
|
||||
ep["top_source_count"] = 0
|
||||
if area_counts:
|
||||
ep["top_area"] = sorted(area_counts.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
|
||||
else:
|
||||
ep["top_area"] = ""
|
||||
span = (end_ms - start_ms) if (end_ms and start_ms and end_ms > start_ms) else 0
|
||||
pct = 0.0
|
||||
if span:
|
||||
pct = 100.0 * sum(ep["duration_ms"] for ep in episodes) / span
|
||||
return {"episodes": episodes, "pct_time_in_flood": pct}
|
||||
|
||||
|
||||
# ---------------- detections ----------------
|
||||
|
||||
|
||||
def chattering(instances, start_ms, end_ms, opts=None):
|
||||
o = opts_merged(opts)
|
||||
window_h = _safe_div(end_ms - start_ms, 3600000.0) if (end_ms and start_ms) else None
|
||||
if not window_h or window_h <= 0:
|
||||
return []
|
||||
groups = {}
|
||||
for inst in instances or []:
|
||||
a = inst.get("active_ms")
|
||||
if a is None:
|
||||
continue
|
||||
groups.setdefault(inst["source"], []).append(inst)
|
||||
out = []
|
||||
for src in groups:
|
||||
insts = groups[src]
|
||||
count = len(insts)
|
||||
if count < o["chatter_min_count"]:
|
||||
continue
|
||||
per_hour = count / window_h
|
||||
times = sorted(i["active_ms"] for i in insts)
|
||||
gaps = [times[i + 1] - times[i] for i in range(len(times) - 1)]
|
||||
med_gap = _median(gaps)
|
||||
if per_hour > o["chatter_per_hr"] and med_gap is not None and med_gap <= o["chatter_gap_s"] * 1000:
|
||||
ref = insts[0]
|
||||
out.append({"source": src, "label": ref["label"], "area": ref["area"],
|
||||
"priority_name": ref["priority_name"], "count": count,
|
||||
"per_hour": per_hour, "median_gap_s": med_gap / 1000.0})
|
||||
out.sort(key=lambda r: (-r["per_hour"], r["label"]))
|
||||
return out[:int(o["list_cap"])]
|
||||
|
||||
|
||||
def fleeting(instances, opts=None):
|
||||
o = opts_merged(opts)
|
||||
thresh = o["fleeting_s"] * 1000
|
||||
groups = {}
|
||||
total = 0
|
||||
for inst in instances or []:
|
||||
d = inst.get("duration_ms")
|
||||
if d is not None and d < thresh:
|
||||
total += 1
|
||||
groups.setdefault(inst["source"], []).append(inst)
|
||||
sources = []
|
||||
for src in groups:
|
||||
insts = groups[src]
|
||||
if len(insts) < o["fleeting_min_count"]:
|
||||
continue
|
||||
ref = insts[0]
|
||||
sources.append({"source": src, "label": ref["label"], "area": ref["area"],
|
||||
"priority_name": ref["priority_name"], "count": len(insts),
|
||||
"median_s": (_median([i["duration_ms"] for i in insts]) or 0) / 1000.0})
|
||||
sources.sort(key=lambda r: (-r["count"], r["label"]))
|
||||
return {"total": total, "sources": sources[:int(o["list_cap"])]}
|
||||
|
||||
|
||||
def standing(active_now, open_instances, now_ms, opts=None):
|
||||
o = opts_merged(opts)
|
||||
thresh = o["standing_hours"] * 3600000.0
|
||||
rows = []
|
||||
if active_now:
|
||||
mode = "status"
|
||||
for s in active_now:
|
||||
a = s.get("active_ms")
|
||||
age = (now_ms - a) if (a is not None and now_ms) else None
|
||||
parsed = parse_source(s.get("source"), s.get("display_path"))
|
||||
rows.append({"source": s.get("source") or "", "label": parsed["label"],
|
||||
"area": parsed["area"],
|
||||
"priority_name": s.get("priority_name", "Unknown"),
|
||||
"active_ms": a, "age_ms": age,
|
||||
"age_h": (age / 3600000.0) if age is not None else None,
|
||||
"unacked": bool(s.get("unacked"))})
|
||||
else:
|
||||
mode = "journal"
|
||||
for inst in open_instances or []:
|
||||
a = inst.get("active_ms")
|
||||
age = (now_ms - a) if (a is not None and now_ms) else None
|
||||
rows.append({"source": inst["source"], "label": inst["label"],
|
||||
"area": inst["area"], "priority_name": inst["priority_name"],
|
||||
"active_ms": a, "age_ms": age,
|
||||
"age_h": (age / 3600000.0) if age is not None else None,
|
||||
"unacked": inst.get("ack_ms") is None})
|
||||
rows.sort(key=lambda r: -(r["age_ms"] or -1))
|
||||
count = len([r for r in rows if r["age_ms"] is not None and r["age_ms"] > thresh])
|
||||
return {"mode": mode, "count": count, "rows": rows[:int(o["list_cap"])]}
|
||||
|
||||
|
||||
# ---------------- MTTA / MTTR ----------------
|
||||
|
||||
|
||||
def mtta_mttr(instances, start_ms, end_ms, opts=None):
|
||||
o = opts_merged(opts)
|
||||
ttas = [i["tta_ms"] for i in instances or [] if i.get("tta_ms") is not None and i["tta_ms"] >= 0]
|
||||
durs = [i["duration_ms"] for i in instances or [] if i.get("duration_ms") is not None and i["duration_ms"] >= 0]
|
||||
result = {"mtta": {"mean_ms": _mean(ttas), "median_ms": _median(ttas), "count": len(ttas)},
|
||||
"mttr": {"mean_ms": _mean(durs), "median_ms": _median(durs), "count": len(durs)},
|
||||
"trend": []}
|
||||
if not end_ms or not start_ms or end_ms <= start_ms:
|
||||
return result
|
||||
span = end_ms - start_ms
|
||||
bin_ms = int(o["bin_ms"])
|
||||
width = max(bin_ms, ((span // int(o["trend_buckets"])) // bin_ms + 1) * bin_ms)
|
||||
origin = (start_ms // width) * width
|
||||
n = int((end_ms - origin) // width) + 1
|
||||
buckets = {}
|
||||
for inst in instances or []:
|
||||
a = inst.get("active_ms")
|
||||
if a is None:
|
||||
continue
|
||||
idx = int((a - origin) // width)
|
||||
b = buckets.setdefault(idx, {"tta": [], "dur": []})
|
||||
if inst.get("tta_ms") is not None and inst["tta_ms"] >= 0:
|
||||
b["tta"].append(inst["tta_ms"])
|
||||
if inst.get("duration_ms") is not None and inst["duration_ms"] >= 0:
|
||||
b["dur"].append(inst["duration_ms"])
|
||||
for i in range(n):
|
||||
b = buckets.get(i, {"tta": [], "dur": []})
|
||||
result["trend"].append({"t0": origin + i * width,
|
||||
"mtta_ms": _median(b["tta"]),
|
||||
"mttr_ms": _median(b["dur"]),
|
||||
"ack_n": len(b["tta"]), "clear_n": len(b["dur"])})
|
||||
return result
|
||||
|
||||
|
||||
# ---------------- pareto / heatmap / priority ----------------
|
||||
|
||||
|
||||
def pareto(instances, opts=None):
|
||||
counts = {}
|
||||
refs = {}
|
||||
total = 0
|
||||
for inst in instances or []:
|
||||
if inst.get("active_ms") is None:
|
||||
continue
|
||||
total += 1
|
||||
src = inst["source"]
|
||||
counts[src] = counts.get(src, 0) + 1
|
||||
if src not in refs:
|
||||
refs[src] = inst
|
||||
ordered = sorted(counts.items(), key=lambda kv: (-kv[1], refs[kv[0]]["label"]))[:10]
|
||||
rows = []
|
||||
cum = 0
|
||||
for i, (src, c) in enumerate(ordered):
|
||||
cum += c
|
||||
ref = refs[src]
|
||||
rows.append({"rank": i + 1, "source": src, "label": ref["label"],
|
||||
"area": ref["area"], "priority_name": ref["priority_name"],
|
||||
"count": c, "pct": 100.0 * c / total if total else 0.0,
|
||||
"cum_pct": 100.0 * cum / total if total else 0.0})
|
||||
return {"total_activations": total, "rows": rows}
|
||||
|
||||
|
||||
def top_sources(pareto_result, n=5):
|
||||
return list((pareto_result or {}).get("rows") or [])[:n]
|
||||
|
||||
|
||||
def heatmap(instances, opts=None):
|
||||
import time as _time
|
||||
rows = [[0] * 24 for _ in range(7)]
|
||||
total = 0
|
||||
mx = 0
|
||||
for inst in instances or []:
|
||||
a = inst.get("active_ms")
|
||||
if a is None:
|
||||
continue
|
||||
lt = _time.localtime(a / 1000.0)
|
||||
rows[lt.tm_wday][lt.tm_hour] += 1
|
||||
total += 1
|
||||
if rows[lt.tm_wday][lt.tm_hour] > mx:
|
||||
mx = rows[lt.tm_wday][lt.tm_hour]
|
||||
return {"rows": rows, "row_labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
"max_count": mx, "total": total}
|
||||
|
||||
|
||||
def priority_distribution(instances, opts=None):
|
||||
o = opts_merged(opts)
|
||||
raw_counts = {}
|
||||
buckets = {"low": 0, "medium": 0, "high": 0, "other": 0}
|
||||
for inst in instances or []:
|
||||
lvl = inst.get("priority", -1)
|
||||
name = inst.get("priority_name", "Unknown")
|
||||
key = (lvl, name)
|
||||
raw_counts[key] = raw_counts.get(key, 0) + 1
|
||||
if lvl in (0, 1):
|
||||
buckets["low"] += 1
|
||||
elif lvl == 2:
|
||||
buckets["medium"] += 1
|
||||
elif lvl in (3, 4):
|
||||
buckets["high"] += 1
|
||||
else:
|
||||
buckets["other"] += 1
|
||||
raw = [{"name": k[1], "level": k[0], "count": raw_counts[k]}
|
||||
for k in sorted(raw_counts.keys())]
|
||||
bucketed = buckets["low"] + buckets["medium"] + buckets["high"]
|
||||
pct = {"low": 0.0, "medium": 0.0, "high": 0.0}
|
||||
sum_abs_dev = None
|
||||
target = o["priority_target"]
|
||||
if bucketed:
|
||||
for k in pct:
|
||||
pct[k] = 100.0 * buckets[k] / bucketed
|
||||
sum_abs_dev = (abs(pct["low"] - target["low"]) +
|
||||
abs(pct["medium"] - target["medium"]) +
|
||||
abs(pct["high"] - target["high"]))
|
||||
return {"raw": raw, "buckets": buckets, "pct": pct,
|
||||
"target": dict(target), "sum_abs_dev": sum_abs_dev}
|
||||
|
||||
|
||||
# ---------------- health / deltas ----------------
|
||||
|
||||
|
||||
def _piecewise(x, points):
|
||||
if x is None:
|
||||
return None
|
||||
pts = sorted(points)
|
||||
if x <= pts[0][0]:
|
||||
return float(pts[0][1])
|
||||
if x >= pts[-1][0]:
|
||||
return float(pts[-1][1])
|
||||
for i in range(len(pts) - 1):
|
||||
x0, y0 = pts[i]
|
||||
x1, y1 = pts[i + 1]
|
||||
if x0 <= x <= x1:
|
||||
if x1 == x0:
|
||||
return float(y1)
|
||||
return y0 + (y1 - y0) * (x - x0) / float(x1 - x0)
|
||||
return float(pts[-1][1])
|
||||
|
||||
|
||||
def health_score(rate_per_hr, pct_flood, chatter_count, standing_count,
|
||||
sum_abs_dev, has_activity, opts=None):
|
||||
o = opts_merged(opts)
|
||||
if not has_activity:
|
||||
rate_s, flood_s, chatter_s = 100.0, 100.0, 100.0
|
||||
else:
|
||||
rate_s = _piecewise(rate_per_hr, [(6, 100), (12, 60), (60, 0)])
|
||||
flood_s = _piecewise(pct_flood, [(0, 100), (1, 80), (5, 0)])
|
||||
chatter_s = clamp(100.0 - 25.0 * chatter_count, 0, 100) if chatter_count is not None else None
|
||||
standing_s = clamp(100.0 - 20.0 * standing_count, 0, 100) if standing_count is not None else None
|
||||
priority_s = clamp(100.0 - sum_abs_dev, 0, 100) if sum_abs_dev is not None else None
|
||||
weights = o["health_weights"]
|
||||
subs = [
|
||||
{"key": "rate", "label": "Alarm Rate", "score": rate_s, "weight": weights["rate"],
|
||||
"detail": ("%.1f alarms/hr vs %.0f/hr ISA target" % (rate_per_hr, o["target_per_hr"]))
|
||||
if rate_per_hr is not None else "no data"},
|
||||
{"key": "flood", "label": "Time in Flood", "score": flood_s, "weight": weights["flood"],
|
||||
"detail": ("%.1f%% of window in flood (target <1%%)" % pct_flood)
|
||||
if pct_flood is not None else "no data"},
|
||||
{"key": "chatter", "label": "Chattering Alarms", "score": chatter_s, "weight": weights["chatter"],
|
||||
"detail": ("%d chattering alarm(s) (target 0)" % chatter_count)
|
||||
if chatter_count is not None else "no data"},
|
||||
{"key": "standing", "label": "Standing Alarms", "score": standing_s, "weight": weights["standing"],
|
||||
"detail": ("%d standing alarm(s) >24h (target 0)" % standing_count)
|
||||
if standing_count is not None else "no data"},
|
||||
{"key": "priority", "label": "Priority Distribution", "score": priority_s, "weight": weights["priority"],
|
||||
"detail": ("%.0f pts deviation from ISA 80/15/5" % sum_abs_dev)
|
||||
if sum_abs_dev is not None else "no data"},
|
||||
]
|
||||
num = 0.0
|
||||
den = 0.0
|
||||
for s in subs:
|
||||
if s["score"] is not None:
|
||||
num += s["score"] * s["weight"]
|
||||
den += s["weight"]
|
||||
score = (num / den) if den else None
|
||||
grade = None
|
||||
if score is not None:
|
||||
for cut, g in o["grade_cuts"]:
|
||||
if score >= cut:
|
||||
grade = g
|
||||
break
|
||||
if grade is None:
|
||||
grade = "F"
|
||||
return {"grade": grade, "score": score, "subs": subs}
|
||||
|
||||
|
||||
def delta(current, prior, higher_is_worse=True, flat_pct=5.0):
|
||||
d = {"value": current, "prior": prior, "delta_pct": None, "dir": "none", "good": None}
|
||||
cur0 = current or 0
|
||||
pri0 = prior or 0
|
||||
if prior is None:
|
||||
d["dir"] = "new" if cur0 > 0 else "none"
|
||||
return d
|
||||
if pri0 == 0:
|
||||
d["dir"] = "new" if cur0 > 0 else "none"
|
||||
return d
|
||||
if current is None:
|
||||
return d
|
||||
dp = 100.0 * (current - prior) / float(prior)
|
||||
d["delta_pct"] = dp
|
||||
if abs(dp) < flat_pct:
|
||||
d["dir"] = "flat"
|
||||
return d
|
||||
d["dir"] = "up" if dp > 0 else "down"
|
||||
d["good"] = (d["dir"] == "down") == bool(higher_is_worse)
|
||||
return d
|
||||
|
||||
|
||||
# ---------------- the bundle ----------------
|
||||
|
||||
|
||||
def build_bundle(rows, active_now, shelved_count, start_ms, end_ms, now_ms, opts=None):
|
||||
o = opts_merged(opts)
|
||||
try:
|
||||
start_ms = int(start_ms or 0)
|
||||
end_ms = int(end_ms or 0)
|
||||
now_ms = int(now_ms or 0)
|
||||
except Exception:
|
||||
start_ms, end_ms, now_ms = 0, 0, 0
|
||||
filters = (opts or {}).get("filters") if opts else None
|
||||
instances, mode = correlate_instances(rows)
|
||||
instances = apply_filters(instances, filters)
|
||||
current, prior, orphans = partition_instances(instances, start_ms, end_ms)
|
||||
window_h = _safe_div(end_ms - start_ms, 3600000.0) or 0.0
|
||||
|
||||
bins = rate_bins(current, start_ms, end_ms, o)
|
||||
floods = flood_episodes(bins, current, start_ms, end_ms, o)
|
||||
chat = chattering(current, start_ms, end_ms, o)
|
||||
fleet = fleeting(current, o)
|
||||
open_current = [i for i in current if i["open"]]
|
||||
stand = standing(active_now, open_current, now_ms, o)
|
||||
mm = mtta_mttr(current, start_ms, end_ms, o)
|
||||
par = pareto(current, o)
|
||||
prio = priority_distribution(current, o)
|
||||
heat = heatmap(current, o)
|
||||
|
||||
# prior mirrors (cheap subset for deltas)
|
||||
p_start = start_ms - (end_ms - start_ms)
|
||||
p_bins = rate_bins(prior, p_start, start_ms, o)
|
||||
p_floods = flood_episodes(p_bins, prior, p_start, start_ms, o)
|
||||
p_chat = chattering(prior, p_start, start_ms, o)
|
||||
p_fleet = fleeting(prior, o)
|
||||
p_mm = mtta_mttr(prior, p_start, start_ms, o)
|
||||
|
||||
n_act = len(current)
|
||||
p_act = len(prior)
|
||||
rate = _safe_div(n_act, window_h)
|
||||
p_rate = _safe_div(p_act, window_h)
|
||||
unacked_now = len([s for s in active_now or [] if s.get("unacked")])
|
||||
flat = o["delta_flat_pct"]
|
||||
|
||||
kpis = {
|
||||
"activations": delta(n_act, p_act, True, flat),
|
||||
"rate_per_hr": delta(rate, p_rate, True, flat),
|
||||
"active_now": delta(len(active_now or []), None, True, flat),
|
||||
"unacked_now": delta(unacked_now, None, True, flat),
|
||||
"shelved_now": delta(shelved_count, None, True, flat),
|
||||
"mtta_ms": delta(mm["mtta"]["median_ms"], p_mm["mtta"]["median_ms"], True, flat),
|
||||
"mttr_ms": delta(mm["mttr"]["median_ms"], p_mm["mttr"]["median_ms"], True, flat),
|
||||
"flood_pct": delta(floods["pct_time_in_flood"], p_floods["pct_time_in_flood"], True, flat),
|
||||
"flood_count": delta(len(floods["episodes"]), len(p_floods["episodes"]), True, flat),
|
||||
"chatter_count": delta(len(chat), len(p_chat), True, flat),
|
||||
"standing_count": delta(stand["count"], None, True, flat),
|
||||
"fleeting_count": delta(fleet["total"], p_fleet["total"], True, flat),
|
||||
}
|
||||
|
||||
has_activity = n_act > 0
|
||||
health = health_score(rate if has_activity else None,
|
||||
floods["pct_time_in_flood"] if has_activity else None,
|
||||
len(chat), stand["count"] if (active_now or open_current) or has_activity else None,
|
||||
prio["sum_abs_dev"], has_activity, o)
|
||||
if not has_activity and not (active_now or []):
|
||||
health = {"grade": None, "score": None, "subs": health["subs"]}
|
||||
|
||||
areas = sorted(set([i["area"] for i in current] +
|
||||
[r["area"] for r in stand["rows"]]))
|
||||
prios_seen = sorted(set([(i["priority"], i["priority_name"]) for i in current]))
|
||||
active_now_rows = []
|
||||
for s in (active_now or [])[:int(o["active_now_cap"])]:
|
||||
parsed = parse_source(s.get("source"), s.get("display_path"))
|
||||
a = s.get("active_ms")
|
||||
active_now_rows.append({"source": s.get("source") or "", "label": parsed["label"],
|
||||
"area": parsed["area"],
|
||||
"priority_name": s.get("priority_name", "Unknown"),
|
||||
"active_ms": a,
|
||||
"age_ms": (now_ms - a) if a is not None else None,
|
||||
"unacked": bool(s.get("unacked"))})
|
||||
|
||||
return {
|
||||
"meta": {"start_ms": start_ms, "end_ms": end_ms, "prior_start_ms": p_start,
|
||||
"now_ms": now_ms, "window_hours": window_h,
|
||||
"event_count": len(rows or []), "activation_count": n_act,
|
||||
"dropped_rows": 0, "truncated": False,
|
||||
"correlation_mode": mode, "standing_mode": stand["mode"],
|
||||
"areas": areas,
|
||||
"priorities_seen": [{"value": p[0], "label": p[1]} for p in prios_seen],
|
||||
"source_count": len(set(i["source"] for i in current)),
|
||||
"error": None, "opts": {
|
||||
"bin_ms": bins["bin_ms"], "target_per_hr": o["target_per_hr"],
|
||||
"flood_per_10min": o["flood_per_10min"],
|
||||
"chatter_per_hr": o["chatter_per_hr"],
|
||||
"fleeting_s": o["fleeting_s"], "standing_hours": o["standing_hours"]}},
|
||||
"kpis": kpis,
|
||||
"health": health,
|
||||
"insights": [],
|
||||
"rate": bins,
|
||||
"floods": floods,
|
||||
"priority": prio,
|
||||
"heatmap": heat,
|
||||
"mtta_mttr": mm,
|
||||
"pareto": par,
|
||||
"top_sources": top_sources(par, 5),
|
||||
"chattering": chat,
|
||||
"fleeting": fleet,
|
||||
"standing": stand,
|
||||
"active_now": active_now_rows,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"scope": "A",
|
||||
"version": 1,
|
||||
"restricted": false,
|
||||
"overridable": true,
|
||||
"files": [
|
||||
"code.py"
|
||||
],
|
||||
"attributes": {
|
||||
"hintScope": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
# PrimeControls.fmt - PURE formatting, insights, shift report (py2/3, no f-strings).
|
||||
from __future__ import division, print_function
|
||||
|
||||
import time as _time
|
||||
|
||||
from PrimeControls import calc
|
||||
|
||||
ARROW_UP = u"▲"
|
||||
ARROW_DOWN = u"▼"
|
||||
ARROW_FLAT = u"—"
|
||||
|
||||
|
||||
def dur(ms):
|
||||
if ms is None:
|
||||
return "--"
|
||||
try:
|
||||
s = int(round(ms / 1000.0))
|
||||
except Exception:
|
||||
return "--"
|
||||
if s < 60:
|
||||
return "%ds" % s
|
||||
m = s // 60
|
||||
if m < 60:
|
||||
return "%dm %ds" % (m, s % 60) if s % 60 else "%dm" % m
|
||||
h = m // 60
|
||||
if h < 24:
|
||||
return "%dh %dm" % (h, m % 60) if m % 60 else "%dh" % h
|
||||
d = h // 24
|
||||
return "%dd %dh" % (d, h % 24) if h % 24 else "%dd" % d
|
||||
|
||||
|
||||
def num(v, dec=0):
|
||||
if v is None:
|
||||
return "--"
|
||||
try:
|
||||
if dec:
|
||||
s = ("%%.%df" % dec) % v
|
||||
whole, frac = s.split(".")
|
||||
return _group(whole) + "." + frac
|
||||
return _group("%d" % round(v))
|
||||
except Exception:
|
||||
return str(v)
|
||||
|
||||
|
||||
def _group(digits):
|
||||
neg = digits.startswith("-")
|
||||
if neg:
|
||||
digits = digits[1:]
|
||||
out = ""
|
||||
while len(digits) > 3:
|
||||
out = "," + digits[-3:] + out
|
||||
digits = digits[:-3]
|
||||
return ("-" if neg else "") + digits + out
|
||||
|
||||
|
||||
def pct(v, dec=0, signed=False):
|
||||
if v is None:
|
||||
return "--"
|
||||
try:
|
||||
fmtstr = "%%+.%df%%%%" % dec if signed else "%%.%df%%%%" % dec
|
||||
return fmtstr % v
|
||||
except Exception:
|
||||
return str(v)
|
||||
|
||||
|
||||
def clock(ms):
|
||||
if ms is None:
|
||||
return "--"
|
||||
try:
|
||||
return _time.strftime("%I:%M %p", _time.localtime(ms / 1000.0)).lstrip("0")
|
||||
except Exception:
|
||||
return "--"
|
||||
|
||||
|
||||
def day_clock(ms):
|
||||
if ms is None:
|
||||
return "--"
|
||||
try:
|
||||
return _time.strftime("%a ", _time.localtime(ms / 1000.0)) + clock(ms)
|
||||
except Exception:
|
||||
return "--"
|
||||
|
||||
|
||||
def delta_chip(d):
|
||||
if not d:
|
||||
return {"arrow": ARROW_FLAT, "text": "--", "good": None}
|
||||
direction = d.get("dir")
|
||||
if direction == "up":
|
||||
arrow = ARROW_UP
|
||||
elif direction == "down":
|
||||
arrow = ARROW_DOWN
|
||||
else:
|
||||
arrow = ARROW_FLAT
|
||||
if direction == "new":
|
||||
text = "new"
|
||||
elif direction == "none":
|
||||
text = "--"
|
||||
elif d.get("delta_pct") is not None:
|
||||
text = pct(d["delta_pct"], 0, True)
|
||||
else:
|
||||
text = "--"
|
||||
return {"arrow": arrow, "text": text, "good": d.get("good")}
|
||||
|
||||
|
||||
def grade_color_key(grade):
|
||||
return grade if grade in ("A", "B", "C", "D", "F") else "NA"
|
||||
|
||||
|
||||
# ---------------- insights ----------------
|
||||
|
||||
|
||||
def insights(bundle, opts=None):
|
||||
o = calc.opts_merged(opts)
|
||||
if not bundle:
|
||||
return []
|
||||
out = []
|
||||
for rule in (_i_rate, _i_floods, _i_chatter, _i_standing,
|
||||
_i_priority, _i_pareto, _i_fleeting):
|
||||
try:
|
||||
r = rule(bundle)
|
||||
if r:
|
||||
out.append(r)
|
||||
except Exception:
|
||||
pass
|
||||
out.sort(key=lambda i: (i["severity"], -i.get("magnitude", 0)))
|
||||
out = out[:int(o["insights_max"])]
|
||||
if not out:
|
||||
k = bundle.get("kpis", {}).get("rate_per_hr", {})
|
||||
rate = k.get("value")
|
||||
if bundle.get("meta", {}).get("activation_count", 0) == 0:
|
||||
text = "No alarm events in the selected range."
|
||||
else:
|
||||
text = ("No significant changes vs the prior period; rate %.1f/hr is within "
|
||||
"the ISA target of %.0f/hr." % (rate or 0.0,
|
||||
bundle["meta"]["opts"]["target_per_hr"]))
|
||||
out = [{"severity": 3, "icon": "check_circle", "text": text,
|
||||
"tab": "overview", "magnitude": 0}]
|
||||
for i in out:
|
||||
i.pop("magnitude", None)
|
||||
return out
|
||||
|
||||
|
||||
def _i_rate(b):
|
||||
k = b["kpis"]["activations"]
|
||||
dp = k.get("delta_pct")
|
||||
cur = k.get("value") or 0
|
||||
if dp is None or cur < 10:
|
||||
return None
|
||||
if dp >= 25:
|
||||
sev = 0 if dp >= 100 else 1
|
||||
text = ("Alarm rate up %d%% vs prior period (%s -> %s activations)"
|
||||
% (round(dp), num(k.get("prior")), num(cur)))
|
||||
attr = _rate_attribution(b)
|
||||
if attr:
|
||||
text += " - largest increase from %s" % attr
|
||||
return {"severity": sev, "icon": "trending_up", "text": text + ".",
|
||||
"tab": "badactors", "magnitude": dp}
|
||||
if dp <= -25:
|
||||
return {"severity": 3, "icon": "trending_down",
|
||||
"text": "Alarm rate down %d%% vs prior period - improving." % round(-dp),
|
||||
"tab": "overview", "magnitude": -dp}
|
||||
return None
|
||||
|
||||
|
||||
def _rate_attribution(b):
|
||||
rows = b.get("pareto", {}).get("rows") or []
|
||||
if rows:
|
||||
return rows[0]["label"]
|
||||
return None
|
||||
|
||||
|
||||
def _i_floods(b):
|
||||
eps = b.get("floods", {}).get("episodes") or []
|
||||
if not eps:
|
||||
return None
|
||||
worst = sorted(eps, key=lambda e: -e["event_count"])[0]
|
||||
total_min = sum(e["duration_ms"] for e in eps) / 60000.0
|
||||
text = ("%d flood episode(s) totaling %s; worst %s-%s (%d events, mostly %s)."
|
||||
% (len(eps), dur(total_min * 60000), day_clock(worst["start_ms"]),
|
||||
clock(worst["end_ms"]), worst["event_count"],
|
||||
worst["top_source_label"] or worst["top_area"] or "n/a"))
|
||||
return {"severity": 0, "icon": "flood", "text": text, "tab": "analysis",
|
||||
"magnitude": len(eps)}
|
||||
|
||||
|
||||
def _i_chatter(b):
|
||||
rows = b.get("chattering") or []
|
||||
if not rows:
|
||||
return None
|
||||
top = rows[0]
|
||||
if len(rows) == 1:
|
||||
text = ("%s is chattering (%.0f re-triggers/hr, median gap %.0fs)."
|
||||
% (top["label"], top["per_hour"], top["median_gap_s"]))
|
||||
else:
|
||||
text = ("%d alarms are chattering (>%.0f re-triggers/hr); worst is %s at %.0f/hr."
|
||||
% (len(rows), b["meta"]["opts"]["chatter_per_hr"], top["label"],
|
||||
top["per_hour"]))
|
||||
return {"severity": 1, "icon": "repeat", "text": text, "tab": "badactors",
|
||||
"magnitude": len(rows)}
|
||||
|
||||
|
||||
def _i_standing(b):
|
||||
st = b.get("standing") or {}
|
||||
if not st.get("count"):
|
||||
return None
|
||||
rows = st.get("rows") or []
|
||||
oldest = rows[0] if rows else None
|
||||
text = "%d alarm(s) standing >%dh" % (st["count"], b["meta"]["opts"]["standing_hours"])
|
||||
if oldest and oldest.get("age_h"):
|
||||
text += "; oldest is %s at %.1f days" % (oldest["label"], oldest["age_h"] / 24.0)
|
||||
return {"severity": 1, "icon": "schedule", "text": text + ".", "tab": "badactors",
|
||||
"magnitude": st["count"]}
|
||||
|
||||
|
||||
def _i_priority(b):
|
||||
p = b.get("priority") or {}
|
||||
if p.get("sum_abs_dev") is None:
|
||||
return None
|
||||
high = p["pct"]["high"]
|
||||
target = p["target"]["high"]
|
||||
if high - target >= 10:
|
||||
return {"severity": 2, "icon": "priority_high",
|
||||
"text": ("High/Critical alarms are %d%% of events vs %d%% ISA target - "
|
||||
"priorities may be over-assigned." % (round(high), round(target))),
|
||||
"tab": "analysis", "magnitude": high - target}
|
||||
return None
|
||||
|
||||
|
||||
def _i_pareto(b):
|
||||
rows = b.get("pareto", {}).get("rows") or []
|
||||
for k, row in enumerate(rows[:3]):
|
||||
if row["cum_pct"] >= 50:
|
||||
n = k + 1
|
||||
return {"severity": 2, "icon": "insights",
|
||||
"text": ("%d source(s) account for %d%% of all activations - start "
|
||||
"with %s." % (n, round(row["cum_pct"]), rows[0]["label"])),
|
||||
"tab": "badactors", "magnitude": row["cum_pct"]}
|
||||
return None
|
||||
|
||||
|
||||
def _i_fleeting(b):
|
||||
fl = b.get("fleeting") or {}
|
||||
if (fl.get("total") or 0) < 10:
|
||||
return None
|
||||
return {"severity": 2, "icon": "bolt",
|
||||
"text": ("%d fleeting alarms (<%ds active) in range - likely deadband or "
|
||||
"nuisance issues." % (fl["total"], b["meta"]["opts"]["fleeting_s"])),
|
||||
"tab": "badactors", "magnitude": fl["total"]}
|
||||
|
||||
|
||||
# ---------------- shift report ----------------
|
||||
|
||||
|
||||
def shift_bounds(now_ms, shift_hours=8, anchor_hour=0):
|
||||
lt = _time.localtime(now_ms / 1000.0)
|
||||
midnight = _time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0,
|
||||
lt.tm_wday, lt.tm_yday, -1))
|
||||
anchor = midnight + anchor_hour * 3600
|
||||
now_s = now_ms / 1000.0
|
||||
if now_s < anchor:
|
||||
anchor -= 86400
|
||||
shift_s = shift_hours * 3600
|
||||
k = int((now_s - anchor) // shift_s)
|
||||
cur_start = anchor + k * shift_s
|
||||
cur = (int(cur_start * 1000), int((cur_start + shift_s) * 1000))
|
||||
prev = (int((cur_start - shift_s) * 1000), int(cur_start * 1000))
|
||||
label = "%s - %s" % (day_clock(prev[0]), day_clock(prev[1]))
|
||||
return {"current": cur, "previous": prev, "label": label}
|
||||
|
||||
|
||||
def _kpi_line(name, k, formatter):
|
||||
chip = delta_chip(k)
|
||||
val = formatter(k.get("value"))
|
||||
extra = "" if chip["text"] == "--" else " (%s %s)" % (chip["arrow"], chip["text"])
|
||||
return "%-14s %s%s" % (name + ":", val, extra)
|
||||
|
||||
|
||||
def shift_report(bundle, label):
|
||||
if not bundle:
|
||||
return {"text": "No data.", "sections": []}
|
||||
k = bundle["kpis"]
|
||||
h = bundle["health"]
|
||||
subs = " | ".join("%s %s" % (s["key"], "%d" % s["score"] if s["score"] is not None else "n/a")
|
||||
for s in h.get("subs", []))
|
||||
sections = []
|
||||
head = ["Shift: %s" % label,
|
||||
"Generated: %s" % day_clock(bundle["meta"]["now_ms"]),
|
||||
"Health: %s (%s) [%s]" % (h.get("grade") or "N/A",
|
||||
"%d" % h["score"] if h.get("score") is not None else "-",
|
||||
subs)]
|
||||
sections.append({"heading": "PRIME CONTROLS - ALARM SHIFT REPORT", "lines": head})
|
||||
kpis = [
|
||||
_kpi_line("Activations", k["activations"], num),
|
||||
_kpi_line("Rate/hr", k["rate_per_hr"], lambda v: num(v, 1)),
|
||||
_kpi_line("MTTA", k["mtta_ms"], dur),
|
||||
_kpi_line("MTTR", k["mttr_ms"], dur),
|
||||
_kpi_line("Flood time", k["flood_pct"], lambda v: pct(v, 1)),
|
||||
_kpi_line("Chattering", k["chatter_count"], num),
|
||||
_kpi_line("Standing", k["standing_count"], num),
|
||||
_kpi_line("Fleeting", k["fleeting_count"], num),
|
||||
]
|
||||
sections.append({"heading": "KPIs (vs prior shift)", "lines": kpis})
|
||||
actors = ["#%d %-40s %6s %s" % (r["rank"], r["label"][:40], num(r["count"]),
|
||||
pct(r["cum_pct"]))
|
||||
for r in bundle["pareto"]["rows"][:5]] or ["none"]
|
||||
sections.append({"heading": "Top actors (count, cumulative %)", "lines": actors})
|
||||
floods = ["%s - %s %s, %d events, top: %s"
|
||||
% (day_clock(e["start_ms"]), clock(e["end_ms"]), dur(e["duration_ms"]),
|
||||
e["event_count"], e["top_source_label"] or e["top_area"])
|
||||
for e in bundle["floods"]["episodes"]] or ["none"]
|
||||
sections.append({"heading": "Flood episodes", "lines": floods})
|
||||
standing_rows = bundle["standing"]["rows"][:8]
|
||||
stand = ["%-40s active %s%s" % (r["label"][:40], dur(r["age_ms"]),
|
||||
" (UNACKED)" if r["unacked"] else "")
|
||||
for r in standing_rows] or ["none"]
|
||||
sections.append({"heading": "Standing / handover attention", "lines": stand})
|
||||
sections.append({"heading": "Insights",
|
||||
"lines": [i["text"] for i in bundle.get("insights") or []] or ["none"]})
|
||||
text_parts = []
|
||||
for s in sections:
|
||||
text_parts.append(s["heading"])
|
||||
text_parts.append("-" * len(s["heading"]))
|
||||
text_parts.extend(s["lines"])
|
||||
text_parts.append("")
|
||||
return {"text": "\n".join(text_parts), "sections": sections}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"scope": "A",
|
||||
"version": 1,
|
||||
"restricted": false,
|
||||
"overridable": true,
|
||||
"files": [
|
||||
"code.py"
|
||||
],
|
||||
"attributes": {
|
||||
"hintScope": 2
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,60 @@
|
||||
import json
|
||||
|
||||
OUT_DIR = "/usr/local/bin/ignition/data/projects/.probe"
|
||||
PROBE_NAME = "P0"
|
||||
PROBE_NAME = "P1"
|
||||
|
||||
|
||||
def _p1():
|
||||
"""Exec PrimeBAT's real PrimeControls modules (calc/fmt/alarms) in this scope
|
||||
and run getDashboardBundle against the live journal - end-to-end data-layer
|
||||
smoke with zero dev footprint inside PrimeBAT."""
|
||||
import imp
|
||||
base = "/usr/local/bin/ignition/data/projects/PrimeBAT/ignition/script-python/PrimeControls/"
|
||||
pkg = imp.new_module("PrimeControls")
|
||||
pkg.__path__ = [base]
|
||||
sys_modules = __import__("sys").modules
|
||||
sys_modules["PrimeControls"] = pkg
|
||||
for name in ("calc", "fmt", "alarms"):
|
||||
m = imp.new_module("PrimeControls." + name)
|
||||
m.__dict__["system"] = system
|
||||
code = open(base + name + "/code.py").read()
|
||||
compiled = compile(code, name, "exec")
|
||||
# py2 qualified-exec statement form: required because _p1 contains
|
||||
# closures, which forbid the unqualified exec(...) call form in Jython.
|
||||
exec compiled in m.__dict__
|
||||
sys_modules["PrimeControls." + name] = m
|
||||
setattr(pkg, name, m)
|
||||
al = pkg.alarms
|
||||
end = system.date.toMillis(system.date.now())
|
||||
start = end - 4 * 3600 * 1000
|
||||
b = al.getDashboardBundle(start, end, None)
|
||||
page = al.getJournalPage(start, end, None, 0, 5)
|
||||
shift = al.getShiftReportText()
|
||||
return {
|
||||
"meta": {"event_count": b["meta"]["event_count"],
|
||||
"activation_count": b["meta"]["activation_count"],
|
||||
"areas": b["meta"]["areas"],
|
||||
"correlation_mode": b["meta"]["correlation_mode"],
|
||||
"standing_mode": b["meta"]["standing_mode"],
|
||||
"error": b["meta"]["error"],
|
||||
"dropped": b["meta"]["dropped_rows"]},
|
||||
"health": {"grade": b["health"]["grade"], "score": b["health"]["score"],
|
||||
"subs": [(s["key"], s["score"]) for s in b["health"]["subs"]]},
|
||||
"kpis": dict((k, b["kpis"][k]["value"]) for k in b["kpis"]),
|
||||
"counts": {"chattering": len(b["chattering"]),
|
||||
"standing": b["standing"]["count"],
|
||||
"fleeting": b["fleeting"]["total"],
|
||||
"floods": len(b["floods"]["episodes"]),
|
||||
"pareto_rows": len(b["pareto"]["rows"]),
|
||||
"bins": len(b["rate"]["bins"]),
|
||||
"active_now": len(b["active_now"])},
|
||||
"insights": [i["text"] for i in b["insights"]],
|
||||
"journal_page": {"total": page["total"], "rows": len(page["rows"]),
|
||||
"error": page["error"],
|
||||
"first": page["rows"][0] if page["rows"] else None},
|
||||
"shift_label": shift["label"],
|
||||
"shift_text_head": shift["text"][:400],
|
||||
}
|
||||
|
||||
|
||||
def _safe(fn):
|
||||
@@ -174,6 +227,6 @@ def _p0():
|
||||
def run():
|
||||
result = {"probe": PROBE_NAME,
|
||||
"ranAt": str(system.date.now())}
|
||||
result["payload"] = _safe(_p0)
|
||||
result["payload"] = _safe(_p1)
|
||||
_write(result)
|
||||
system.util.getLogger("probe").info("probe %s written to %s" % (PROBE_NAME, OUT_DIR))
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
"fixedDelay": true,
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user