Heatmap drill-down to Journal, layout fixes, healthy-plant sim mode (B grade)

Heatmap -> Journal drill-down (PrimeBAT only):
- clicking an Activations-by-hour/day cell jumps to the Journal filtered to
  that recurring day-of-week + hour (new query.filterDow/filterHour session
  props; same local-time bucketing as calc.heatmap)
- Journal shows a "Time filter: Thu 12:00-12:59" chip with an X to clear;
  filter composes with existing filters and is included in CSV export
- getJournalPage clamps out-of-range pages when a filter shrinks the set

Layout:
- filter bar moved below the tab strip so the tabs no longer shift when the
  bar hides on Overview (tab bar y verified pixel-stable across tabs)
- popup titles (AlarmDetail/EventDetail) auto-shrink their font to fit long
  source paths on one line without colliding with the state/priority chips

Alarm health -> B (score ~88):
- alarmsim gains a MODE flag: "healthy" (default) generates ~12 activations/hr
  with an 80/15/5 priority mix and no chatter/standing/fleeting/floods;
  "chaos" restores the original stress profile
- SimHarness gains a SimReset one-shot timer (disabled) that clears all sim
  tags; probe P3 reports health-grade ground truth for the 8h window
- Dashboard onStartup now re-anchors relative range presets (4h/8h/24h/7d)
  to now on session start - fixes stale Designer-baked startMs/endMs pinning
  every new session to an old window

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 09:27:47 -05:00
parent d353c5001e
commit 4b07272556
14 changed files with 377 additions and 158 deletions

View File

@@ -1,36 +1,21 @@
# alarm_simulator.py — Build-a-Thon alarm activity generator (Jython, Ignition 8.3)
# alarmsim - Build-a-Thon alarm activity generator (Jython, Ignition 8.3).
# Driven by the AlarmSimTick gateway timer (5000 ms).
#
# Drives the Boolean memory tags in [default]BuildathonSim/ (import
# simulation_tags.json first). Designed to be called every 5 seconds:
# MODE:
# "healthy" - ~12 activations/hr plant-wide, ISA-like 80/15/5 priority mix,
# no chatter / standing / fleeting / floods. Keeps the Alarm
# Health score in the B range.
# "chaos" - original stress profile (floods, chatterers, standing,
# fleeting) for demoing bad-actor analytics.
#
# RECOMMENDED — Gateway timer script (survives Designer close):
# 1. Designer > Project Library: create a script named "alarmsim", paste this file.
# 2. Project > Gateway Events > Timer: new script, Delay 5000 ms,
# Fixed Delay, Dedicated thread. Body: alarmsim.tick()
# 3. Save the project. Alarm activity starts immediately and runs forever.
#
# ALTERNATIVE — Designer Script Console (blocks the console while running):
# Paste this whole file, then add at the bottom:
# run_console(minutes=30)
#
# Patterns produced (all timestamps land in the alarm journal):
# - Baseline: random alarms across 5 areas / 3 priorities, active 30 s - 5 min,
# averaging a few activations per minute
# - Chattering: *_Chatter tags re-trigger every 30-60 s (short 5-15 s actives)
# - Standing: *_Standing tags go active on the first tick and never clear
# - Fleeting: *_Fleeting tags are active < 10 s
# - Flood: roughly every 30-60 min (or on demand via force_flood()), one area
# bursts 20-40 activations over 10 minutes
#
# Manual helpers (Script Console, gateway scope via system.util.sendRequest not
# needed — just call from a console if running console mode, or temporarily from
# the timer script):
# alarmsim.force_flood() -> start a flood burst now
# alarmsim.reset() -> clear all sim tags and internal state
# Helpers: alarmsim.force_flood() (chaos only), alarmsim.reset() clears all
# sim tags and internal state.
import random
import time
MODE = "healthy"
BASE = "[default]BuildathonSim"
BASELINE = {
@@ -62,12 +47,29 @@ FLEETING = [
"TankFarm/PumpSealFlushLow_Fleeting",
]
# Tuning (per 5 s tick)
BASELINE_PROB = 0.30 # ~3-4 baseline activations/min across the plant
FLEETING_PROB = 0.08 # ~1 fleeting alarm/min
FLOOD_START_PROB = 0.0025 # expected flood every ~30-60 min
FLOOD_DURATION = 600 # 10 minutes
FLOOD_EVENT_PROB = 0.35 # per tick during flood -> ~25 activations / 10 min
# Healthy mode: weighted 80/15/5 Low/Medium/High tag pools (priorities as
# configured in test-data/simulation_tags.json).
HEALTHY_LOW = ["Intake/InletFlowLow", "Intake/SampleTempHigh", "Intake/PowerMonitorAlarm",
"BoilerHouse/StackTempHigh", "BoilerHouse/BlowdownConductivityHigh",
"Packaging/CapperTorqueLow", "Packaging/FillerLevelDeviation", "Packaging/GuardDoorOpen",
"Utilities/CoolingTowerVibration", "Utilities/WaterSoftenerFault",
"TankFarm/VaporRecoveryFault", "TankFarm/T104_PressHigh", "TankFarm/ManifoldLeakDetect"]
HEALTHY_MED = ["Intake/Pump2_Fault", "Intake/ScreenDiffPressHigh", "BoilerHouse/FuelGasPressLow",
"BoilerHouse/EconomizerDPHigh", "Packaging/PrinterInkLow", "Packaging/ConveyorOverload",
"Utilities/GlycolTempHigh", "Utilities/N2PressLow", "TankFarm/T103_TempHigh",
"TankFarm/TransferPumpFault"]
HEALTHY_HIGH = ["Intake/Pump1_Fault", "BoilerHouse/FeedPumpA_Fault", "Packaging/LabelerFault",
"Utilities/ChillerTripped", "TankFarm/T101_LevelHigh"]
# chaos tuning (per 5 s tick)
BASELINE_PROB = 0.30
FLEETING_PROB = 0.08
FLOOD_START_PROB = 0.0025
FLOOD_DURATION = 600
FLOOD_EVENT_PROB = 0.35
# healthy tuning: ~12 activations/hr -> 12/720 per 5 s tick
HEALTHY_PROB = 12.0 / 720.0
def _path(rel):
@@ -78,9 +80,9 @@ def _state():
g = system.util.getGlobals()
if "buildathon_alarmsim" not in g:
g["buildathon_alarmsim"] = {
"clears": {}, # tag path -> epoch seconds to write False
"chatter_next": {}, # chatter path -> epoch seconds of next re-trigger
"flood": None, # {"area": name, "until": epoch} while flooding
"clears": {},
"chatter_next": {},
"flood": None,
"standing_set": False,
}
return g["buildathon_alarmsim"]
@@ -92,30 +94,52 @@ def _write(pairs):
def tick():
if MODE == "healthy":
_tick_healthy()
else:
_tick_chaos()
def _tick_healthy():
st = _state()
now = time.time()
writes = []
# 1. Standing alarms: activate once, never clear
if not st["standing_set"]:
writes += [(_path(p), True) for p in STANDING]
st["standing_set"] = True
# 2. Process scheduled clears
for path in list(st["clears"].keys()):
if now >= st["clears"][path]:
writes.append((path, False))
del st["clears"][path]
if random.random() < HEALTHY_PROB:
r = random.random()
if r < 0.80:
rel = random.choice(HEALTHY_LOW)
elif r < 0.95:
rel = random.choice(HEALTHY_MED)
else:
rel = random.choice(HEALTHY_HIGH)
path = _path(rel)
if path not in st["clears"]:
writes.append((path, True))
st["clears"][path] = now + random.uniform(45, 360)
_write(writes)
# 3. Baseline activity: random tag, active 30 s - 5 min
def _tick_chaos():
st = _state()
now = time.time()
writes = []
if not st["standing_set"]:
writes += [(_path(p), True) for p in STANDING]
st["standing_set"] = True
for path in list(st["clears"].keys()):
if now >= st["clears"][path]:
writes.append((path, False))
del st["clears"][path]
if random.random() < BASELINE_PROB:
area = random.choice(list(BASELINE.keys()))
path = _path("%s/%s" % (area, random.choice(BASELINE[area])))
if path not in st["clears"]:
writes.append((path, True))
st["clears"][path] = now + random.uniform(30, 300)
# 4. Chattering: re-trigger every 30-60 s, active 5-15 s each time
for rel in CHATTER:
path = _path(rel)
nxt = st["chatter_next"].get(path, 0)
@@ -123,15 +147,11 @@ def tick():
writes.append((path, True))
st["clears"][path] = now + random.uniform(5, 15)
st["chatter_next"][path] = now + random.uniform(30, 60)
# 5. Fleeting: active 2-8 s
if random.random() < FLEETING_PROB:
path = _path(random.choice(FLEETING))
if path not in st["clears"]:
writes.append((path, True))
st["clears"][path] = now + random.uniform(2, 8)
# 6. Flood burst: one area, 15+ activations inside 10 minutes
if st["flood"] is None:
if random.random() < FLOOD_START_PROB:
_start_flood(st, now)
@@ -141,11 +161,8 @@ def tick():
if random.random() < FLOOD_EVENT_PROB:
area = st["flood"]["area"]
path = _path("%s/%s" % (area, random.choice(BASELINE[area])))
# During a flood, alarms clear fast and may re-trigger, inflating the
# event rate the way a real upset does. Overwrite any pending clear.
writes.append((path, True))
st["clears"][path] = now + random.uniform(10, 45)
_write(writes)
@@ -156,7 +173,7 @@ def _start_flood(st, now):
def force_flood():
"""Start a flood burst immediately (handy for demo/testing)."""
"""Start a flood burst immediately (chaos mode only)."""
_start_flood(_state(), time.time())
@@ -169,11 +186,3 @@ def reset():
paths += [_path("%s/%s" % (area, n)) for n in names]
paths += [_path(p) for p in CHATTER + STANDING + FLEETING]
system.tag.writeBlocking(paths, [False] * len(paths))
def run_console(minutes=30):
"""Script Console runner: calls tick() every 5 s for `minutes`. Blocks the console."""
end = time.time() + minutes * 60
while time.time() < end:
tick()
time.sleep(5)

View File

@@ -98,6 +98,22 @@ def _p2():
return out
def _p3():
"""Health-grade ground truth for the default 8h window."""
pkg = _load_pc()
end = system.date.toMillis(system.date.now())
start = end - 8 * 3600 * 1000
b = pkg.alarms.getDashboardBundle(start, end, None)
return {"grade": b["health"]["grade"], "score": b["health"]["score"],
"subs": [(s["key"], s["score"], s["detail"]) for s in b["health"]["subs"]],
"activations": b["meta"]["activation_count"],
"kpis": {"rate": b["kpis"]["rate_per_hr"]["value"],
"flood_pct": b["kpis"]["flood_pct"]["value"],
"chatter": b["kpis"]["chatter_count"]["value"],
"standing": b["kpis"]["standing_count"]["value"],
"active_now": b["kpis"]["active_now"]["value"]}}
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
@@ -311,6 +327,6 @@ def _p0():
def run():
result = {"probe": PROBE_NAME,
"ranAt": str(system.date.now())}
result["payload"] = _safe(_p2)
result["payload"] = _safe(_p3)
_write(result)
system.util.getLogger("probe").info("probe %s written to %s" % (PROBE_NAME, OUT_DIR))

View File

@@ -0,0 +1,2 @@
def handleTimerEvent():
alarmsim.reset()

View File

@@ -0,0 +1,15 @@
{
"scope": "G",
"version": 1,
"restricted": false,
"overridable": true,
"files": [
"handleTimerEvent.py"
],
"attributes": {
"sharedThread": false,
"delay": 10000,
"fixedDelay": true,
"enabled": false
}
}