Data & simulation: - simulation_tags.json: re-prioritize 10 tags to Medium so all four priorities appear in live data (backfilled journal history to match) - SimHarness probe P2: ack-user/includeData ground truth + filtered-bundle verification against the live journal Data layer (PrimeControls.calc/alarms): - filters now drive every metric: queryStatus rows (Active Now/Unacked/ Standing) filtered via calc.apply_status_filters; event_count counts only filtered instances (per-instance n_events); dropdown options enumerate unfiltered data so choices never collapse - build_bundle refactored into _assemble(); when filters are active an unfiltered mirror is attached as bundle.overview - the Overview tab stays a plant-wide summary while all other consumers bind filtered sections - _fetch_journal now honors includeData (required for ackUserName; without it ack attribution is always None); enabled for journal rows + source detail so Top Ack Users and the Ack By column populate FilterBar/Dashboard: - root-cause fix: bidirectional bindings never wrote dropdown selections back to session props, so filters silently did nothing; Apply now pulls values directly from the sibling controls, Clear resets both - multi-select dropdowns cap at 280px and wrap chips; filter bar row is auto-height so wrapped chips push tabs down instead of overlapping - filter bar hidden on the Overview tab (not applicable there) UI scaling: - Header: Prime logo added; KpiCard compacted to fit the 71px KPI row (basis-95% overflow fix, chip hugs text, embeds clipped) - no scrollbars - Overview timeline: thinned x labels (minDistance 140), labels anchored below the axis, smaller legend markers - Bad Actors focus chip shows the full source string with the clear X - popup repeaters (Daily Activity, ScoreBars) no longer overflow (useDefaultViewWidth/Height off, taller strip); 5px table cell padding Tooling/tests: - lint_project.py: accept propConfig params.* as param declarations (Designer saves strip null params entries) - tests: 31 -> 34 (status-filter semantics, overview mirror, filtered event counts) - .gitignore: runtime churn (valueStore.idb, local-system-properties, migration logs, thumbnails, .schemas) New project: AlarmAnalysis - submission copy of PrimeBAT with client-visible Prime Controls branding removed (logo, subtitle, project title, shift report heading); PrimeControls organizational namespaces retained per spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
808 lines
31 KiB
Python
808 lines
31 KiB
Python
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, "n_events": 0}
|
|
|
|
|
|
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])
|
|
inst["n_events"] = len(grp)
|
|
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"]
|
|
inst["n_events"] = 1
|
|
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
|
|
inst["n_events"] = inst.get("n_events", 0) + 1
|
|
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
|
|
|
|
|
|
def apply_status_filters(status_rows, filters):
|
|
"""Same filter semantics as apply_filters, but for queryStatus rows
|
|
(active-now list): keys source/display_path/priority/unacked."""
|
|
if not filters:
|
|
return status_rows
|
|
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 status_rows
|
|
out = []
|
|
for s in status_rows or []:
|
|
if prios and s.get("priority") not in prios:
|
|
continue
|
|
parsed = parse_source(s.get("source"), s.get("display_path"))
|
|
if areas and parsed["area"] not in areas:
|
|
continue
|
|
if states:
|
|
tags = set(["active"])
|
|
tags.add("unacked" if s.get("unacked") else "acked")
|
|
if not (states & tags):
|
|
continue
|
|
if search:
|
|
hay = ((s.get("source") or "") + " " + parsed["label"]).lower()
|
|
if search not in hay:
|
|
continue
|
|
out.append(s)
|
|
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)
|
|
all_instances = instances
|
|
all_active_now = active_now
|
|
instances = apply_filters(instances, filters)
|
|
active_now = apply_status_filters(active_now, filters)
|
|
|
|
# Filter dropdown options enumerate the UNfiltered data, so applying a
|
|
# filter never collapses the available choices.
|
|
areas = sorted(set([i["area"] for i in all_instances] +
|
|
[parse_source(s.get("source"), s.get("display_path"))["area"]
|
|
for s in all_active_now or []]))
|
|
prios_seen = sorted(set([(i["priority"], i["priority_name"])
|
|
for i in all_instances]))
|
|
|
|
bundle = _assemble(instances, active_now, shelved_count, start_ms, end_ms,
|
|
now_ms, mode, areas, prios_seen, o)
|
|
if filters_active(filters):
|
|
# Overview tab stays a plant-wide (unfiltered) summary; every other
|
|
# consumer binds the filtered top-level sections.
|
|
bundle["overview"] = _assemble(all_instances, all_active_now,
|
|
shelved_count, start_ms, end_ms,
|
|
now_ms, mode, areas, prios_seen, o)
|
|
else:
|
|
bundle["overview"] = None
|
|
return bundle
|
|
|
|
|
|
def filters_active(filters):
|
|
if not filters:
|
|
return False
|
|
try:
|
|
return bool(filters.get("priorities") or filters.get("areas") or
|
|
filters.get("states") or
|
|
(filters.get("search") or "").strip())
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _assemble(instances, active_now, shelved_count, start_ms, end_ms, now_ms,
|
|
mode, areas, prios_seen, o):
|
|
window_h = _safe_div(end_ms - start_ms, 3600000.0) or 0.0
|
|
filtered_events = 0
|
|
for i in instances:
|
|
filtered_events += i.get("n_events", 0)
|
|
current, prior, orphans = partition_instances(instances, start_ms, end_ms)
|
|
|
|
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"]}
|
|
|
|
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": filtered_events, "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,
|
|
}
|