Files
BAT/ignition/gateway/projects/PrimeBAT/ignition/script-python/PrimeControls/fmt/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

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}