Operator screen
Anomalies
Anomaly detection with a heatmap of scores, forecast bands and markers on the trend, and a root-cause tree.
index.htm as it is written with the smart-industrial package; industrial-demos.css is the screens' shared layout
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Anomaly Detection - Smart.Industrial</title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.default.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-elements.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-dark.css" />
<link rel="stylesheet" type="text/css" href="industrial-demos.css" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body class="viewport" theme="industrial-dark">
<div class="hmi-screen an-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">ANOMALY DETECTION</span>
<span class="hmi-area">Unit 200 · Reactor train · model <code>u200-iforest-v3</code></span>
</div>
<div class="an-summary" id="summary" role="status"></div>
<div class="hmi-header-actions">
<label class="an-window"><span>Window</span>
<select id="window">
<option value="21600000">6 h</option>
<option value="43200000">12 h</option>
<option value="86400000" selected>24 h</option>
</select>
</label>
<button class="hmi-button" id="feed" type="button">Pause feed</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="hmi-body an-body">
<div class="hmi-panel an-heatmap-panel">
<div class="hmi-panel-header">
<span>Where and when</span>
<span class="an-panel-hint">Arrow keys move, Enter opens the tag's trend</span>
</div>
<div class="hmi-panel-content">
<smart-anomaly-heatmap id="heatmap" label="Unit 200" bucket="900000"></smart-anomaly-heatmap>
</div>
</div>
<div class="an-row">
<div class="hmi-panel an-trend-panel">
<div class="hmi-panel-header">
<span id="trendTitle">Trend</span>
<span class="an-panel-hint">N and P step through the markers; Enter asks for a verdict</span>
</div>
<div class="hmi-panel-content">
<smart-trend id="trend" show-pens="false"></smart-trend>
</div>
</div>
<div class="hmi-panel an-cause-panel">
<div class="hmi-panel-header">
<span id="causeTitle">Root cause</span>
<span class="an-panel-hint" id="causeHint">Select an anomaly</span>
</div>
<div class="hmi-panel-content">
<smart-root-cause-tree id="causes" min-contribution="0.05"></smart-root-cause-tree>
</div>
</div>
</div>
<div class="hmi-panel an-verdict-panel" id="verdictPanel" hidden>
<div class="hmi-panel-header">
<span>The model asked; the operator answers</span>
</div>
<div class="hmi-panel-content an-verdict">
<p class="an-verdict-text" id="verdictText"></p>
<div class="an-verdict-actions">
<button class="hmi-button hmi-button-primary" id="confirm" type="button">Confirm: real anomaly</button>
<button class="hmi-button" id="dismiss" type="button">Dismiss: false positive</button>
<button class="hmi-button" id="later" type="button">Not now</button>
</div>
<p class="an-verdict-note">
A verdict is a change to the record: it goes into the audit trail with the marker
it answers, and to the model service as a label. Neither is done here.
</p>
</div>
</div>
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Verdicts this shift</span>
</div>
<div class="hmi-panel-content">
<smart-audit-trail id="verdicts" label="Anomaly verdicts" max-rows="20"></smart-audit-trail>
</div>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/smart.industrial.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.audit.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.anomalyheatmap.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.trend.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.rootcausetree.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.audittrail.js"></script>
<script src="detector.js"></script>
<script type="module" src="index.js"></script>
<script>
/* Shown once every element has rendered, so nothing moves on the way in (styles/demos.css). */
addEventListener('load', function () {
var all = Array.prototype.slice.call(document.querySelectorAll('*')),
ready = function () { document.body.classList.add('ready'); };
Promise.all(all.map(function (e) { return e.localName.indexOf('smart-') === 0 ? customElements.whenDefined(e.localName) : null; })).then(function () {
return Promise.all(all.filter(function (e) { return typeof e.whenRendered === 'function' && !e.isRendered; }).map(function (e) { return e.whenRendered(); }));
}).then(function () { requestAnimationFrame(function () { requestAnimationFrame(ready); }); }, ready);
});
</script>
<script>
/* The site's light/dark switch is the reader's choice: a screen opened from the light site
in a new tab opens light too. Framed, the site's page sets the theme itself. */
addEventListener('load', function () {
var light = false,
toggle = document.querySelector('.hmi-theme-toggle');
try { light = window.top === window && localStorage.getItem('smart-industrial-dark') === 'false'; } catch (e) { }
/* after the screen's own onload, which is where its button starts listening */
if (light && toggle) { setTimeout(function () { if (document.body.getAttribute('theme') === 'industrial-dark') { toggle.click(); } }, 0); }
});
</script>
</body>
</html>
index.js
/// <reference path="../../../source/typescript/smart.elements.d.ts" />
/* Anomaly detection screen.
*
* The three model-output elements together, the way a shift uses them: the heatmap says
* where and when across the unit, a cell opens the trend of that tag with the model's
* forecast band and markers on it, a marker opens the model's explanation as a root-cause
* tree, and the operator's verdict on the marker - real or false - goes into the audit
* trail. The model is a stand-in (detector.js); everything it feeds the elements is the
* shape a real service produces. */
window.onload = function () {
const detector = window.detector,
Audit = window.Smart.Industrial.Audit,
heatmap = document.getElementById('heatmap'),
trend = document.getElementById('trend'),
trendTitle = document.getElementById('trendTitle'),
causes = document.getElementById('causes'),
causeTitle = document.getElementById('causeTitle'),
causeHint = document.getElementById('causeHint'),
verdictPanel = document.getElementById('verdictPanel'),
verdictText = document.getElementById('verdictText'),
verdicts = document.getElementById('verdicts'),
summary = document.getElementById('summary'),
windowSelect = document.getElementById('window'),
feedButton = document.getElementById('feed'),
themeToggle = document.getElementById('themeToggle'),
THRESHOLDS = { advisory: 0.5, warning: 0.7, critical: 0.9 },
trail = new Audit.Trail({ system: 'Unit 200 HMI' });
/* Boolean attributes are presence-based, so the toolbar is switched off here. */
verdicts.showToolbar = false;
let result = null,
currentTag = null,
pendingMarker = null,
feedTimer = null;
/* ── The model's output into the elements ─────────────────────────── */
function load(windowMs, end) {
result = detector.run(windowMs, end);
heatmap.series = result.tags.map(function (tag) {
return { id: tag.id, label: tag.id + ' ' + tag.label };
});
heatmap.timeSpan = windowMs;
heatmap.thresholds = THRESHOLDS;
heatmap.data = result.scores;
const hot = heatmap.hotspots('warning');
summary.textContent = hot.length ?
hot.length + ' intervals at warning or above across ' + new Set(hot.map(function (cell) {
return cell.series;
})).size + ' tags' :
'Nothing at warning or above in the window';
/* The worst thing first: the screen opens on the top hotspot's marker, the way a
cell click lands on it. */
if (currentTag) {
showTag(currentTag);
}
else if (hot.length) {
landOn(hot[0].series, hot[0].timestamp);
}
else {
showTag(result.tags[0].id);
}
}
function showTag(tagId) {
const tag = detector.tags.find(function (candidate) {
return candidate.id === tagId;
});
currentTag = tagId;
trendTitle.textContent = tag.id + ' · ' + tag.label;
trend.timeSpan = Number(windowSelect.value);
trend.live = false;
trend.end = result.end;
trend.pens = [{ field: tag.id, label: tag.id + ' ' + tag.label, unit: tag.unit, min: tag.min, max: tag.max }];
trend.bands = [{ field: tag.id, label: 'Forecast', confidence: 0.95, records: result.bands[tag.id] }];
trend.markers = detector.markersFor(result, tag.id, THRESHOLDS);
trend.data = result.records;
trend.cursor = null;
causes.root = null;
causeTitle.textContent = 'Root cause';
causeHint.textContent = trend.markers.length ? trend.markers.length + ' marker' + (trend.markers.length === 1 ? '' : 's') + ' on this tag - press N on the plot, then Enter' : 'No markers on this tag';
verdictPanel.hidden = true;
pendingMarker = null;
}
function explain(marker) {
causes.root = detector.explain(result, currentTag, marker);
causeTitle.textContent = 'Root cause · ' + marker.label;
causeHint.textContent = 'The model\'s explanation; the bars are its shares';
pendingMarker = marker;
verdictText.textContent = marker.severity.charAt(0).toUpperCase() + marker.severity.slice(1) + ' on ' + currentTag + ' at ' +
new Date(marker.timestamp).toLocaleTimeString('en-GB', { hour12: false }) + ', score ' + marker.score.toFixed(2) + ': ' + marker.description + ' Is this a real anomaly?';
verdictPanel.hidden = false;
}
/* The tag's trend with the cursor on the cell's interval, and on its marker if the
interval has one: a cell is a quarter hour, a marker is wherever the model put it. */
function landOn(tagId, timestamp) {
const from = timestamp,
to = timestamp + Number(heatmap.bucket);
heatmap.selected = { series: tagId, timestamp: timestamp };
showTag(tagId);
const there = trend.markers.filter(function (marker) {
return (marker.timestampEnd || marker.timestamp) >= from && marker.timestamp < to;
});
if (there.length) {
trend.cursor = there[0].timestamp;
explain(there[0]);
}
else {
trend.cursor = from + (to - from) / 2;
}
}
/* ── Interaction: heatmap → trend → tree → verdict ─────────────────── */
heatmap.addEventListener('cellClick', function (event) {
landOn(event.detail.series, event.detail.timestamp);
trend.querySelector('.smart-trend-plot').focus();
});
trend.addEventListener('markerClick', function (event) {
explain(event.detail.marker);
});
causes.addEventListener('nodeSelect', function (event) {
/* A cause with a tag is a trend to look at; the screen shows it without losing the
explanation. */
const node = event.detail.node;
if (node.tag && node.tag !== currentTag && event.detail.depth > 0 && detector.tags.some(function (tag) {
return tag.id === node.tag;
})) {
const keep = causes.root,
keepTitle = causeTitle.textContent,
keepMarker = pendingMarker;
showTag(node.tag);
causes.root = keep;
causeTitle.textContent = keepTitle;
causeHint.textContent = 'Showing ' + node.tag + ' for "' + node.label + '"';
pendingMarker = keepMarker;
verdictPanel.hidden = !keepMarker;
}
});
function verdict(answer) {
if (!pendingMarker) {
return;
}
const entry = trail.append({
user: 'jmoreau', userName: 'J. Moreau', action: 'verdict', record: currentTag,
field: 'anomaly ' + new Date(pendingMarker.timestamp).toLocaleTimeString('en-GB', { hour12: false }),
oldValue: 'flagged, score ' + pendingMarker.score.toFixed(2), newValue: answer,
reason: answer === 'confirmed' ? 'Operator agrees with the model' : 'Operator disagrees with the model'
});
verdicts.addEntry(entry);
verdictPanel.hidden = true;
causeHint.textContent = 'Verdict recorded: ' + answer + '. The label goes to the model service.';
pendingMarker = null;
}
document.getElementById('confirm').addEventListener('click', function () {
verdict('confirmed');
});
document.getElementById('dismiss').addEventListener('click', function () {
verdict('dismissed as false positive');
});
document.getElementById('later').addEventListener('click', function () {
verdictPanel.hidden = true;
});
windowSelect.addEventListener('change', function () {
load(Number(windowSelect.value), result.end);
});
/* ── The feed: a new minute of scores every few seconds ───────────── */
function tick() {
result = detector.run(Number(windowSelect.value), result.end + detector.step);
heatmap.data = result.scores;
trend.data = result.records;
trend.end = result.end;
trend.bands = [{ field: currentTag, label: 'Forecast', confidence: 0.95, records: result.bands[currentTag] }];
trend.markers = detector.markersFor(result, currentTag, THRESHOLDS);
}
function startFeed() {
feedTimer = setInterval(tick, 4000);
feedButton.textContent = 'Pause feed';
}
feedButton.addEventListener('click', function () {
if (feedTimer) {
clearInterval(feedTimer);
feedTimer = null;
feedButton.textContent = 'Resume feed';
}
else {
startFeed();
}
});
/* ── Theme ───────────────────────────────────────────────────────── */
themeToggle.addEventListener('click', function () {
const theme = document.body.getAttribute('theme') === 'industrial-dark' ? 'industrial' : 'industrial-dark';
document.body.setAttribute('theme', theme);
themeToggle.textContent = theme === 'industrial-dark' ? 'Light' : 'Dark';
trend.invalidate();
});
load(Number(windowSelect.value));
startFeed();
};
styles.css
/* Anomaly detection. The heatmap across the top is the map; the trend and the tree under
it are the two views of whatever was picked; the verdict panel appears only when the
model has asked a question. */
html,
body {
height: 100%;
margin: 0;
}
body.viewport {
background: var(--smart-industrial-canvas, var(--smart-body-background-color));
color: var(--smart-text-color);
font-family: var(--smart-font-family);
font-size: 13px;
}
.an-screen {
gap: 12px;
}
.an-summary {
font-size: 12px;
color: var(--smart-text-secondary-color);
}
.an-window {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.an-window select {
min-height: 28px;
font: inherit;
font-size: 12px;
color: var(--smart-text-color);
background: var(--smart-industrial-panel-background, var(--smart-surface));
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 3px;
}
.an-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.an-panel-hint {
font-size: 11px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.75;
}
.an-row {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(320px, 2fr);
gap: 12px;
}
@media (max-width: 1000px) {
.an-row {
grid-template-columns: 1fr;
}
}
smart-anomaly-heatmap {
--smart-heatmap-cell-size: 11px;
--smart-heatmap-row-height: 20px;
--smart-heatmap-series-width: 190px;
width: 100%;
}
smart-trend {
--smart-trend-height: 340px;
width: 100%;
}
smart-root-cause-tree {
width: 100%;
min-height: 340px;
}
.an-verdict {
display: flex;
flex-direction: column;
gap: 10px;
}
.an-verdict-text {
margin: 0;
font-size: 14px;
}
.an-verdict-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.an-verdict-note {
margin: 0;
font-size: 12px;
opacity: 0.75;
max-width: 80ch;
}
#verdicts {
--smart-audit-max-height: 260px;
}
/* The panel chrome sets display, which would beat the hidden attribute. */
.an-verdict-panel[hidden] {
display: none;
}