@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 +21 -0
- package/README.md +53 -0
- package/index.js +45 -0
- package/package.json +27 -0
- package/src/activeMelting.js +54 -0
- package/src/alert.js +59 -0
- package/src/alerts.js +52 -0
- package/src/clickhouseSensor.js +88 -0
- package/src/completedMelting.js +42 -0
- package/src/event.js +24 -0
- package/src/events.js +51 -0
- package/src/initialized.js +30 -0
- package/src/interval.js +18 -0
- package/src/machineChronology.js +77 -0
- package/src/meltingChronology.js +37 -0
- package/src/meltingMachine.js +53 -0
- package/src/meltingRuleEngine.js +37 -0
- package/src/meltingShop.js +32 -0
- package/src/meltings.js +97 -0
- package/src/monitoredMeltingMachine.js +33 -0
- package/src/plant.js +23 -0
- package/src/pubsub.js +32 -0
- package/src/rule.js +33 -0
- package/src/rules.js +23 -0
- package/src/scyllaSensor.js +53 -0
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
|
+
}
|
package/src/interval.js
ADDED
|
@@ -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
|
+
}
|
package/src/meltings.js
ADDED
|
@@ -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
|
+
}
|