Operator screen
Safety
Safety interlocks with a live cause and effect matrix, device faceplates, bypasses under signature and the trip record.
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>Safety Interlocks - 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 sf-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">SAFETY INTERLOCKS</span>
<span class="hmi-area">Unit 200 · SIS logic solver SLS-200 · SIL 2</span>
</div>
<div class="hmi-header-status sf-status" id="status" role="status"></div>
<div class="hmi-header-actions">
<button class="hmi-button" id="scenario" type="button">Raise reactor pressure</button>
<button class="hmi-button" id="resetPlant" type="button">Reset the plant</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="hmi-body sf-body">
<div class="hmi-panel sf-matrix-panel">
<div class="hmi-panel-header">
<span>Cause and effect</span>
<span class="sf-panel-hint" id="matrixHint">Arrows move, Enter opens a cell; a cause's header lists what it trips</span>
</div>
<div class="hmi-panel-content">
<smart-cause-effect-matrix id="matrix" label="Unit 200 SIS"></smart-cause-effect-matrix>
</div>
</div>
<div class="sf-row">
<div class="hmi-panel sf-devices-panel">
<div class="hmi-panel-header">
<span>Final elements</span>
<span class="sf-panel-hint">The feedback beside the command; the trip holds the device until the cause clears</span>
</div>
<div class="hmi-panel-content sf-devices">
<smart-device-control id="xv04" tag="XV-2104" description="Feed isolation" kind="valve" state="open" commanded="open" mode="auto" interactive></smart-device-control>
<smart-device-control id="p01" tag="P-2101A" description="Feed pump" kind="pump" state="running" commanded="start" mode="auto" interactive run-hours="1842.5" start-count="312"></smart-device-control>
<smart-device-control id="xv07" tag="XV-2107" description="Steam to jacket" kind="valve" state="open" commanded="open" mode="auto" interactive></smart-device-control>
<smart-device-control id="xv09" tag="XV-2109" description="Depressuring" kind="valve" state="closed" commanded="close" mode="auto" interactive></smart-device-control>
</div>
</div>
<div class="hmi-panel sf-bypass-panel">
<div class="hmi-panel-header">
<span>Bypasses</span>
<span class="sf-panel-hint">A bypass on a safety function is signed for</span>
</div>
<div class="hmi-panel-content sf-bypass">
<ul class="sf-bypass-list" id="bypasses"></ul>
<p class="sf-note">
Bypassing a cause takes it out of every trip it belongs to. The chart shows the
bypass as the logic solver reports it; nothing here applies one. The request is
signed by a person authorised to, with a reason, and goes into the record.
</p>
<smart-esignature id="sign" label="Authorise a bypass" lock-meaning meaning="authorised" require-reason auto-close="1500"></smart-esignature>
</div>
</div>
</div>
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Trip record</span>
</div>
<div class="hmi-panel-content">
<smart-audit-trail id="record" label="Safety system record" max-rows="20"></smart-audit-trail>
</div>
</div>
</section>
</div>
<!-- scripts -->
<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.causeeffectmatrix.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.devicecontrol.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.esignature.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.audittrail.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" />
/* Safety interlocks.
*
* The unit's cause and effect chart live, the final elements it acts on as device
* faceplates, the bypasses under signature, and the record. The logic solver here is a
* stand-in that trips what the chart says a second after a cause goes active and holds the
* device until the cause clears; a plant's is its SIS, and the screen only ever shows what
* it reports. The one thing the screen asks for is a bypass, and it asks with a signature. */
window.onload = function () {
const Audit = window.Smart.Industrial.Audit,
matrix = document.getElementById('matrix'),
status = document.getElementById('status'),
matrixHint = document.getElementById('matrixHint'),
bypassList = document.getElementById('bypasses'),
sign = document.getElementById('sign'),
record = document.getElementById('record'),
scenarioButton = document.getElementById('scenario'),
resetButton = document.getElementById('resetPlant'),
themeToggle = document.getElementById('themeToggle'),
plates = {
xv04: document.getElementById('xv04'),
p01: document.getElementById('p01'),
xv07: document.getElementById('xv07'),
xv09: document.getElementById('xv09')
},
OPERATOR = { id: 'jmoreau', name: 'J. Moreau' },
trail = new Audit.Trail({ system: 'SLS-200' });
record.showToolbar = false;
trail.subscribe(function (entry) {
record.addEntry(entry);
});
/* ── The chart ──────────────────────────────────────────────────────── */
const CAUSES = [
{ id: 'psh', tag: 'PSHH-2101', label: 'Reactor pressure high high' },
{ id: 'tsh', tag: 'TSHH-2102', label: 'Reactor temperature high high' },
{ id: 'lsl', tag: 'LSLL-2105', label: 'Feed drum level low low' },
{ id: 'fsl', tag: 'FSLL-2104', label: 'Cooling water flow low low' },
{ id: 'esd', tag: 'HS-2100', label: 'Unit emergency stop' }
];
const EFFECTS = [
{ id: 'xv04', tag: 'XV-2104', label: 'Close feed isolation' },
{ id: 'p01', tag: 'P-2101A', label: 'Stop feed pump' },
{ id: 'xv07', tag: 'XV-2107', label: 'Close steam to jacket' },
{ id: 'xv09', tag: 'XV-2109', label: 'Open depressuring' },
{ id: 'al', tag: 'UA-2100', label: 'Unit alarm' }
];
const TRIP = { xv04: 'close', p01: 'stop', xv07: 'close', xv09: 'open' },
TRIPPED_STATE = { close: 'closed', stop: 'stopped', open: 'open' },
NORMAL = { xv04: { command: 'open', state: 'open' }, p01: { command: 'start', state: 'running' }, xv07: { command: 'open', state: 'open' }, xv09: { command: 'close', state: 'closed' } };
matrix.effects = EFFECTS.map(function (effect) {
return { id: effect.id, tag: effect.tag, label: effect.label, tripped: false };
});
matrix.matrix = [
{ cause: 'psh', effect: 'xv04', mark: 'X' }, { cause: 'psh', effect: 'p01', mark: 'X' }, { cause: 'psh', effect: 'xv07', mark: 'X' },
{ cause: 'psh', effect: 'xv09', mark: 'T', delay: 30, note: 'after the feed is isolated' }, { cause: 'psh', effect: 'al', mark: 'X' },
{ cause: 'tsh', effect: 'xv04', mark: 'X' }, { cause: 'tsh', effect: 'xv07', mark: 'X' }, { cause: 'tsh', effect: 'al', mark: 'X' },
{ cause: 'lsl', effect: 'p01', mark: 'X' }, { cause: 'lsl', effect: 'al', mark: 'X' },
{ cause: 'fsl', effect: 'xv07', mark: 'X' }, { cause: 'fsl', effect: 'p01', mark: 'T', delay: 60 }, { cause: 'fsl', effect: 'al', mark: 'X' },
{ cause: 'esd', effect: 'xv04', mark: 'X' }, { cause: 'esd', effect: 'p01', mark: 'X' }, { cause: 'esd', effect: 'xv07', mark: 'X' },
{ cause: 'esd', effect: 'xv09', mark: 'X' }, { cause: 'esd', effect: 'al', mark: 'X' }
];
/* ── The stand-in logic solver ──────────────────────────────────────── */
const active = {},
bypassed = {},
tripped = {};
function publish() {
matrix.causes = CAUSES.map(function (cause) {
return { id: cause.id, tag: cause.tag, label: cause.label, active: !!active[cause.id], bypassed: !!bypassed[cause.id] };
});
matrix.effects = EFFECTS.map(function (effect) {
return { id: effect.id, tag: effect.tag, label: effect.label, tripped: !!tripped[effect.id] };
});
Object.keys(plates).forEach(function (id) {
const plate = plates[id];
plate.interlocked = !!tripped[id];
plate.interlockReason = tripped[id] ? 'Held by ' + CAUSES.filter(function (cause) {
return active[cause.id] && !bypassed[cause.id] && matrix.linksFor(cause.id).some(function (link) {
return link.effect === id;
});
}).map(function (cause) {
return cause.tag;
}).join(', ') : '';
});
const bad = matrix.discrepancies(),
activeCount = Object.keys(active).filter(function (id) {
return active[id];
}).length;
status.textContent = (activeCount ? activeCount + ' cause' + (activeCount === 1 ? '' : 's') + ' active, ' : 'No cause active, ') +
Object.keys(tripped).filter(function (id) {
return tripped[id];
}).length + ' effects tripped' + (bad.length ? ', ' + bad.length + ' discrepancy' : '');
status.classList.toggle('sf-status-bad', bad.length > 0);
bypassList.innerHTML = CAUSES.map(function (cause) {
return '<li><span class="sf-bypass-tag">' + cause.tag + '</span><span class="sf-bypass-label">' + cause.label + '</span>' +
'<button type="button" class="hmi-button" data-cause="' + cause.id + '">' + (bypassed[cause.id] ? 'Remove bypass' : 'Bypass') + '</button></li>';
}).join('');
}
/* the solver trips what the chart says, and releases when nothing holds it */
function solve() {
EFFECTS.forEach(function (effect) {
const expected = matrix.expected(effect.id);
if (expected && !tripped[effect.id]) {
tripped[effect.id] = true;
if (plates[effect.id]) {
plates[effect.id].commanded = TRIP[effect.id];
plates[effect.id].state = TRIPPED_STATE[TRIP[effect.id]];
}
trail.append({ user: 'SLS-200', userName: 'Logic solver', action: 'trip', record: effect.tag, field: 'state', newValue: TRIP[effect.id], reason: 'Cause and effect' });
}
else if (!expected && tripped[effect.id]) {
tripped[effect.id] = false;
trail.append({ user: 'SLS-200', userName: 'Logic solver', action: 'release', record: effect.tag, field: 'state', newValue: 'released', reason: 'No active cause' });
}
});
publish();
}
function raise(causeId, on) {
const cause = CAUSES.find(function (candidate) {
return candidate.id === causeId;
});
active[causeId] = on;
trail.append({ user: 'SLS-200', userName: 'Logic solver', action: on ? 'cause' : 'clear', record: cause.tag, field: 'state', newValue: on ? 'active' : 'normal' });
publish();
setTimeout(solve, 1000);
}
/* ── The scenario ───────────────────────────────────────────────────── */
const SCENARIO = [
{ label: 'Raise reactor pressure', cause: 'psh', on: true },
{ label: 'Pressure returns to normal', cause: 'psh', on: false },
{ label: 'Lose cooling water', cause: 'fsl', on: true },
{ label: 'Cooling water back', cause: 'fsl', on: false },
{ label: 'Emergency stop', cause: 'esd', on: true },
{ label: 'Reset the emergency stop', cause: 'esd', on: false }
];
let step = 0;
scenarioButton.addEventListener('click', function () {
raise(SCENARIO[step % SCENARIO.length].cause, SCENARIO[step % SCENARIO.length].on);
step++;
scenarioButton.textContent = SCENARIO[step % SCENARIO.length].label;
});
resetButton.addEventListener('click', function () {
CAUSES.forEach(function (cause) {
active[cause.id] = false;
});
EFFECTS.forEach(function (effect) {
tripped[effect.id] = false;
});
Object.keys(plates).forEach(function (id) {
plates[id].commanded = NORMAL[id].command;
plates[id].state = NORMAL[id].state;
});
step = 0;
scenarioButton.textContent = SCENARIO[0].label;
publish();
});
/* ── The plates: the operator may run a device back once it is released ── */
Object.keys(plates).forEach(function (id) {
const plate = plates[id];
plate.addEventListener('modeRequest', function (event) {
setTimeout(function () {
plate.mode = event.detail.mode;
}, 300);
});
plate.addEventListener('commandRequest', function (event) {
setTimeout(function () {
plate.commanded = event.detail.command;
plate.state = TRIPPED_STATE[event.detail.command] || { start: 'running', open: 'open' }[event.detail.command];
trail.append({ user: OPERATOR.id, userName: OPERATOR.name, action: 'command', record: plate.tag, field: 'command', newValue: event.detail.command });
}, 700);
});
plate.addEventListener('blocked', function (event) {
matrixHint.textContent = event.detail.tag + ': ' + event.detail.reason;
});
});
/* ── The chart's own events ─────────────────────────────────────────── */
matrix.addEventListener('cellClick', function (event) {
const cause = CAUSES.find(function (candidate) {
return candidate.id === event.detail.cause;
}),
effect = EFFECTS.find(function (candidate) {
return candidate.id === event.detail.effect;
});
matrixHint.textContent = event.detail.mark ?
cause.tag + ' → ' + effect.tag + ': ' + event.detail.mark + (event.detail.link.delay ? ', ' + event.detail.link.delay + ' s' : '') + (event.detail.link.note ? ' - ' + event.detail.link.note : '') :
cause.tag + ' does not act on ' + effect.tag;
});
matrix.addEventListener('effectClick', function (event) {
const effect = EFFECTS.find(function (candidate) {
return candidate.id === event.detail.effect;
});
matrixHint.textContent = effect.tag + ' is tripped by ' + event.detail.causes.map(function (link) {
return CAUSES.find(function (candidate) {
return candidate.id === link.cause;
}).tag;
}).join(', ') + (event.detail.expected ? ' - expected tripped now' : '');
});
/* ── Bypasses, under signature ──────────────────────────────────────── */
bypassList.addEventListener('click', function (event) {
const button = event.target.closest('button[data-cause]');
if (!button) {
return;
}
const id = button.getAttribute('data-cause'),
cause = CAUSES.find(function (candidate) {
return candidate.id === id;
}),
removing = !!bypassed[id];
sign.sign({ id: cause.tag, title: (removing ? 'Remove the bypass on ' : 'Bypass ') + cause.tag, fields: [{ label: 'Safety function', value: cause.label }, { label: 'Trips', value: matrix.linksFor(id).length + ' effects' }] })
.then(function (signature) {
bypassed[id] = !removing;
trail.append({
user: signature.userId, userName: signature.userName, action: removing ? 'bypass removed' : 'bypass', record: cause.tag, field: 'bypass',
oldValue: removing ? 'bypassed' : 'in service', newValue: removing ? 'in service' : 'bypassed', reason: signature.reason, signature: signature
});
publish();
setTimeout(solve, 500);
}, function () { });
});
/* the directory: only a supervisor authorises a bypass */
sign.addEventListener('signRequest', function (event) {
setTimeout(function () {
if (event.detail.userId === 'asmith' && event.detail.password === 'qa2026') {
sign.accept({ userName: 'A. Smith' });
}
else {
sign.reject(event.detail.userId === 'jmoreau' ? 'an operator cannot authorise a bypass' : '');
}
}, 600);
});
/* ── 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';
});
publish();
};
styles.css
/* Safety interlocks. The chart is the top of the screen because it is the document; the
final elements and the bypasses under it are what the plant and the operator did about it. */
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;
}
.sf-screen {
gap: 12px;
}
.sf-status {
font-size: 12px;
color: var(--smart-text-secondary-color);
}
.sf-status-bad {
color: var(--smart-industrial-alarm-critical-text);
font-weight: 600;
}
.sf-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.sf-panel-hint {
font-size: 11px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.75;
}
smart-cause-effect-matrix {
width: 100%;
}
.sf-row {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(320px, 1.3fr);
gap: 12px;
}
@media (max-width: 1100px) {
.sf-row {
grid-template-columns: 1fr;
}
}
.sf-devices {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.sf-devices smart-device-control {
--smart-device-width: 250px;
}
.sf-bypass {
display: flex;
flex-direction: column;
gap: 10px;
}
.sf-bypass-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.sf-bypass-list li {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
font-size: 12px;
}
.sf-bypass-tag {
font-family: Consolas, "Courier New", monospace;
font-weight: 700;
}
.sf-bypass-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--smart-text-secondary-color);
}
.sf-note {
margin: 0;
font-size: 12px;
color: var(--smart-text-secondary-color);
max-width: 60ch;
}
smart-esignature {
width: 100%;
}
#record {
--smart-audit-max-height: 240px;
}