@yarkivaev/scada 2.3.72 → 2.3.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.72",
3
+ "version": "2.3.74",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,7 +4,7 @@
4
4
  * @param {object} sensor - machine sensor
5
5
  * @param {{start: Date, end: Date}} range - query window
6
6
  * @param {number} step - downsample step in ms
7
- * @param {string} [mode] - rises or changes
7
+ * @param {string} [mode] - rises, changes, or holds
8
8
  * @returns {Promise<Array>} series rows
9
9
  */
10
10
  export default function measurementRead(sensor, range, step, mode) {
@@ -14,6 +14,9 @@ export default function measurementRead(sensor, range, step, mode) {
14
14
  if (mode === 'changes') {
15
15
  return sensor.changes(range);
16
16
  }
17
+ if (mode === 'holds') {
18
+ return sensor.holds(range, step);
19
+ }
17
20
  return sensor.measurements(range, step);
18
21
  }
19
22
 
@@ -28,10 +31,14 @@ export default function measurementRead(sensor, range, step, mode) {
28
31
  export function measurementItem(key, sensor, rows) {
29
32
  const unit = rows.length > 0 ? rows[0].unit : '';
30
33
  const values = rows.map((row) => {
31
- return {
34
+ const point = {
32
35
  timestamp: row.timestamp.toISOString(),
33
36
  value: row.value
34
37
  };
38
+ if (row.until) {
39
+ point.until = row.until.toISOString();
40
+ }
41
+ return point;
35
42
  });
36
43
  return { key, name: sensor.name(), unit, values };
37
44
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Coverage holds of one ClickHouse metric.
3
+ *
4
+ * Heartbeat repeats stay in one span. A value change or a silence
5
+ * longer than gap closes the span. Samples before the window are ignored.
6
+ *
7
+ * @example
8
+ * await clickhouseHolds(conn, 'm1/diam', { start, end }, 3000, 'mm');
9
+ *
10
+ * @param {object} connection - ClickHouse connection with query method
11
+ * @param {string} topic - metric topic
12
+ * @param {{start: Date, end: Date}} range - half-open window
13
+ * @param {number} gap - maximum silence in milliseconds
14
+ * @param {string} unit - measurement unit
15
+ * @returns {Promise<Array<{timestamp: Date, value: number, until: Date, unit: string}>>} holds
16
+ */
17
+ function formatDateTime(date) {
18
+ return date.toISOString().replace('Z', '').replace('T', ' ');
19
+ }
20
+
21
+ const HOLDS_SQL = `WITH windowed AS (
22
+ SELECT ts, value
23
+ FROM scada.metrics
24
+ WHERE topic = {topic:String}
25
+ AND ts >= {start:DateTime64(3)}
26
+ AND ts < {end:DateTime64(3)}
27
+ ), lagged AS (
28
+ SELECT
29
+ ts,
30
+ value,
31
+ lagInFrame(ts, 1) OVER (ORDER BY ts) AS prev_ts,
32
+ lagInFrame(toNullable(value), 1) OVER (ORDER BY ts) AS prev
33
+ FROM windowed
34
+ ), flagged AS (
35
+ SELECT
36
+ ts,
37
+ value,
38
+ if(
39
+ prev IS NULL
40
+ OR value != prev
41
+ OR toUnixTimestamp64Milli(ts) - toUnixTimestamp64Milli(prev_ts) > {gap:UInt64},
42
+ 1,
43
+ 0
44
+ ) AS fresh
45
+ FROM lagged
46
+ ), grouped AS (
47
+ SELECT
48
+ ts,
49
+ value,
50
+ sum(fresh) OVER (ORDER BY ts) AS grp
51
+ FROM flagged
52
+ )
53
+ SELECT min(ts) AS ts, max(ts) AS until, any(value) AS value
54
+ FROM grouped
55
+ GROUP BY grp
56
+ ORDER BY ts`;
57
+
58
+ export default async function clickhouseHolds(connection, topic, range, gap, unit) {
59
+ const rows = await connection.query(HOLDS_SQL, {
60
+ topic,
61
+ start: formatDateTime(range.start),
62
+ end: formatDateTime(range.end),
63
+ gap: Math.max(1, Number(gap) || 1000)
64
+ });
65
+ return rows.map((row) => {
66
+ return {
67
+ timestamp: new Date(`${row.ts}Z`),
68
+ value: row.value,
69
+ until: new Date(`${row.until}Z`),
70
+ unit
71
+ };
72
+ });
73
+ }
74
+
75
+ export { HOLDS_SQL };
@@ -37,7 +37,7 @@ const RISES_SQL = `WITH prior AS (
37
37
  lagInFrame(toNullable(value), 1) OVER (ORDER BY ts) AS prev
38
38
  FROM series
39
39
  )
40
- SELECT ts, sum(1) OVER (ORDER BY ts) AS value
40
+ SELECT ts, sum(1) OVER (ORDER BY ts) AS prefix
41
41
  FROM lagged
42
42
  WHERE ts >= {start:DateTime64(3)} AND prev = 0 AND value = 1
43
43
  ORDER BY ts`;
@@ -49,7 +49,7 @@ export default async function clickhouseRises(connection, topic, range, unit) {
49
49
  end: formatDateTime(range.end)
50
50
  });
51
51
  return rows.map((row) => {
52
- return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
52
+ return { timestamp: new Date(`${row.ts}Z`), value: Number(row.prefix), unit };
53
53
  });
54
54
  }
55
55
 
@@ -23,6 +23,7 @@
23
23
  import clickhouseStreamHub from './streamHub.js';
24
24
  import clickhouseRises from './rises.js';
25
25
  import clickhouseChanges from './changes.js';
26
+ import clickhouseHolds from './holds.js';
26
27
  import clickhouseSeries from './series.js';
27
28
  import clickhouseLatest from './latest.js';
28
29
 
@@ -43,6 +44,9 @@ export default function clickhouseSensor(connection, topic, displayName, unit) {
43
44
  changes(range) {
44
45
  return clickhouseChanges(connection, topic, range, unit);
45
46
  },
47
+ holds(range, step) {
48
+ return clickhouseHolds(connection, topic, range, step, unit);
49
+ },
46
50
  stream(since, step, callback, clock) {
47
51
  return clickhouseStreamHub(connection).watch(topic, since, step, callback, {
48
52
  unit,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Folds heartbeat samples into coverage holds.
3
+ *
4
+ * A new span starts on the first in-window sample, a value change,
5
+ * or a silence longer than gap. Samples before the window are ignored.
6
+ *
7
+ * @param {Array<{ts: Date, value: *}>} rows - raw metric rows
8
+ * @param {number} start - window start epoch milliseconds
9
+ * @param {number} end - exclusive window end epoch milliseconds
10
+ * @param {number} gap - maximum silence in milliseconds
11
+ * @returns {Array<{ts: Date, value: *, until: Date}>} holds
12
+ *
13
+ * @example
14
+ * coverageHolds(rows, from, to, 3000);
15
+ */
16
+ export default function coverageHolds(rows, start, end, gap) {
17
+ const series = (rows || []).filter((row) => {
18
+ const ts = new Date(row.ts).getTime();
19
+ return ts >= start && ts < end;
20
+ }).sort((left, right) => {
21
+ return new Date(left.ts) - new Date(right.ts);
22
+ });
23
+ const spans = [];
24
+ series.forEach((row) => {
25
+ const ts = new Date(row.ts);
26
+ const last = spans[spans.length - 1];
27
+ if (!last) {
28
+ spans.push({ ts, value: row.value, until: ts });
29
+ return;
30
+ }
31
+ const silence = ts.getTime() - last.until.getTime();
32
+ if (row.value !== last.value || silence > gap) {
33
+ spans.push({ ts, value: row.value, until: ts });
34
+ return;
35
+ }
36
+ last.until = ts;
37
+ });
38
+ return spans;
39
+ }
@@ -1,5 +1,6 @@
1
1
  import risingPrefixes from '../risingPrefixes.js';
2
2
  import levelChanges from '../levelChanges.js';
3
+ import coverageHolds from '../coverageHolds.js';
3
4
  import { seriesWindow, topicRows } from '../topicWindow.js';
4
5
 
5
6
  function bucketKey(tsMs, originMs, stepMs) {
@@ -49,6 +50,15 @@ function changesOf(rows, topic, startIso, endIso) {
49
50
  });
50
51
  }
51
52
 
53
+ function holdsOf(rows, topic, startIso, endIso, stepMs) {
54
+ const start = new Date(startIso).getTime();
55
+ const end = new Date(endIso).getTime();
56
+ const hits = rows.filter((row) => {
57
+ return row.topic === topic;
58
+ });
59
+ return coverageHolds(hits, start, end, Math.max(1, Number(stepMs) || 1000));
60
+ }
61
+
52
62
  /**
53
63
  * In-memory metrics state port for tests and local runs.
54
64
  *
@@ -77,6 +87,9 @@ export default function metricsStateMemory(store) {
77
87
  changesForTopic(topic, startIso, endIso) {
78
88
  return changesOf(store.metrics, topic, startIso, endIso);
79
89
  },
90
+ holdsForTopic(topic, startIso, endIso, stepMs) {
91
+ return holdsOf(store.metrics, topic, startIso, endIso, stepMs);
92
+ },
80
93
  pollTopic(topic, afterIso, untilIso) {
81
94
  const after = new Date(afterIso).getTime();
82
95
  const until = new Date(untilIso).getTime();
@@ -15,6 +15,9 @@ export default function metricsStateDisabled() {
15
15
  changesForTopic() {
16
16
  return Promise.resolve([]);
17
17
  },
18
+ holdsForTopic() {
19
+ return Promise.resolve([]);
20
+ },
18
21
  pollTopic() {
19
22
  return Promise.resolve([]);
20
23
  },
@@ -49,6 +49,9 @@ export function pgMetricsRead(metrics) {
49
49
  changes(topic, startIso, endIso) {
50
50
  return metrics.changesForTopic(topic, startIso, endIso);
51
51
  },
52
+ holds(topic, startIso, endIso, stepMs) {
53
+ return metrics.holdsForTopic(topic, startIso, endIso, stepMs);
54
+ },
52
55
  poll(topic, afterIso, untilIso) {
53
56
  return metrics.pollTopic(topic, afterIso, untilIso);
54
57
  }
@@ -64,6 +67,13 @@ export function pgMetricsRead(metrics) {
64
67
  * @param {string} unit - unit string
65
68
  * @returns {object} sensor with name current measurements stream
66
69
  */
70
+ async function windowed(read, spec) {
71
+ const rows = spec.extra === undefined
72
+ ? await read[spec.method](spec.topic, spec.range.start.toISOString(), spec.range.end.toISOString())
73
+ : await read[spec.method](spec.topic, spec.range.start.toISOString(), spec.range.end.toISOString(), spec.extra);
74
+ return seriesRows(rows, spec.unit);
75
+ }
76
+
67
77
  export default function metricsSensor(read, topic, displayName, unit) {
68
78
  return {
69
79
  name() {
@@ -81,30 +91,17 @@ export default function metricsSensor(read, topic, displayName, unit) {
81
91
  unit
82
92
  };
83
93
  },
84
- async measurements(range, step) {
85
- const rows = await read.range(
86
- topic,
87
- range.start.toISOString(),
88
- range.end.toISOString(),
89
- step
90
- );
91
- return seriesRows(rows, unit);
94
+ measurements(range, step) {
95
+ return windowed(read, { method: 'range', topic, range, extra: step, unit });
96
+ },
97
+ rises(range) {
98
+ return windowed(read, { method: 'rises', topic, range, extra: undefined, unit });
92
99
  },
93
- async rises(range) {
94
- const rows = await read.rises(
95
- topic,
96
- range.start.toISOString(),
97
- range.end.toISOString()
98
- );
99
- return seriesRows(rows, unit);
100
+ changes(range) {
101
+ return windowed(read, { method: 'changes', topic, range, extra: undefined, unit });
100
102
  },
101
- async changes(range) {
102
- const rows = await read.changes(
103
- topic,
104
- range.start.toISOString(),
105
- range.end.toISOString()
106
- );
107
- return seriesRows(rows, unit);
103
+ holds(range, step) {
104
+ return windowed(read, { method: 'holds', topic, range, extra: step, unit });
108
105
  },
109
106
  stream(since, step, callback, clock) {
110
107
  return pollStream({ read, topic, since, step, callback, clock, unit });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Coverage holds of one Postgres metrics topic.
3
+ *
4
+ * $1 topic, $2 start, $3 exclusive end, $4 gap milliseconds.
5
+ *
6
+ * @returns {string} SQL
7
+ */
8
+ export default function holdsSql() {
9
+ return `WITH windowed AS (
10
+ SELECT ts, value FROM metrics
11
+ WHERE topic = $1 AND ts >= $2 AND ts < $3
12
+ ), lagged AS (
13
+ SELECT ts, value,
14
+ LAG(ts) OVER (ORDER BY ts) AS prev_ts,
15
+ LAG(value) OVER (ORDER BY ts) AS prev
16
+ FROM windowed
17
+ ), flagged AS (
18
+ SELECT ts, value,
19
+ CASE WHEN prev IS NULL
20
+ OR value IS DISTINCT FROM prev
21
+ OR (EXTRACT(EPOCH FROM (ts - prev_ts)) * 1000) > $4
22
+ THEN 1 ELSE 0 END AS fresh
23
+ FROM lagged
24
+ ), grouped AS (
25
+ SELECT ts, value, SUM(fresh) OVER (ORDER BY ts) AS grp
26
+ FROM flagged
27
+ )
28
+ SELECT MIN(ts) AS ts, MAX(ts) AS until, MIN(value) AS value
29
+ FROM grouped
30
+ GROUP BY grp
31
+ ORDER BY ts`;
32
+ }
@@ -1,8 +1,19 @@
1
1
  import risesSql from './risesSql.js';
2
2
  import changesSql from './changesSql.js';
3
+ import holdsSql from './holdsSql.js';
3
4
  import queryTopic from './queryTopic.js';
4
5
  import rangeSql from './rangeSql.js';
5
6
 
7
+ async function pollRows(pool, topic, afterIso, untilIso) {
8
+ const result = await pool.query(
9
+ 'SELECT ts, value FROM metrics WHERE topic = $1 AND ts > $2 AND ts <= $3 ORDER BY ts LIMIT 100',
10
+ [topic, afterIso, untilIso]
11
+ );
12
+ return result.rows.map((row) => {
13
+ return { ts: row.ts, value: row.value };
14
+ });
15
+ }
16
+
6
17
  export default function metricsStatePg(pool) {
7
18
  return {
8
19
  async latestForTopic(topic) {
@@ -22,14 +33,16 @@ export default function metricsStatePg(pool) {
22
33
  changesForTopic(topic, startIso, endIso) {
23
34
  return queryTopic(pool, changesSql(), [topic, startIso, endIso]);
24
35
  },
25
- async pollTopic(topic, afterIso, untilIso) {
26
- const result = await pool.query(
27
- 'SELECT ts, value FROM metrics WHERE topic = $1 AND ts > $2 AND ts <= $3 ORDER BY ts LIMIT 100',
28
- [topic, afterIso, untilIso]
29
- );
30
- return result.rows.map((row) => {
31
- return { ts: row.ts, value: row.value };
32
- });
36
+ holdsForTopic(topic, startIso, endIso, stepMs) {
37
+ return queryTopic(pool, holdsSql(), [
38
+ topic,
39
+ startIso,
40
+ endIso,
41
+ Math.max(1, Number(stepMs) || 1000)
42
+ ]);
43
+ },
44
+ pollTopic(topic, afterIso, untilIso) {
45
+ return pollRows(pool, topic, afterIso, untilIso);
33
46
  },
34
47
  async insertRows(items) {
35
48
  const topics = items.map((row) => {
@@ -9,6 +9,10 @@
9
9
  export default async function queryTopic(pool, sql, params) {
10
10
  const result = await pool.query(sql, params);
11
11
  return result.rows.map((row) => {
12
- return { ts: row.ts, value: Number(row.value) };
12
+ const item = { ts: row.ts, value: Number(row.value) };
13
+ if (row.until) {
14
+ item.until = row.until;
15
+ }
16
+ return item;
13
17
  });
14
18
  }
@@ -11,11 +11,15 @@ function parseTimestamp(ts) {
11
11
  */
12
12
  export default function seriesRows(rows, unit) {
13
13
  return rows.map((row) => {
14
- return {
14
+ const point = {
15
15
  timestamp: parseTimestamp(row.ts),
16
16
  value: row.value,
17
17
  unit
18
18
  };
19
+ if (row.until) {
20
+ point.until = parseTimestamp(row.until);
21
+ }
22
+ return point;
19
23
  });
20
24
  }
21
25