Operator screen
Sequence of Events
A substation trip recorded at microsecond resolution, in order.
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>Sequence of Events - 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">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">SEQUENCE OF EVENTS</span>
<span class="hmi-area">Substation 4 · Feeder 3 trip · IEC 61850 SOE record</span>
</div>
<div class="hmi-header-actions">
<span class="hmi-resolution-label" id="resolutionLabel">Microsecond</span>
<button class="hmi-button" id="toggleResolution" type="button">Show at 1 ms</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="hmi-panel soe-explain" aria-label="What this record shows">
<div class="hmi-panel-header">The record</div>
<div class="soe-explain-body">
<p>
A protection event on a feeder takes about twelve milliseconds. Nine devices each record what they saw, and afterwards the records have to answer one question: <b>what caused what</b>. The answer depends on the order of the events, and the order depends on the resolution of the timestamps.
</p>
<p>
<code>Smart.DateTime</code> stores microseconds and nanoseconds as separate fields instead of as a fraction of a millisecond, so a timestamp keeps its resolution when it is stored, sorted and printed. Press <b>Show at 1 ms</b> to truncate the same record to millisecond resolution and see the order of events become ambiguous.
</p>
</div>
</section>
<section class="hmi-panel soe-panel" aria-label="Event record">
<div class="hmi-panel-header">
<span>Events</span>
<span class="soe-count" id="ambiguity"></span>
</div>
<div class="soe-table-wrap" tabindex="0" aria-label="Sequence of events table">
<table class="soe-table" id="soeTable">
<caption class="visually-hidden">
Sequence of events for the Feeder 3 trip, ordered by time
</caption>
<thead>
<tr>
<th scope="col" class="col-seq">#</th>
<th scope="col" class="col-time">Timestamp</th>
<th scope="col" class="col-delta">Δt from first</th>
<th scope="col" class="col-device">IED</th>
<th scope="col">Event</th>
<th scope="col" class="col-quality">Quality</th>
</tr>
</thead>
<tbody id="soeBody"></tbody>
</table>
</div>
</section>
<section class="hmi-panel soe-notes" aria-label="Notes">
<div class="hmi-panel-header">Why the resolution is the whole point</div>
<div class="soe-explain-body">
<p>
<b>IEC 61850 SOE.</b> A relay and the breaker it commands are different devices with different clocks. Protection engineers need to know whether the breaker opened because the relay tripped, or whether it failed and the backup operated. At 1 ms resolution both events have the same timestamp and the record cannot answer the question.
</p>
<p>
<b>21 CFR Part 11.</b> An audit trail has to be attributable and ordered. Two records with the same timestamp have no defined order, and an audit trail that cannot order its records can be rejected by an inspector.
</p>
<p class="soe-caveat">
Note on the format tokens: <code>uu</code> and <code>nn</code> are the microsecond and nanosecond fields. A run of three characters such as <code>uuu</code> is parsed as <code>uu</code> followed by <code>u</code> and prints the field twice, so <code>456</code> becomes <code>456456</code>. Use one or two characters per field. This screen uses <code>HH:mm:ss.fff uu</code>; <code>HH:mm:ss.fffuunn</code> gives the same timestamp without separators and with the nanosecond field.
</p>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.date.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" />
/* Sequence of events - what a timestamp has to be able to say.
*
* The record below is a feeder trip: a fault, the relay that saw it, the trip command,
* the breaker's own aux contacts, and the backup that armed and then stood down. The
* whole thing is over in about twelve milliseconds, and every interesting question about
* it - did the breaker open because the relay tripped, or did the relay time out and the
* backup operate - is a question about the order of events, not their values.
*
* At microsecond resolution the order is a fact. At millisecond resolution several of
* these events share a stamp, and the order becomes whatever the storage layer happened
* to return. The toggle shows the same record both ways.
*
* Offsets below are microseconds from the fault, taken to be the zero of the record. */
const FAULT = { year: 2026, month: 8, day: 18, hour: 14, minute: 32, second: 10, ms: 123 };
const EVENTS = [
{ us: 0, device: 'MU-F3-A', event: 'Phase A current step change detected', quality: 'good' },
{ us: 417, device: 'MU-F3-A', event: 'Sampled value stream flags overcurrent', quality: 'good' },
{ us: 1180, device: 'REL-F3', event: 'Protection element 50P pickup', quality: 'good' },
{ us: 1188, device: 'REL-F3', event: 'Phase selection: A-G', quality: 'good' },
{ us: 2043, device: 'REL-F3', event: 'Trip decision, GOOSE published', quality: 'good' },
{ us: 2107, device: 'REL-BK4', event: 'Backup timer armed (breaker failure)', quality: 'good' },
{ us: 2650, device: 'CB-F3', event: 'Trip coil energised', quality: 'good' },
{ us: 2694, device: 'CB-F3', event: 'GOOSE received, subscription healthy', quality: 'good' },
{ us: 9310, device: 'CB-F3', event: 'Aux contact 52a opened', quality: 'good' },
{ us: 9355, device: 'CB-F3', event: 'Aux contact 52b closed', quality: 'good' },
{ us: 9362, device: 'REL-BK4', event: 'Backup timer stood down - breaker operated', quality: 'good' },
{ us: 11840, device: 'MU-F3-A', event: 'Phase A current below threshold', quality: 'good' },
{ us: 12006, device: 'REL-F3', event: 'Protection element 50P dropout', quality: 'good' },
{ us: 12470, device: 'SCADA', event: 'Feeder 3 reported open', quality: 'questionable' }
];
function el(id) {
return document.getElementById(id);
}
/* A DateTime at the fault instant plus an offset in microseconds.
*
* The offset is split rather than added as a fraction of a millisecond: the whole reason
* the element carries microsecond and nanosecond fields is that a fraction of a
* millisecond in a double is exactly what loses the low digits. */
function stampFor(offsetMicroseconds) {
const DateTime = Smart.Utilities.DateTime,
totalMs = FAULT.ms + Math.floor(offsetMicroseconds / 1000),
microsecond = offsetMicroseconds % 1000;
return new DateTime(FAULT.year, FAULT.month, FAULT.day, FAULT.hour, FAULT.minute,
FAULT.second + Math.floor(totalMs / 1000), totalMs % 1000, microsecond, 0);
}
/* Microsecond resolution, and the same stamp truncated to the millisecond.
*
* The nanosecond field is deliberately not in the format. Merchandising precision the
* record does not have is how a display starts lying: these devices timestamp to the
* microsecond, so a trailing 000 would be three digits of decoration. */
const FULL = 'HH:mm:ss.fff uu',
COARSE = 'HH:mm:ss.fff';
let coarse = false;
function render() {
const body = el('soeBody'),
format = coarse ? COARSE : FULL,
seen = {},
rows = [];
//Which stamps are shared once truncated - that is what "ambiguous" means here.
EVENTS.forEach(function (event) {
const text = stampFor(event.us).toString(format);
seen[text] = (seen[text] || 0) + 1;
rows.push({ event: event, text: text });
});
body.innerHTML = '';
rows.forEach(function (row, index) {
const tr = document.createElement('tr'),
ambiguous = seen[row.text] > 1;
if (ambiguous) {
tr.className = 'ambiguous';
}
/* Delta is computed from the offsets, not from the rendered strings: the
truncated view is a display, and the underlying record still knows. */
const delta = row.event.us === 0 ? '—' :
(row.event.us / 1000).toFixed(3) + ' ms';
tr.innerHTML =
'<td class="col-seq">' + (index + 1) + '</td>' +
'<td class="col-time">' + row.text +
(ambiguous ? '<span class="tie" title="Shared with another event">tie</span>' : '') +
'</td>' +
'<td class="col-delta">' + delta + '</td>' +
'<td class="col-device">' + row.event.device + '</td>' +
'<td>' + row.event.event + '</td>' +
'<td class="col-quality"><span class="quality quality-' + row.event.quality + '">' +
row.event.quality + '</span></td>';
body.appendChild(tr);
});
const tied = Object.keys(seen).filter(function (key) {
return seen[key] > 1;
});
//How many events lost their place, not how many stamps collided.
const lost = tied.reduce(function (total, key) {
return total + seen[key];
}, 0);
el('ambiguity').textContent = tied.length === 0 ?
'Every event has a distinct timestamp' :
lost + ' of ' + EVENTS.length + ' events share a timestamp with another - ' +
'their order is no longer recorded';
el('ambiguity').className = 'soe-count' + (tied.length ? ' soe-count-bad' : '');
el('resolutionLabel').textContent = coarse ? 'Millisecond' : 'Microsecond';
el('toggleResolution').textContent = coarse ? 'Show at 1 µs' : 'Show at 1 ms';
}
window.onload = function () {
render();
el('toggleResolution').addEventListener('click', function () {
coarse = !coarse;
render();
});
const toggle = el('themeToggle');
toggle.addEventListener('click', function () {
const dark = document.body.getAttribute('theme') === 'industrial-dark';
document.body.setAttribute('theme', dark ? 'industrial' : 'industrial-dark');
toggle.textContent = dark ? 'Dark' : 'Light';
});
};
styles.css
/* Sequence of events. Colour is used for exactly one thing on this screen: marking the
rows whose order stopped being a fact. Everything else is neutral, which is what makes
that marking readable. */
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;
overflow: hidden;
}
.hmi-screen {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 10px;
padding: 10px;
height: 100%;
box-sizing: border-box;
}
/* ── Header ──────────────────────────────────────────────────────────── */
.hmi-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 10px 14px;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.hmi-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.hmi-plant {
font-size: 16px;
font-weight: 600;
letter-spacing: .08em;
}
.hmi-area {
color: var(--smart-text-tertiary-color);
font-size: 11px;
letter-spacing: .04em;
}
.hmi-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.hmi-resolution-label {
color: var(--smart-text-tertiary-color);
font-size: 11px;
letter-spacing: .06em;
text-transform: uppercase;
}
.hmi-button,
.hmi-theme-toggle {
background: transparent;
border: 1px solid var(--smart-border);
border-radius: var(--smart-border-radius);
color: var(--smart-text-secondary-color);
cursor: pointer;
font-family: inherit;
font-size: 11px;
height: 30px;
letter-spacing: .06em;
padding: 0 14px;
text-transform: uppercase;
}
.hmi-button:hover,
.hmi-theme-toggle:hover {
border-color: var(--smart-border-hover);
color: var(--smart-text-color);
}
.hmi-button:focus-visible,
.hmi-theme-toggle:focus-visible {
outline: 2px solid var(--smart-industrial-focus);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--smart-industrial-focus-halo);
}
/* ── Panels ──────────────────────────────────────────────────────────── */
.hmi-panel {
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.hmi-panel-header {
align-items: center;
background: var(--smart-industrial-panel-header-background);
border-bottom: 1px solid var(--smart-industrial-panel-border);
color: var(--smart-text-secondary-color);
display: flex;
font-size: 11px;
gap: 16px;
justify-content: space-between;
letter-spacing: .08em;
padding: 7px 12px;
text-transform: uppercase;
}
.soe-explain-body {
padding: 10px 14px;
overflow: auto;
}
.soe-explain-body p {
margin: 0 0 8px;
max-width: 105ch;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.soe-explain-body p:last-child {
margin-bottom: 0;
}
.soe-explain-body code {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
color: var(--smart-industrial-accent-text);
}
.soe-caveat {
border-left: 2px solid var(--smart-border);
padding-left: 10px;
}
/* ── The record ──────────────────────────────────────────────────────── */
.soe-count {
letter-spacing: 0;
text-transform: none;
font-size: 11.5px;
}
.soe-count-bad {
color: var(--smart-industrial-alarm-warning-text);
}
.soe-table-wrap {
overflow: auto;
min-height: 0;
}
.soe-table {
border-collapse: collapse;
width: 100%;
font-size: 12.5px;
}
.soe-table th {
background: var(--smart-industrial-panel-header-background);
border-bottom: 1px solid var(--smart-industrial-panel-border);
color: var(--smart-text-tertiary-color);
font-size: 10.5px;
font-weight: 600;
letter-spacing: .06em;
padding: 6px 10px;
position: sticky;
text-align: left;
text-transform: uppercase;
top: 0;
z-index: 1;
}
.soe-table td {
border-bottom: 1px solid var(--smart-industrial-panel-border);
padding: 5px 10px;
vertical-align: top;
}
/* Timestamps, deltas and identifiers are compared down the column, so they are
monospaced and figure-aligned. Prose is not. */
.col-seq,
.col-time,
.col-delta,
.col-device {
font-family: Consolas, Monaco, monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.col-seq {
color: var(--smart-text-tertiary-color);
text-align: right;
width: 36px;
}
/* The fixed columns are sized to their content so the event description takes the
slack - a delta column three times wider than "12.470 ms" pushes the two things
being compared apart for no reason. */
.col-time {
width: 160px;
}
.col-delta {
text-align: right;
width: 104px;
padding-right: 18px;
}
.col-device {
width: 92px;
}
.col-quality {
width: 96px;
}
.quality {
border-radius: 2px;
font-size: 10.5px;
letter-spacing: .04em;
padding: 1px 6px;
text-transform: uppercase;
}
.quality-good {
color: var(--smart-industrial-ok-text);
}
.quality-questionable {
color: var(--smart-industrial-alarm-advisory-text);
}
/* The one piece of colour on the screen: a row that no longer knows its own place. */
.soe-table tr.ambiguous td {
background: var(--smart-industrial-abnormal-background);
}
.soe-table tr.ambiguous .col-time {
color: var(--smart-industrial-alarm-warning-text);
font-weight: 600;
}
.tie {
border: 1px solid currentColor;
border-radius: 2px;
font-size: 9.5px;
letter-spacing: .06em;
margin-left: 8px;
padding: 0 4px;
text-transform: uppercase;
vertical-align: 1px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}