@yarkivaev/scada 1.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # scada
2
+
3
+ SCADA domain objects for industrial plant monitoring.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npm install scada
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```javascript
14
+ import { plant, meltingShop, meltingMachine, meltings, alerts, initialized } from 'scada';
15
+
16
+ const machine = meltingMachine('icht1', sensors, alertHistory);
17
+ const shop = meltingShop('shop1', initialized({ icht1: machine }, Object.values), meltings());
18
+ const factory = plant(initialized({ shop1: shop }, Object.values));
19
+ ```
20
+
21
+ ## Modules
22
+
23
+ ### Plant Hierarchy
24
+ - `plant`
25
+ - `meltingShop`
26
+ - `meltingMachine`
27
+ - `machineChronology`
28
+ - `monitoredMeltingMachine`
29
+
30
+ ### Melting Operations
31
+ - `activeMelting`
32
+ - `completedMelting`
33
+ - `meltings`
34
+ - `meltingChronology`
35
+
36
+ ### Alerting
37
+ - `alert`
38
+ - `acknowledgedAlert`
39
+ - `alerts`
40
+ - `meltingRuleEngine`
41
+
42
+ ### Sensors
43
+ - `scyllaSensor`
44
+ - `clickhouseSensor`
45
+
46
+ ### Utilities
47
+ - `interval`
48
+ - `initialized`
49
+ - `events`
50
+
51
+ ## License
52
+
53
+ MIT
package/index.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * SCADA domain objects for industrial plant monitoring.
3
+ * Provides immutable objects for modeling melting operations,
4
+ * alerting systems, events, and plant hierarchy.
5
+ *
6
+ * @example
7
+ * import { plant, meltingShop, meltingMachine, meltings, alerts, initialized } from 'scada';
8
+ * const shop = meltingShop('shop1', initialized({ m1: machine }, Object.values), meltings());
9
+ * const p = plant(initialized({ shop1: shop }, Object.values));
10
+ */
11
+
12
+ // Plant hierarchy
13
+ export { default as plant } from './src/plant.js';
14
+ export { default as meltingShop } from './src/meltingShop.js';
15
+ export { default as meltingMachine } from './src/meltingMachine.js';
16
+ export { default as machineChronology } from './src/machineChronology.js';
17
+ export { default as monitoredMeltingMachine } from './src/monitoredMeltingMachine.js';
18
+
19
+ // Melting operations
20
+ export { default as activeMelting } from './src/activeMelting.js';
21
+ export { default as completedMelting } from './src/completedMelting.js';
22
+ export { default as meltings } from './src/meltings.js';
23
+ export { default as meltingChronology } from './src/meltingChronology.js';
24
+
25
+ // Events system
26
+ export { default as event } from './src/event.js';
27
+ export { default as events } from './src/events.js';
28
+ export { default as rule } from './src/rule.js';
29
+ export { default as rules } from './src/rules.js';
30
+
31
+ // Rule engine (legacy)
32
+ export { default as meltingRuleEngine } from './src/meltingRuleEngine.js';
33
+
34
+ // Alerting
35
+ export { alert, acknowledgedAlert } from './src/alert.js';
36
+ export { default as alerts } from './src/alerts.js';
37
+
38
+ // Utilities
39
+ export { default as interval } from './src/interval.js';
40
+ export { default as initialized } from './src/initialized.js';
41
+ export { default as pubsub } from './src/pubsub.js';
42
+
43
+ // Sensors
44
+ export { default as scyllaSensor } from './src/scyllaSensor.js';
45
+ export { default as clickhouseSensor } from './src/clickhouseSensor.js';
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@yarkivaev/scada",
3
+ "version": "1.1.0",
4
+ "description": "SCADA domain objects for plant monitoring",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "mocha --exit",
9
+ "coverage": "c8 --all --src src npm test",
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix"
12
+ },
13
+ "devDependencies": {
14
+ "@clickhouse/client": "^1.0.0",
15
+ "@eslint/js": "^9.39.2",
16
+ "c8": "^10.1.3",
17
+ "cassandra-driver": "^4.7.2",
18
+ "eslint": "^9.39.2",
19
+ "globals": "^17.0.0",
20
+ "mocha": "^10.2.0",
21
+ "testcontainers": "^10.13.0"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "src/"
26
+ ]
27
+ }
@@ -0,0 +1,54 @@
1
+ import meltingChronology from './meltingChronology.js';
2
+ import completedMelting from './completedMelting.js';
3
+
4
+ /**
5
+ * In-progress melting session that can be stopped to produce a completed melting.
6
+ * Chronology is bound to machine and derives values from machine's weight history.
7
+ * Notifies callbacks when stopped or updated.
8
+ *
9
+ * @param {string} id - unique melting session identifier
10
+ * @param {object} machine - the melting machine running this session
11
+ * @param {Date} start - when the melting session started
12
+ * @param {function} onStop - callback invoked with completedMelting when stopped
13
+ * @param {function} onUpdate - callback invoked when melting is updated
14
+ * @returns {object} active melting with id, machine, chronology, stop, update methods
15
+ *
16
+ * @example
17
+ * const active = activeMelting('m1', machine, new Date(), onStop, onUpdate);
18
+ * machine.load(500);
19
+ * machine.dispense(480);
20
+ * const completed = active.stop();
21
+ */
22
+ export default function activeMelting(id, machine, start, onStop, onUpdate) {
23
+ return {
24
+ id() {
25
+ return id;
26
+ },
27
+ machine() {
28
+ return machine;
29
+ },
30
+ chronology() {
31
+ return meltingChronology(machine, start, undefined);
32
+ },
33
+ stop() {
34
+ const end = new Date();
35
+ const chron = meltingChronology(machine, start, end);
36
+ const completed = completedMelting(id, machine, chron, onUpdate);
37
+ onStop(completed);
38
+ return completed;
39
+ },
40
+ update(data) {
41
+ const opts = data === undefined ? {} : data;
42
+ const newStart = opts.start === undefined ? start : new Date(opts.start);
43
+ if (opts.end !== undefined) {
44
+ const chron = meltingChronology(machine, newStart, new Date(opts.end));
45
+ const completed = completedMelting(id, machine, chron, onUpdate);
46
+ onStop(completed);
47
+ return completed;
48
+ }
49
+ const updated = activeMelting(id, machine, newStart, onStop, onUpdate);
50
+ onUpdate(updated);
51
+ return updated;
52
+ }
53
+ };
54
+ }
package/src/alert.js ADDED
@@ -0,0 +1,59 @@
1
+ /* eslint-disable max-params */
2
+ /**
3
+ * Immutable alert record with acknowledgment capability.
4
+ * Represents a single actionable item for supervisors.
5
+ *
6
+ * @param {string} id - unique alert identifier
7
+ * @param {string} message - alert description
8
+ * @param {Date} timestamp - when the alert occurred
9
+ * @param {string} object - identifier of the object that raised the alert
10
+ * @param {object} source - optional source event that triggered this alert
11
+ * @param {function} acknowledge - callback to dismiss the alert
12
+ * @returns {object} alert with id, message, timestamp, object, event, acknowledge, acknowledged
13
+ *
14
+ * @example
15
+ * const a = alert('alert-1', 'High voltage', new Date(), 'icht1', srcEvent, ackCallback);
16
+ * a.id; // 'alert-1'
17
+ * a.event; // srcEvent reference
18
+ * a.acknowledged; // false
19
+ * a.acknowledge(); // triggers callback
20
+ */
21
+ export function alert(id, message, timestamp, object, source, acknowledge) {
22
+ return {
23
+ id,
24
+ message,
25
+ timestamp,
26
+ object,
27
+ event: source,
28
+ acknowledge,
29
+ acknowledged: false
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Immutable acknowledged alert record.
35
+ * Represents an alert that has been acknowledged by the user.
36
+ *
37
+ * @param {string} id - unique alert identifier
38
+ * @param {string} message - alert description
39
+ * @param {Date} timestamp - when the alert occurred
40
+ * @param {string} object - identifier of the object that raised the alert
41
+ * @param {object} source - optional source event that triggered this alert
42
+ * @returns {object} acknowledged alert with id, message, timestamp, object, event, acknowledged
43
+ *
44
+ * @example
45
+ * const a = acknowledgedAlert('alert-1', 'High voltage', new Date(), 'icht1', srcEvent);
46
+ * a.id; // 'alert-1'
47
+ * a.event; // srcEvent reference
48
+ * a.acknowledged; // true
49
+ */
50
+ export function acknowledgedAlert(id, message, timestamp, object, source) {
51
+ return {
52
+ id,
53
+ message,
54
+ timestamp,
55
+ object,
56
+ event: source,
57
+ acknowledged: true
58
+ };
59
+ }
package/src/alerts.js ADDED
@@ -0,0 +1,52 @@
1
+ import pubsub from './pubsub.js';
2
+
3
+ /**
4
+ * History of alert occurrences with filtering support.
5
+ * Creates and stores alerts, replaces with acknowledged version on acknowledge.
6
+ * Generates unique IDs for each alert.
7
+ *
8
+ * @param {function} alert - factory function to create alerts
9
+ * @param {function} acknowledgedAlert - factory function to create acknowledged alerts
10
+ * @returns {object} alerts history with trigger, all, find, stream methods
11
+ *
12
+ * @example
13
+ * const history = alerts(alert, acknowledgedAlert);
14
+ * const a = history.trigger('High voltage', new Date(), 'icht1');
15
+ * a.id; // 'alert-0'
16
+ * a.acknowledge(); // replaces with acknowledged version
17
+ * history.trigger('From event', new Date(), 'icht1', sourceEvent); // with event
18
+ * history.stream((evt) => console.log(evt)); // subscribe to events
19
+ */
20
+ export default function alerts(alert, acknowledgedAlert) {
21
+ const items = [];
22
+ const bus = pubsub();
23
+ let counter = 0;
24
+ return {
25
+ trigger(message, timestamp, object, source) {
26
+ const id = `alert-${counter}`;
27
+ counter += 1;
28
+ const index = items.length;
29
+ const created = alert(id, message, timestamp, object, source, () => {
30
+ const acknowledged = acknowledgedAlert(id, message, timestamp, object, source);
31
+ items[index] = acknowledged;
32
+ bus.emit({ type: 'acknowledged', alert: acknowledged });
33
+ });
34
+ items.push(created);
35
+ bus.emit({ type: 'created', alert: created });
36
+ return created;
37
+ },
38
+ all(...filters) {
39
+ return items.filter((a) => {
40
+ return filters.every((filter) => {
41
+ return filter(a);
42
+ });
43
+ });
44
+ },
45
+ find(id) {
46
+ return items.find((a) => {
47
+ return a.id === id;
48
+ });
49
+ },
50
+ stream: bus.stream
51
+ };
52
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Sensor backed by ClickHouse metrics table with downsampling.
3
+ *
4
+ * Reads sensor measurements from scada.metrics table
5
+ * and provides real-time streaming via polling.
6
+ * Supports time-based downsampling using ClickHouse aggregation.
7
+ * Returns value of 0 for missing data to comply with no-null-return principle.
8
+ *
9
+ * @param {object} connection - ClickHouse connection with query method
10
+ * @param {string} topic - Metric topic in format '{machine}/{sensor}'
11
+ * @param {string} displayName - Human-readable sensor name
12
+ * @param {string} unit - Measurement unit (e.g., 'V', 'cos(φ)')
13
+ * @returns {object} sensor with name, current, measurements and stream methods
14
+ *
15
+ * @example
16
+ * const sensor = clickhouseSensor(conn, 'icht1/voltage', 'Voltage', 'V');
17
+ * sensor.name(); // 'Voltage'
18
+ * await sensor.current(); // { timestamp, value, unit }
19
+ * await sensor.measurements({ start, end }, 60000); // downsampled to 1-minute intervals
20
+ * sensor.stream(since, 1000, callback); // live stream
21
+ */
22
+ function formatDateTime(date) {
23
+ return date.toISOString().replace('Z', '').replace('T', ' ');
24
+ }
25
+
26
+ // eslint-disable-next-line max-lines-per-function
27
+ export default function clickhouseSensor(connection, topic, displayName, unit) {
28
+ return {
29
+ name() {
30
+ return displayName;
31
+ },
32
+ async current() {
33
+ const rows = await connection.query(
34
+ `SELECT ts, value FROM scada.metrics
35
+ WHERE topic = {topic:String}
36
+ ORDER BY ts DESC LIMIT 1`,
37
+ { topic }
38
+ );
39
+ if (rows.length === 0) {
40
+ return { timestamp: new Date(), value: 0, unit };
41
+ }
42
+ return { timestamp: new Date(rows[0].ts), value: rows[0].value, unit };
43
+ },
44
+ async measurements(range, step) {
45
+ const seconds = Math.max(1, Math.floor(step / 1000));
46
+ const rows = await connection.query(
47
+ `SELECT
48
+ toStartOfInterval(ts, INTERVAL ${seconds} SECOND) as ts,
49
+ avg(value) as value
50
+ FROM scada.metrics
51
+ WHERE topic = {topic:String}
52
+ AND ts >= toStartOfInterval({start:DateTime64(3)}, INTERVAL ${seconds} SECOND)
53
+ AND ts <= {end:DateTime64(3)}
54
+ GROUP BY ts
55
+ ORDER BY ts`,
56
+ {
57
+ topic,
58
+ start: formatDateTime(range.start),
59
+ end: formatDateTime(range.end)
60
+ }
61
+ );
62
+ return rows.map((row) => {
63
+ return { timestamp: new Date(row.ts), value: row.value, unit };
64
+ });
65
+ },
66
+ stream(since, step, callback) {
67
+ let lastTs = since;
68
+ const timer = setInterval(async () => {
69
+ const rows = await connection.query(
70
+ `SELECT ts, value FROM scada.metrics
71
+ WHERE topic = {topic:String} AND ts > {since:DateTime64(3)}
72
+ ORDER BY ts LIMIT 100`,
73
+ { topic, since: formatDateTime(lastTs) }
74
+ );
75
+ rows.forEach((row) => {
76
+ const timestamp = new Date(row.ts);
77
+ callback({ timestamp, value: row.value, unit });
78
+ lastTs = timestamp;
79
+ });
80
+ }, step);
81
+ return {
82
+ cancel() {
83
+ clearInterval(timer);
84
+ }
85
+ };
86
+ }
87
+ };
88
+ }
@@ -0,0 +1,42 @@
1
+ import meltingChronology from './meltingChronology.js';
2
+
3
+ /**
4
+ * Immutable record of a finished melting session.
5
+ * Chronology is bound to machine and derives values from machine's weight history.
6
+ * Notifies callback when updated.
7
+ *
8
+ * @param {string} id - unique melting session identifier
9
+ * @param {object} machine - the machine that performed this melting
10
+ * @param {object} chron - chronology bound to machine with start/end times
11
+ * @param {function} onUpdate - callback invoked when melting is updated
12
+ * @returns {object} completed melting with id, machine, chronology, update methods
13
+ *
14
+ * @example
15
+ * const completed = completedMelting('m1', machine, chron, onUpdate);
16
+ * completed.id(); // 'm1'
17
+ * completed.chronology().get().start; // start time
18
+ * completed.chronology().get().end; // end time
19
+ */
20
+ export default function completedMelting(id, machine, chron, onUpdate) {
21
+ return {
22
+ id() {
23
+ return id;
24
+ },
25
+ machine() {
26
+ return machine;
27
+ },
28
+ chronology() {
29
+ return chron;
30
+ },
31
+ update(data) {
32
+ const opts = data === undefined ? {} : data;
33
+ const original = chron.get();
34
+ const newStart = opts.start === undefined ? original.start : new Date(opts.start);
35
+ const newEnd = opts.end === undefined ? original.end : new Date(opts.end);
36
+ const updated = meltingChronology(machine, newStart, newEnd);
37
+ const result = completedMelting(id, machine, updated, onUpdate);
38
+ onUpdate(result);
39
+ return result;
40
+ }
41
+ };
42
+ }
package/src/event.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Immutable event record with properties and labels.
3
+ * Represents a single occurrence in the system log.
4
+ *
5
+ * @param {string} id - unique event identifier
6
+ * @param {Date} timestamp - when the event occurred
7
+ * @param {object} properties - event-specific data
8
+ * @param {string[]} labels - classification tags for the event
9
+ * @returns {object} event with id, timestamp, properties, labels methods
10
+ *
11
+ * @example
12
+ * const e = event('ev-1', new Date(), {machine: 'icht1', voltage: 340}, ['sensor']);
13
+ * e.id(); // 'ev-1'
14
+ * e.labels(); // ['sensor']
15
+ * e.properties(); // {machine: 'icht1', voltage: 340}
16
+ */
17
+ export default function event(id, timestamp, properties, labels) {
18
+ return {
19
+ id: () => {return id},
20
+ timestamp: () => {return timestamp},
21
+ properties: () => {return properties},
22
+ labels: () => {return [...labels]}
23
+ };
24
+ }
package/src/events.js ADDED
@@ -0,0 +1,51 @@
1
+ import pubsub from './pubsub.js';
2
+
3
+ /**
4
+ * Append-only event log for system occurrences.
5
+ * Events are immutable and never modified after creation.
6
+ * Optionally evaluates rules when events are created.
7
+ *
8
+ * @param {function} factory - factory function to create event records
9
+ * @param {object} rules - optional rules collection to evaluate on create
10
+ * @returns {object} log with create, all, find, stream methods
11
+ *
12
+ * @example
13
+ * const log = events(event, rules([rule1, rule2]));
14
+ * const e = log.create(new Date(), {machine: 'icht1'}, ['sensor']);
15
+ * log.all(); // returns all events
16
+ * log.all((e) => e.labels().includes('sensor')); // filter events
17
+ * log.find('ev-0'); // find by id
18
+ * log.stream((evt) => console.log(evt)); // subscribe to new events
19
+ */
20
+ export default function events(factory, rules) {
21
+ const items = [];
22
+ const bus = pubsub();
23
+ let counter = 0;
24
+ return {
25
+ create(timestamp, properties, labels) {
26
+ const id = `ev-${counter}`;
27
+ counter += 1;
28
+ const tags = labels === undefined ? [] : labels;
29
+ const e = factory(id, timestamp, properties, tags);
30
+ items.push(e);
31
+ bus.emit({ type: 'created', event: e });
32
+ if (rules !== undefined) {
33
+ rules.evaluate({ event: e });
34
+ }
35
+ return e;
36
+ },
37
+ all(...filters) {
38
+ return items.filter((e) => {
39
+ return filters.every((filter) => {
40
+ return filter(e);
41
+ });
42
+ });
43
+ },
44
+ find(id) {
45
+ return items.find((e) => {
46
+ return e.id() === id;
47
+ });
48
+ },
49
+ stream: bus.stream
50
+ };
51
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Wrapper that provides batch initialization for a collection.
3
+ * init() initializes items once, get() returns the collection.
4
+ *
5
+ * @param {object} collection - object or array containing items with init method
6
+ * @param {function} toList - function that extracts array of items from collection
7
+ * @returns {object} wrapper with init() and get() methods
8
+ *
9
+ * @example
10
+ * const machines = initialized({ icht1: machine }, Object.values);
11
+ * machines.init(); // initializes all items once
12
+ * machines.get().icht1; // access by key
13
+ * Object.values(machines.get()); // iterate
14
+ */
15
+ export default function initialized(collection, toList) {
16
+ let done = false;
17
+ return {
18
+ init() {
19
+ if (!done) {
20
+ toList(collection).forEach((item) => {
21
+ item.init();
22
+ });
23
+ done = true;
24
+ }
25
+ },
26
+ get() {
27
+ return collection;
28
+ }
29
+ };
30
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Periodic action executor wrapping setInterval.
3
+ *
4
+ * @param {number} period - interval in milliseconds
5
+ * @param {function} action - callback to execute periodically
6
+ * @returns {object} interval with start method
7
+ *
8
+ * @example
9
+ * const i = interval(1000, function() { console.log('tick'); });
10
+ * i.start();
11
+ */
12
+ export default function interval(period, action) {
13
+ return {
14
+ start() {
15
+ setInterval(action, period);
16
+ }
17
+ };
18
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Immutable chronology that queries shared machine weight history.
3
+ * Supports point-in-time and range queries via query object.
4
+ * Optionally includes sensor readings in current snapshot.
5
+ *
6
+ * @param {number} initial - initial weight when machine was created
7
+ * @param {array} history - shared mutable array of { timestamp, weight } entries
8
+ * @param {object} sensors - optional sensor objects keyed by name with current() method
9
+ * @returns {object} chronology with get method
10
+ *
11
+ * @example
12
+ * const history = [{ timestamp: new Date(), weight: 0 }];
13
+ * const chron = machineChronology(0, history, { voltage: sensor });
14
+ * chron.get({ type: 'current' }).weight; // current weight
15
+ * chron.get({ type: 'current' }).voltage; // current voltage reading
16
+ * chron.get({ type: 'point', at: someDate }).weight; // weight at someDate
17
+ * chron.get({ type: 'range', from: start, to: end }); // { loaded, dispensed }
18
+ */
19
+ // eslint-disable-next-line max-lines-per-function
20
+ export default function machineChronology(initial, history, sensors) {
21
+ async function current() {
22
+ const result = { weight: history[history.length - 1].weight };
23
+ if (sensors) {
24
+ const keys = Object.keys(sensors);
25
+ const values = await Promise.all(keys.map((key) => {
26
+ return sensors[key].current();
27
+ }));
28
+ keys.forEach((key, i) => { result[key] = values[i]; });
29
+ }
30
+ return result;
31
+ }
32
+ function point(datetime) {
33
+ const time = new Date(datetime).getTime();
34
+ for (let i = history.length - 1; i >= 0; i -= 1) {
35
+ if (history[i].timestamp.getTime() <= time) {
36
+ return { weight: history[i].weight };
37
+ }
38
+ }
39
+ return { weight: initial };
40
+ }
41
+ function range(from, to) {
42
+ const start = new Date(from).getTime();
43
+ const end = new Date(to).getTime();
44
+ const entries = history.filter((entry) => {
45
+ const time = entry.timestamp.getTime();
46
+ return time >= start && time < end;
47
+ });
48
+ let loaded = 0;
49
+ let dispensed = 0;
50
+ const base = point(from).weight;
51
+ let previous = base;
52
+ for (const entry of entries) {
53
+ const delta = entry.weight - previous;
54
+ if (delta > 0) {
55
+ loaded += delta;
56
+ } else if (delta < 0) {
57
+ dispensed += Math.abs(delta);
58
+ }
59
+ previous = entry.weight;
60
+ }
61
+ return { loaded, dispensed };
62
+ }
63
+ return {
64
+ get(query) {
65
+ if (query.type === 'current') {
66
+ return current();
67
+ }
68
+ if (query.type === 'point') {
69
+ return point(query.at);
70
+ }
71
+ if (query.type === 'range') {
72
+ return range(query.from, query.to);
73
+ }
74
+ throw new Error(`Unknown query type: ${query.type}`);
75
+ }
76
+ };
77
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Melting chronology bound to a machine.
3
+ * Derives values from machine's weight history during the melting window.
4
+ *
5
+ * @param {object} machine - melting machine with chronology method
6
+ * @param {Date} start - melting start time
7
+ * @param {Date} end - melting end time (undefined for active meltings)
8
+ * @returns {object} chronology with get method
9
+ *
10
+ * @example
11
+ * const chron = meltingChronology(machine, startTime, endTime);
12
+ * chron.get(); // { start, end, initial, weight, loaded, dispensed }
13
+ * chron.get(someTime); // state at specific time
14
+ */
15
+ export default function meltingChronology(machine, start, end) {
16
+ return {
17
+ get(datetime) {
18
+ const machineChron = machine.chronology();
19
+ const defaultTime = end === undefined ? new Date() : end;
20
+ const queryTime = datetime === undefined ? defaultTime : datetime;
21
+ const initial = machineChron.get({ type: 'point', at: start }).weight;
22
+ const current = machineChron.get({ type: 'point', at: queryTime }).weight;
23
+ const range = machineChron.get({ type: 'range', from: start, to: queryTime });
24
+ const result = {
25
+ start,
26
+ initial,
27
+ weight: current,
28
+ loaded: range.loaded,
29
+ dispensed: range.dispensed
30
+ };
31
+ if (end !== undefined) {
32
+ result.end = end;
33
+ }
34
+ return result;
35
+ }
36
+ };
37
+ }
@@ -0,0 +1,53 @@
1
+ import machineChronology from './machineChronology.js';
2
+
3
+ /**
4
+ * Melting machine that tracks metal weight history and sensor measurements.
5
+ * Supports loading and dispensing metal during melting operations.
6
+ * Weight history is tracked for historical queries.
7
+ *
8
+ * @param {string} name - unique machine identifier
9
+ * @param {object} sensors - object containing sensor instances
10
+ * @param {object} alerts - centralized alerts collection
11
+ * @param {number} initial - initial weight (defaults to 0)
12
+ * @returns {object} machine with name, sensors, alerts, chronology, load, dispense
13
+ *
14
+ * @example
15
+ * const machine = meltingMachine('icht1', { voltage: voltageSensor() }, alerts());
16
+ * machine.load(500);
17
+ * machine.chronology().get().weight; // 500
18
+ * machine.chronology().get(pastDate).weight; // weight at pastDate
19
+ */
20
+ export default function meltingMachine(name, sensors, alerts, initial) {
21
+ const start = initial === undefined ? 0 : initial;
22
+ const history = [{ timestamp: new Date(), weight: start }];
23
+ let current = start;
24
+ return {
25
+ name() {
26
+ return name;
27
+ },
28
+ sensors,
29
+ alerts() {
30
+ return alerts.all((item) => {
31
+ return item.object === name;
32
+ });
33
+ },
34
+ chronology() {
35
+ return machineChronology(start, history, sensors);
36
+ },
37
+ load(w) {
38
+ current += w;
39
+ history.push({ timestamp: new Date(), weight: current });
40
+ },
41
+ dispense(w) {
42
+ current -= w;
43
+ history.push({ timestamp: new Date(), weight: current });
44
+ },
45
+ reset(w) {
46
+ current = w;
47
+ history.push({ timestamp: new Date(), weight: current });
48
+ },
49
+ init() {
50
+ return this;
51
+ }
52
+ };
53
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Expert system that evaluates measurements and issues alerts.
3
+ * Checks voltage and power factor thresholds.
4
+ *
5
+ * @param {function} issue - callback to issue alerts with message and timestamp
6
+ * @returns {object} rule engine with evaluate method
7
+ *
8
+ * @example
9
+ * const engine = meltingRuleEngine(function(msg, ts) { console.log(msg); });
10
+ * engine.evaluate({ voltage: { value: 340 }, cosphi: { value: 0.9 } });
11
+ */
12
+ export default function meltingRuleEngine(issue) {
13
+ return {
14
+ evaluate(snapshot) {
15
+ const timestamp = new Date();
16
+ const {voltage} = snapshot;
17
+ const {cosphi} = snapshot;
18
+ if (voltage && voltage.value < 350) {
19
+ issue(`Critical low voltage: ${ voltage.value.toFixed(1) }V`, timestamp);
20
+ } else if (voltage && voltage.value < 360) {
21
+ issue(`Low voltage: ${ voltage.value.toFixed(1) }V`, timestamp);
22
+ } else if (voltage && voltage.value > 410) {
23
+ issue(`Critical high voltage: ${ voltage.value.toFixed(1) }V`, timestamp);
24
+ } else if (voltage && voltage.value > 400) {
25
+ issue(`High voltage: ${ voltage.value.toFixed(1) }V`, timestamp);
26
+ }
27
+ if (cosphi && cosphi.value < 0.7) {
28
+ issue(`Critical low power factor: ${ cosphi.value.toFixed(2)}`, timestamp);
29
+ } else if (cosphi && cosphi.value < 0.8) {
30
+ issue(`Low power factor: ${ cosphi.value.toFixed(2)}`, timestamp);
31
+ }
32
+ if (voltage && cosphi && voltage.value < 370 && cosphi.value < 0.8) {
33
+ issue('Power quality issue detected', timestamp);
34
+ }
35
+ }
36
+ };
37
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Melting shop containing melting machines and their meltings.
3
+ * Provides initialization for all contained machines.
4
+ *
5
+ * @param {string} name - unique identifier for the shop
6
+ * @param {object} meltingMachines - initialized wrapper of melting machines
7
+ * @param {object} meltings - collection managing melting sessions
8
+ * @param {object} alerts - alerts collection for the shop
9
+ * @param {object} events - events collection shared from plant
10
+ * @returns {object} shop with name, machines, meltings, alerts, events properties and init method
11
+ *
12
+ * @example
13
+ * const shop = meltingShop('shop1', initialized({ m1: machine }, Object.values), meltings(), alerts(), events());
14
+ * shop.name(); // 'shop1'
15
+ * shop.machines.init().m1; // access machine by key
16
+ * shop.events.create(new Date(), {}, ['label']);
17
+ * shop.init();
18
+ */
19
+ export default function meltingShop(name, meltingMachines, meltings, alerts, events) {
20
+ return {
21
+ name() {
22
+ return name;
23
+ },
24
+ machines: meltingMachines,
25
+ meltings,
26
+ alerts,
27
+ events,
28
+ init() {
29
+ meltingMachines.init();
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,97 @@
1
+ /* eslint-disable max-lines-per-function, max-statements */
2
+ import pubsub from './pubsub.js';
3
+ import meltingChronology from './meltingChronology.js';
4
+ import activeMelting from './activeMelting.js';
5
+ import completedMelting from './completedMelting.js';
6
+
7
+ /**
8
+ * Collection of melting sessions associated with machines.
9
+ * Uses add() for creating both active and completed meltings.
10
+ * Uses query() for filtering and streaming meltings.
11
+ *
12
+ * @returns {object} collection with add, query methods
13
+ *
14
+ * @example
15
+ * const list = meltings();
16
+ * const active = list.add(machine, {}); // creates active melting
17
+ * const completed = list.add(machine, { start, end }); // creates completed melting
18
+ * list.query(); // returns all completed meltings
19
+ * list.query({ machine }); // returns meltings for machine
20
+ * list.query({ id: 'm1' }); // returns melting by id
21
+ * list.query({ stream: callback }); // subscribe to events
22
+ */
23
+ export default function meltings() {
24
+ const items = [];
25
+ const bus = pubsub();
26
+ let counter = 0;
27
+ function onUpdate(id, updated) {
28
+ const item = items.find((i) => {
29
+ return i.melting.id() === id;
30
+ });
31
+ if (item) {
32
+ item.melting = updated;
33
+ bus.emit({ type: 'updated', melting: updated });
34
+ }
35
+ }
36
+ return {
37
+ add(machine, data) {
38
+ const opts = data === undefined ? {} : data;
39
+ if (opts.end === undefined) {
40
+ const existing = items.find((i) => {
41
+ const chron = i.melting.chronology().get();
42
+ return i.machine === machine && chron.end === undefined;
43
+ });
44
+ if (existing) {
45
+ return existing.melting;
46
+ }
47
+ }
48
+ counter += 1;
49
+ const id = `m${counter}`;
50
+ if (opts.end !== undefined) {
51
+ const chron = meltingChronology(machine, new Date(opts.start), new Date(opts.end));
52
+ const completed = completedMelting(id, machine, chron, (updated) => {
53
+ onUpdate(id, updated);
54
+ });
55
+ items.push({ machine, melting: completed });
56
+ bus.emit({ type: 'completed', melting: completed });
57
+ return completed;
58
+ }
59
+ const start = opts.start === undefined ? new Date() : new Date(opts.start);
60
+ const item = { machine, melting: undefined };
61
+ items.push(item);
62
+ const active = activeMelting(id, machine, start, (completed) => {
63
+ item.melting = completed;
64
+ bus.emit({ type: 'completed', melting: completed });
65
+ }, (updated) => {
66
+ onUpdate(id, updated);
67
+ });
68
+ item.melting = active;
69
+ bus.emit({ type: 'started', melting: active });
70
+ return active;
71
+ },
72
+ query(options) {
73
+ const opts = options === undefined ? {} : options;
74
+ if (opts.stream !== undefined) {
75
+ return bus.stream(opts.stream);
76
+ }
77
+ if (opts.id !== undefined) {
78
+ const item = items.find((i) => {
79
+ return i.melting.id() === opts.id;
80
+ });
81
+ return item === undefined ? undefined : item.melting;
82
+ }
83
+ if (opts.machine !== undefined) {
84
+ return items.filter((i) => {
85
+ return i.machine === opts.machine;
86
+ }).map((i) => {
87
+ return i.melting;
88
+ });
89
+ }
90
+ return items.filter((i) => {
91
+ return i.melting.chronology().get().end !== undefined;
92
+ }).map((i) => {
93
+ return i.melting;
94
+ });
95
+ }
96
+ };
97
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Melting machine wrapper that monitors measurements and generates alerts.
3
+ * Periodically evaluates sensor readings using a rule engine via chronology.
4
+ *
5
+ * @param {object} machine - the melting machine to monitor
6
+ * @param {object} ruleEngine - engine that evaluates measurements and triggers alerts
7
+ * @param {function} interval - factory to create periodic intervals
8
+ * @returns {object} monitored machine with name, sensors, alerts, chronology, init methods
9
+ *
10
+ * @example
11
+ * const monitored = monitoredMeltingMachine(machine, ruleEngine(), interval);
12
+ * monitored.init(); // starts periodic monitoring
13
+ */
14
+ export default function monitoredMeltingMachine(machine, ruleEngine, interval) {
15
+ return {
16
+ name() {
17
+ return machine.name();
18
+ },
19
+ sensors: machine.sensors,
20
+ alerts() {
21
+ return machine.alerts();
22
+ },
23
+ chronology() {
24
+ return machine.chronology();
25
+ },
26
+ init() {
27
+ interval(1000, async () => {
28
+ const snapshot = await machine.chronology().get({ type: 'current' });
29
+ ruleEngine.evaluate(snapshot);
30
+ }).start();
31
+ }
32
+ };
33
+ }
package/src/plant.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Top-level plant structure containing melting shops.
3
+ * Provides initialization for all contained shops.
4
+ *
5
+ * @param {object} shops - initialized list of melting shops
6
+ * @param {object} events - events collection shared by all shops
7
+ * @returns {object} plant with shops, events properties and init method
8
+ *
9
+ * @example
10
+ * const p = plant(initializedList(shop1, shop2), events(event, rules));
11
+ * p.shops.list(); // [shop1, shop2]
12
+ * p.events.create(new Date(), {}, ['label']);
13
+ * p.init();
14
+ */
15
+ export default function plant(shops, events) {
16
+ return {
17
+ shops,
18
+ events,
19
+ init() {
20
+ shops.init();
21
+ },
22
+ };
23
+ }
package/src/pubsub.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Simple publish-subscribe mechanism for event distribution.
3
+ * Provides emit() to publish and stream() to subscribe.
4
+ * Abstraction point for future message broker integration.
5
+ *
6
+ * @returns {object} bus with emit() and stream() methods
7
+ *
8
+ * @example
9
+ * const bus = pubsub();
10
+ * const sub = bus.stream((e) => console.log(e));
11
+ * bus.emit({ type: 'created', data: {...} });
12
+ * sub.cancel();
13
+ */
14
+ export default function pubsub() {
15
+ const subscribers = [];
16
+ return {
17
+ emit(event) {
18
+ subscribers.forEach((cb) => {
19
+ cb(event);
20
+ });
21
+ },
22
+ stream(callback) {
23
+ subscribers.push(callback);
24
+ return {
25
+ cancel() {
26
+ const index = subscribers.indexOf(callback);
27
+ subscribers.splice(index, 1);
28
+ }
29
+ };
30
+ }
31
+ };
32
+ }
package/src/rule.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Unified trigger-action mapping for event processing.
3
+ * Evaluates context and executes action when trigger matches.
4
+ *
5
+ * @param {function} trigger - predicate that receives context and returns boolean
6
+ * @param {function} action - callback executed when trigger returns true
7
+ * @returns {object} rule with evaluate method
8
+ *
9
+ * @example
10
+ * // Sensor rule - executes on voltage threshold
11
+ * const r1 = rule(
12
+ * (ctx) => ctx.sensor && ctx.sensor.voltage < 350,
13
+ * (ctx) => alerts.trigger('Low voltage')
14
+ * );
15
+ *
16
+ * // Event rule - handles labeled events
17
+ * const r2 = rule(
18
+ * (ctx) => ctx.event && ctx.event.labels().includes('melting-start'),
19
+ * (ctx) => meltings.add(ctx.event.properties().machine)
20
+ * );
21
+ *
22
+ * r1.evaluate({sensor: {voltage: 340}}); // triggers action
23
+ * r2.evaluate({event: e}); // triggers action if labels match
24
+ */
25
+ export default function rule(trigger, action) {
26
+ return {
27
+ evaluate(context) {
28
+ if (trigger(context)) {
29
+ action(context);
30
+ }
31
+ }
32
+ };
33
+ }
package/src/rules.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Collection of rules with unified evaluation.
3
+ * Evaluates all rules against provided context.
4
+ *
5
+ * @param {object[]} list - array of rule objects with evaluate method
6
+ * @returns {object} rules collection with evaluate and all methods
7
+ *
8
+ * @example
9
+ * const rs = rules([rule1, rule2, rule3]);
10
+ * rs.evaluate({sensor: {voltage: 340}}); // evaluates all rules
11
+ * rs.evaluate({event: e}); // evaluates all rules against event
12
+ * rs.all(); // returns array of all rules
13
+ */
14
+ export default function rules(list) {
15
+ return {
16
+ evaluate(context) {
17
+ list.forEach((item) => {
18
+ item.evaluate(context);
19
+ });
20
+ },
21
+ all: () => {return [...list]}
22
+ };
23
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Sensor backed by ScyllaDB metrics table.
3
+ *
4
+ * Reads sensor measurements from scada.metrics table
5
+ * and provides real-time streaming via polling.
6
+ *
7
+ * @param {object} connection - ScyllaDB connection with query method
8
+ * @param {string} topic - Metric topic in format '{machine}/{sensor}'
9
+ * @param {string} displayName - Human-readable sensor name
10
+ * @param {string} unit - Measurement unit (e.g., 'V', 'cos(φ)')
11
+ * @returns {object} sensor with name, measurements and stream methods
12
+ *
13
+ * @example
14
+ * const sensor = scyllaSensor(conn, 'icht1/voltage', 'Voltage', 'V');
15
+ * sensor.name(); // 'Voltage'
16
+ * await sensor.measurements({ start, end }); // array of readings
17
+ * sensor.stream(since, 1000, callback); // live stream
18
+ */
19
+ export default function scyllaSensor(connection, topic, displayName, unit) {
20
+ return {
21
+ name() {
22
+ return displayName;
23
+ },
24
+ async measurements(range, step) {
25
+ void step;
26
+ const rows = await connection.query(
27
+ 'SELECT ts, value FROM scada.metrics WHERE topic = ? AND ts >= ? AND ts <= ?',
28
+ [topic, range.start, range.end]
29
+ );
30
+ return rows.map((row) => {
31
+ return { timestamp: row.ts, value: row.value, unit };
32
+ });
33
+ },
34
+ stream(since, step, callback) {
35
+ let lastTs = since;
36
+ const timer = setInterval(async () => {
37
+ const rows = await connection.query(
38
+ 'SELECT ts, value FROM scada.metrics WHERE topic = ? AND ts > ? LIMIT 100',
39
+ [topic, lastTs]
40
+ );
41
+ rows.forEach((row) => {
42
+ callback({ timestamp: row.ts, value: row.value, unit });
43
+ lastTs = row.ts;
44
+ });
45
+ }, step);
46
+ return {
47
+ cancel() {
48
+ clearInterval(timer);
49
+ }
50
+ };
51
+ }
52
+ };
53
+ }