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:
2026-07-16 13:08:02 -05:00
parent c82a2fa6df
commit ecb10b9b7b
12 changed files with 1971 additions and 3 deletions

View File

@@ -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": ""}

View File

@@ -0,0 +1,12 @@
{
"scope": "A",
"version": 1,
"restricted": false,
"overridable": true,
"files": [
"code.py"
],
"attributes": {
"hintScope": 2
}
}

View File

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

View File

@@ -0,0 +1,12 @@
{
"scope": "A",
"version": 1,
"restricted": false,
"overridable": true,
"files": [
"code.py"
],
"attributes": {
"hintScope": 2
}
}

View File

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

View File

@@ -0,0 +1,12 @@
{
"scope": "A",
"version": 1,
"restricted": false,
"overridable": true,
"files": [
"code.py"
],
"attributes": {
"hintScope": 2
}
}

View File

@@ -11,7 +11,60 @@
import json import json
OUT_DIR = "/usr/local/bin/ignition/data/projects/.probe" 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): def _safe(fn):
@@ -174,6 +227,6 @@ def _p0():
def run(): def run():
result = {"probe": PROBE_NAME, result = {"probe": PROBE_NAME,
"ranAt": str(system.date.now())} "ranAt": str(system.date.now())}
result["payload"] = _safe(_p0) result["payload"] = _safe(_p1)
_write(result) _write(result)
system.util.getLogger("probe").info("probe %s written to %s" % (PROBE_NAME, OUT_DIR)) system.util.getLogger("probe").info("probe %s written to %s" % (PROBE_NAME, OUT_DIR))

21
tests/conftest.py Normal file
View File

@@ -0,0 +1,21 @@
"""Register PrimeControls.{calc,fmt} from the Ignition project's code.py files so
pytest imports them exactly as the gateway does (single source of truth)."""
import importlib.util
import os
import sys
import types
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SP = os.path.join(ROOT, "ignition", "gateway", "projects", "PrimeBAT",
"ignition", "script-python", "PrimeControls")
pkg = types.ModuleType("PrimeControls")
pkg.__path__ = [SP]
sys.modules["PrimeControls"] = pkg
for mod in ("calc", "fmt"):
spec = importlib.util.spec_from_file_location(
"PrimeControls." + mod, os.path.join(SP, mod, "code.py"))
m = importlib.util.module_from_spec(spec)
sys.modules["PrimeControls." + mod] = m
spec.loader.exec_module(m)
setattr(pkg, mod, m)

306
tests/test_calc.py Normal file
View File

@@ -0,0 +1,306 @@
import os
import time
import pytest
from PrimeControls import calc
H = 3600000
M = 60000
T0 = 1784200000000 # fixed epoch anchor
def row(state, ts, src="prov:default:/tag:Sim/AreaA/T1:/alm:Alm1", eid=None,
prio=3, dp="", system=False, ack_user=None):
lvl, name = calc.normalize_priority(prio)
return {"event_id": eid, "source": src, "display_path": dp,
"priority": lvl, "priority_name": name, "state": state, "ts": ts,
"is_system": system, "ack_user": ack_user}
def lifecycle(eid, src, t_active, dur_ms=None, ack_ms=None, prio=3):
rows = [row("active", t_active, src, eid, prio)]
if ack_ms is not None:
rows.append(row("ack", t_active + ack_ms, src, eid, prio, ack_user="op1"))
if dur_ms is not None:
rows.append(row("clear", t_active + dur_ms, src, eid, prio))
return rows
# ---------------- parse_source / normalize_priority ----------------
def test_parse_source_from_source_path():
p = calc.parse_source("prov:default:/tag:Plant/TankFarm/T101_Hi:/alm:T101_Hi", "")
assert p["area"] == "TankFarm"
assert p["label"] == "TankFarm/T101_Hi"
def test_parse_source_display_path_wins_label_but_area_from_tag():
p = calc.parse_source("prov:default:/tag:Plant/Boiler/DrumLo:/alm:DrumLo", "North/Drum Level")
assert p["label"] == "North/Drum Level"
assert p["area"] == "Boiler"
def test_parse_source_system_row():
p = calc.parse_source("evt:System Startup", "")
assert p["label"] == "evt:System Startup"
def test_normalize_priority():
assert calc.normalize_priority(3) == (3, "High")
assert calc.normalize_priority("Critical") == (4, "Critical")
assert calc.normalize_priority("AlarmPriority.Low") == (1, "Low")
lvl, _ = calc.normalize_priority("weird")
assert lvl == -1
# ---------------- correlation ----------------
def test_correlate_full_lifecycle_uuid():
rows = lifecycle("e1", "s1", T0, dur_ms=5 * M, ack_ms=2 * M)
insts, mode = calc.correlate_instances(rows)
assert mode == "uuid"
assert len(insts) == 1
i = insts[0]
assert i["active_ms"] == T0 and i["duration_ms"] == 5 * M and i["tta_ms"] == 2 * M
assert not i["open"] and i["ack_user"] == "op1"
def test_correlate_ack_after_clear():
rows = [row("active", T0, eid="e1"), row("clear", T0 + M, eid="e1"),
row("ack", T0 + 2 * M, eid="e1")]
i = calc.correlate_instances(rows)[0][0]
assert i["duration_ms"] == M and i["tta_ms"] == 2 * M
def test_correlate_straddler_and_open():
rows = [row("clear", T0, eid="old"), row("active", T0 + M, eid="new")]
insts, _ = calc.correlate_instances(rows)
by_id = dict((i["event_id"], i) for i in insts)
assert by_id["old"]["active_ms"] is None and by_id["old"]["duration_ms"] is None
assert by_id["new"]["open"] and by_id["new"]["clear_ms"] is None
def test_correlate_negative_skew_none():
rows = [row("active", T0, eid="e1"), row("ack", T0 - M, eid="e1")]
i = calc.correlate_instances(rows)[0][0]
assert i["tta_ms"] is None
def test_correlate_system_dropped_and_empty():
assert calc.correlate_instances([row("active", T0, system=True)])[0] == []
assert calc.correlate_instances([])[0] == []
def test_correlate_source_sequence_fallback():
rows = [row("active", T0, src="sA"), row("clear", T0 + M, src="sA"),
row("active", T0 + 2 * M, src="sA"), row("active", T0 + 3 * M, src="sB")]
insts, mode = calc.correlate_instances(rows)
assert mode == "source-sequence"
assert len(insts) == 3
closed = [i for i in insts if i["source"] == "sA" and not i["open"]]
assert len(closed) == 1 and closed[0]["duration_ms"] == M
def test_partition_boundary():
insts = [dict(active_ms=T0, source="a"), dict(active_ms=T0 - 1, source="b"),
dict(active_ms=None, source="c")]
cur, prior, orphans = calc.partition_instances(insts, T0, T0 + H)
assert len(cur) == 1 and len(prior) == 1 and len(orphans) == 1
# ---------------- bins / floods ----------------
def make_insts(times, src="s1", prio=3):
out = []
for i, t in enumerate(times):
rows = lifecycle("e%d_%s" % (i, src), src, t, dur_ms=30000, prio=prio)
out.extend(rows)
insts, _ = calc.correlate_instances(out)
return insts
def test_rate_bins_placement_and_edges():
start = (T0 // 600000) * 600000
insts = make_insts([start, start + 600000 - 1, start + 600000])
r = calc.rate_bins(insts, start, start + 2 * 600000)
assert r["bins"][0]["count"] == 2 and r["bins"][1]["count"] == 1
def test_rate_bins_empty_and_reversed():
assert calc.rate_bins([], T0, T0)["bins"] == []
assert calc.rate_bins([], T0 + 1, T0)["bins"] == []
def test_flood_hysteresis_merge_and_split():
start = (T0 // 600000) * 600000
times = []
times += [start + i for i in range(12)] # bin0: 12 (flood)
times += [start + 600000 + i for i in range(7)] # bin1: 7 (>=5 continues)
times += [start + 2 * 600000 + i for i in range(12)] # bin2: 12
insts = make_insts(times)
r = calc.rate_bins(insts, start, start + 3 * 600000)
f = calc.flood_episodes(r, insts, start, start + 3 * 600000)
assert len(f["episodes"]) == 1
times2 = [start + i for i in range(12)] + [start + 2 * 600000 + i for i in range(12)]
insts2 = make_insts(times2)
r2 = calc.rate_bins(insts2, start, start + 3 * 600000)
f2 = calc.flood_episodes(r2, insts2, start, start + 3 * 600000)
assert len(f2["episodes"]) == 2
assert f2["episodes"][0]["event_count"] == 12
assert f2["pct_time_in_flood"] > 0
# ---------------- detections ----------------
def test_chattering_detects_sim_shape_not_slow():
start = T0
end = T0 + 2 * H
chatter_times = [start + i * 45000 for i in range(160)] # every 45s
slow_times = [start + i * 5 * M for i in range(24)] # every 5min -> 12/hr slow
insts = make_insts(chatter_times, src="chat") + make_insts(slow_times, src="slow")
out = calc.chattering(insts, start, end)
srcs = [r["source"] for r in out]
assert "chat" in srcs and "slow" not in srcs
def test_fleeting_and_standing():
rows = []
for i in range(4):
rows += lifecycle("f%d" % i, "fleety", T0 + i * M, dur_ms=3000)
rows += lifecycle("n1", "normal", T0, dur_ms=5 * M)
insts, _ = calc.correlate_instances(rows)
fl = calc.fleeting(insts)
assert fl["total"] == 4 and fl["sources"][0]["source"] == "fleety"
now = T0 + 30 * H
status = [{"source": "prov:default:/tag:A/B/C:/alm:C", "display_path": "",
"priority": 3, "priority_name": "High",
"active_ms": T0, "unacked": True}]
st = calc.standing(status, [], now)
assert st["mode"] == "status" and st["count"] == 1
st2 = calc.standing([], [i for i in insts if i["open"]], now)
assert st2["mode"] == "journal"
def test_mtta_mttr_exclusions_and_median():
rows = (lifecycle("a", "s", T0, dur_ms=2 * M, ack_ms=M) +
lifecycle("b", "s", T0 + M, dur_ms=4 * M, ack_ms=3 * M) +
lifecycle("c", "s", T0 + 2 * M, dur_ms=100 * M) + # no ack
lifecycle("d", "s", T0 + 3 * M)) # open, no clear
insts, _ = calc.correlate_instances(rows)
mm = calc.mtta_mttr(insts, T0, T0 + H)
assert mm["mtta"]["count"] == 2 and mm["mtta"]["median_ms"] == 2 * M
assert mm["mttr"]["count"] == 3 and mm["mttr"]["median_ms"] == 4 * M
def test_pareto_cum_pct_vs_total():
insts = make_insts([T0 + i * M for i in range(6)], src="hot")
insts += make_insts([T0 + i * M for i in range(4)], src="warm")
p = calc.pareto(insts)
assert p["total_activations"] == 10
assert p["rows"][0]["source"] == "hot" and abs(p["rows"][0]["cum_pct"] - 60.0) < 0.01
assert abs(p["rows"][1]["cum_pct"] - 100.0) < 0.01
assert calc.top_sources(p, 5) == p["rows"][:5]
def test_heatmap_placement():
os.environ["TZ"] = "UTC"
time.tzset()
# 2026-07-16 is a Thursday; 10:00 UTC
ts = 1784196000000 # Thu Jul 16 2026 10:00:00 UTC
insts = make_insts([ts, ts, ts + H])
hm = calc.heatmap(insts)
assert hm["rows"][3][10] == 2 and hm["rows"][3][11] == 1
assert hm["max_count"] == 2 and hm["total"] == 3
def test_priority_distribution_extremes():
insts = make_insts([T0 + i * M for i in range(8)], src="lo", prio=1)
insts += make_insts([T0 + i * M for i in range(2)], src="hi", prio=4)
d = calc.priority_distribution(insts)
assert d["buckets"]["low"] == 8 and d["buckets"]["high"] == 2
assert d["sum_abs_dev"] is not None
assert calc.priority_distribution([])["sum_abs_dev"] is None
# ---------------- health / delta ----------------
def test_health_anchors_and_grades():
h = calc.health_score(6.0, 0.0, 0, 0, 0.0, True)
assert h["score"] == 100 and h["grade"] == "A"
assert calc.health_score(60.0, None, 0, 0, None, True)["subs"][0]["score"] == 0
h2 = calc.health_score(None, 1.0, None, None, None, True)
flood_sub = [s for s in h2["subs"] if s["key"] == "flood"][0]
assert flood_sub["score"] == 80
h3 = calc.health_score(None, None, None, None, None, True)
assert h3["score"] is None and h3["grade"] is None
def test_health_quiet_plant():
h = calc.health_score(None, None, 0, 0, None, False)
assert h["score"] is not None and h["grade"] in ("A", "B")
def test_delta_semantics():
assert calc.delta(10, 0)["dir"] == "new"
assert calc.delta(0, None)["dir"] == "none"
assert calc.delta(102, 100)["dir"] == "flat"
d = calc.delta(200, 100)
assert d["dir"] == "up" and d["good"] is False
d2 = calc.delta(50, 100, higher_is_worse=True)
assert d2["dir"] == "down" and d2["good"] is True
# ---------------- bundle ----------------
def synth_rows():
rows = []
base = T0
for i in range(20): # baseline across areas/priorities
src = "prov:default:/tag:Plant/Area%d/Tag%d:/alm:A%d" % (i % 4, i, i)
rows += lifecycle("b%d" % i, src, base + i * 8 * M, dur_ms=3 * M,
ack_ms=M, prio=(i % 3) + 1)
chat_src = "prov:default:/tag:Plant/Area1/Chatty:/alm:Chatty"
for i in range(100): # every 50s for ~83 min -> ~25/hr over the 4h window
rows += lifecycle("c%d" % i, chat_src, base + i * 50000, dur_ms=8000, prio=3)
flood_t = base + 3 * H
for i in range(18):
rows += lifecycle("fl%d" % i, "prov:default:/tag:Plant/Area2/F%d:/alm:F%d" % (i, i),
flood_t + i * 20000, dur_ms=M, prio=2)
for i in range(5):
rows += lifecycle("fle%d" % i, "prov:default:/tag:Plant/Area3/Blip:/alm:Blip",
base + i * 12 * M, dur_ms=4000, prio=1)
return rows
def test_build_bundle_golden_and_empty():
start, end = T0, T0 + 4 * H
status = [{"source": "prov:default:/tag:Plant/Area0/Stand:/alm:Stand",
"display_path": "", "priority": 3, "priority_name": "High",
"active_ms": T0 - 30 * H, "unacked": True}]
b = calc.build_bundle(synth_rows(), status, 0, start, end, end + M)
for key in ("meta", "kpis", "health", "rate", "floods", "priority", "heatmap",
"mtta_mttr", "pareto", "top_sources", "chattering", "fleeting",
"standing", "active_now", "insights"):
assert key in b, key
assert b["meta"]["activation_count"] > 0
assert len(b["floods"]["episodes"]) >= 1
assert any(r["source"].endswith("Chatty") for r in b["chattering"])
assert b["standing"]["count"] == 1
assert b["fleeting"]["total"] >= 5
assert b["health"]["grade"] is not None
assert b["kpis"]["activations"]["value"] > 0
assert len(b["meta"]["areas"]) >= 4
empty = calc.build_bundle([], [], None, start, end, end)
assert empty["meta"]["activation_count"] == 0
assert empty["health"]["grade"] is None
assert empty["pareto"]["rows"] == [] and empty["rate"]["bins"] != []
def test_build_bundle_filters():
start, end = T0, T0 + 4 * H
b = calc.build_bundle(synth_rows(), [], None, start, end, end,
{"filters": {"areas": ["Area2"]}})
assert all(a == "Area2" for a in b["meta"]["areas"])
# Area2 = 5 baseline rows (i % 4 == 2) + 18 flood rows
assert b["meta"]["activation_count"] == 23

94
tests/test_fmt.py Normal file
View File

@@ -0,0 +1,94 @@
import os
import time
from PrimeControls import calc, fmt
H = 3600000
M = 60000
T0 = 1784200000000
def test_dur_num_pct():
assert fmt.dur(None) == "--"
assert fmt.dur(42000) == "42s"
assert fmt.dur(90 * M) == "1h 30m"
assert fmt.dur(26 * H) == "1d 2h"
assert fmt.num(None) == "--"
assert fmt.num(1234567) == "1,234,567"
assert fmt.num(12.345, 1) == "12.3"
assert fmt.pct(34.2) == "34%"
assert fmt.pct(34.2, 0, True) == "+34%"
def test_delta_chip():
assert fmt.delta_chip(calc.delta(200, 100))["arrow"] == fmt.ARROW_UP
assert fmt.delta_chip(calc.delta(50, 100))["arrow"] == fmt.ARROW_DOWN
assert fmt.delta_chip(calc.delta(10, 0))["text"] == "new"
assert fmt.delta_chip(None)["text"] == "--"
def bundle_with(rows=None, status=None):
return calc.build_bundle(rows or [], status or [], 0, T0, T0 + 4 * H, T0 + 4 * H)
def lifecycle(eid, src, t, dur_ms=None, ack_ms=None, prio=3):
lvl, name = calc.normalize_priority(prio)
rows = [{"event_id": eid, "source": src, "display_path": "", "priority": lvl,
"priority_name": name, "state": "active", "ts": t, "is_system": False,
"ack_user": None}]
if dur_ms:
rows.append(dict(rows[0], state="clear", ts=t + dur_ms))
return rows
def test_insights_fallback_and_cap():
b = bundle_with()
ins = fmt.insights(b)
assert len(ins) == 1 and ins[0]["severity"] == 3
assert "No alarm events" in ins[0]["text"]
def test_insights_chatter_and_flood_fire():
rows = []
for i in range(100): # ~25/hr over the 4h window, median gap 50s
rows += lifecycle("c%d" % i, "prov:default:/tag:P/A/Chat:/alm:Chat",
T0 + i * 50000, dur_ms=8000)
for i in range(15):
rows += lifecycle("f%d" % i, "prov:default:/tag:P/B/F%d:/alm:F%d" % (i, i),
T0 + 2 * H + i * 30000, dur_ms=M)
b = bundle_with(rows)
b["insights"] = fmt.insights(b)
texts = " | ".join(i["text"] for i in b["insights"])
assert "chatter" in texts.lower()
assert "flood" in texts.lower()
assert len(b["insights"]) <= 5
def test_shift_bounds_grid():
os.environ["TZ"] = "UTC"
time.tzset()
# Thu Jul 16 2026 14:20 UTC
now = 1784211600000 + 20 * M
sb = fmt.shift_bounds(now, 8, 0)
cs, ce = sb["current"]
ps, pe = sb["previous"]
assert ce - cs == 8 * H and pe == cs
lt = time.localtime(cs / 1000.0)
assert lt.tm_hour in (0, 8, 16)
sb2 = fmt.shift_bounds(now, 12, 6)
lt2 = time.localtime(sb2["current"][0] / 1000.0)
assert lt2.tm_hour in (6, 18)
def test_shift_report_sections():
rows = []
for i in range(10):
rows += lifecycle("b%d" % i, "prov:default:/tag:P/A/T%d:/alm:T%d" % (i, i),
T0 + i * 10 * M, dur_ms=M)
b = bundle_with(rows)
b["insights"] = fmt.insights(b)
rep = fmt.shift_report(b, "Thu 6:00 AM - Thu 2:00 PM")
heads = [s["heading"] for s in rep["sections"]]
assert any("SHIFT REPORT" in h for h in heads)
assert any("KPIs" in h for h in heads)
assert "Activations" in rep["text"]