forked from b.peck/BAT
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>
326 lines
11 KiB
Python
326 lines
11 KiB
Python
|
|
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}
|