Files
BAT/ignition/gateway/projects/AlarmAnalysis/ignition/script-python/PrimeControls/calc/code.py
a.williamson d353c5001e Data seeding, filter overhaul, UI scaling fixes, sanitized AlarmAnalysis copy
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>
2026-07-16 16:27:48 -05:00

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,
}