Compare commits
13 Commits
fix/valida
...
9a998191cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a998191cd | ||
|
|
94bcc90b4b | ||
|
|
a516c2b2b6 | ||
|
|
4b6250cc42 | ||
|
|
35f648f64e | ||
|
|
4252292ae1 | ||
|
|
693517cc8f | ||
|
|
086e5fe751 | ||
|
|
29b78a3f9b | ||
|
|
43f69066af | ||
|
|
e50be2ee66 | ||
|
|
75d16c620a | ||
|
|
024db5533a |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Local stub generated by `npm install` in the submodule directory.
|
||||||
|
# generalFunctions has no production deps of its own.
|
||||||
|
package-lock.json
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
100
|
100
|
||||||
],
|
],
|
||||||
"y": [
|
"y": [
|
||||||
52.14679487594751,
|
11.142207365162072,
|
||||||
20.746724065725342,
|
20.746724065725342,
|
||||||
31.960270693111905,
|
31.960270693111905,
|
||||||
45.6989826531509,
|
45.6989826531509,
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
"y": [
|
"y": [
|
||||||
8.219999984177646,
|
8.219999984177646,
|
||||||
13.426327986363882,
|
13.426327986363882,
|
||||||
57.998168647814666,
|
25.971821741448165,
|
||||||
42.997354839160536,
|
42.997354839160536,
|
||||||
64.33911122026377
|
64.33911122026377
|
||||||
]
|
]
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
"y": [
|
"y": [
|
||||||
8.219999984177646,
|
8.219999984177646,
|
||||||
13.426327986363882,
|
13.426327986363882,
|
||||||
53.35067019159144,
|
25.288156424842576,
|
||||||
42.48429874246399,
|
42.48429874246399,
|
||||||
64.03769740244357
|
64.03769740244357
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class ConfigManager {
|
|||||||
functionality: {
|
functionality: {
|
||||||
softwareType: nodeName.toLowerCase(),
|
softwareType: nodeName.toLowerCase(),
|
||||||
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
||||||
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
|
distance: uiConfig.hasDistance ? uiConfig.distance : null
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
process: uiConfig.processOutputFormat || 'process',
|
process: uiConfig.processOutputFormat || 'process',
|
||||||
|
|||||||
@@ -411,6 +411,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mode": {
|
||||||
|
"current": {
|
||||||
|
"default": "analog",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "analog",
|
||||||
|
"description": "Single-scalar input mode (classic 4-20mA / PLC style). msg.payload is a number; the node runs one offset/scaling/smoothing/outlier pipeline and emits one MeasurementContainer slot."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "digital",
|
||||||
|
"description": "Multi-channel input mode (MQTT / IoT JSON style). msg.payload is an object keyed by channel names declared under config.channels; the node routes each key through its own pipeline and emits N slots from one input message."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Selects how incoming msg.payload is interpreted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"default": [],
|
||||||
|
"rules": {
|
||||||
|
"type": "array",
|
||||||
|
"itemType": "object",
|
||||||
|
"minLength": 0,
|
||||||
|
"description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode."
|
||||||
|
}
|
||||||
|
},
|
||||||
"outlierDetection": {
|
"outlierDetection": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"default": false,
|
"default": false,
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
"description": "The default flow unit used for reporting station throughput."
|
"description": "The default flow unit used for reporting station throughput."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"flowThreshold": {
|
||||||
|
"default": 0.0001,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"description": "Flow dead-band in m3/s below which the station treats net flow as steady."
|
||||||
|
}
|
||||||
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"logLevel": {
|
"logLevel": {
|
||||||
"default": "info",
|
"default": "info",
|
||||||
@@ -127,6 +135,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"output": {
|
||||||
|
"process": {
|
||||||
|
"default": "process",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "process",
|
||||||
|
"description": "Delta-compressed process message."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "json",
|
||||||
|
"description": "JSON payload."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "csv",
|
||||||
|
"description": "CSV-formatted payload."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Format of the process payload emitted on output port 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dbase": {
|
||||||
|
"default": "influxdb",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "influxdb",
|
||||||
|
"description": "InfluxDB telemetry payload."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "json",
|
||||||
|
"description": "JSON payload."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "csv",
|
||||||
|
"description": "CSV-formatted payload."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Format of the telemetry payload emitted on output port 1."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
"uuid": {
|
"uuid": {
|
||||||
"default": null,
|
"default": null,
|
||||||
@@ -235,23 +287,23 @@
|
|||||||
"description": "Unit used for level related setpoints and thresholds."
|
"description": "Unit used for level related setpoints and thresholds."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heightInlet": {
|
"inflowLevel": {
|
||||||
"default": 2,
|
"default": 2,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Height of the inlet pipe measured from the basin floor (m)."
|
"description": "Bottom/invert height of the inlet pipe measured from the basin floor (m)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heightOutlet": {
|
"outflowLevel": {
|
||||||
"default": 0.2,
|
"default": 0.2,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Height of the outlet pipe measured from the basin floor (m)."
|
"description": "Top height of the outlet or pump-suction pipe measured from the basin floor (m)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heightOverflow": {
|
"overflowLevel": {
|
||||||
"default": 2.5,
|
"default": 2.5,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@@ -433,36 +485,86 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"levelbased": {
|
"levelbased": {
|
||||||
|
"minLevel": {
|
||||||
|
"default": 1,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"description": "Below this level the MGC shuts down all pumps (unconditional stop). Between minLevel and the active ramp start, demand is held at 0 %."
|
||||||
|
}
|
||||||
|
},
|
||||||
"startLevel": {
|
"startLevel": {
|
||||||
"default": 1,
|
"default": 1,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "start of pump / group when level reaches this in meters starting from bottom."
|
"description": "Pump-on threshold and ramp foot. Below this level demand is 0 %; at or above it demand scales 0 → 100 % across [startLevel, maxLevel] using the configured curve (linear or log). When enableShiftedRamp is on, this also serves as the bottom of the held-then-ramp curve during draining."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stopLevel": {
|
"stopLevel": {
|
||||||
"default": 1,
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "stop of pump / group when level reaches this in meters starting from bottom"
|
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minFlowLevel": {
|
"maxLevel": {
|
||||||
"default": 1,
|
|
||||||
"rules": {
|
|
||||||
"type": "number",
|
|
||||||
"min": 0,
|
|
||||||
"description": "min level to scale the flow lineair"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"maxFlowLevel": {
|
|
||||||
"default": 4,
|
"default": 4,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "max level to scale the flow lineair"
|
"description": "Level at which the pump demand saturates at 100 %. Above this, demand stays clamped."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"curveType": {
|
||||||
|
"default": "linear",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "linear",
|
||||||
|
"description": "Linear demand scaling between the active lower ramp level and maxLevel."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "log",
|
||||||
|
"description": "Logarithmic demand scaling with fast response early in the ramp."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Demand curve used by levelbased control."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logCurveFactor": {
|
||||||
|
"default": 9,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0.001,
|
||||||
|
"description": "Shape factor for the levelbased log curve; higher values increase early response."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enableShiftedRamp": {
|
||||||
|
"default": false,
|
||||||
|
"rules": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "When true, arm a hysteresis shift: once level rises past shiftLevel the ramp foot moves left from inflowLevel to startLevel until level falls back below startLevel."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shiftLevel": {
|
||||||
|
"default": 0,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"description": "Level (m) at which the held output starts ramping down during draining. Must be > startLevel and ≤ maxLevel. Ignored when enableShiftedRamp is false."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shiftArmPercent": {
|
||||||
|
"default": 95,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"description": "Output % threshold that arms the shift on the way up. Once armed, the output value at the moment direction flips to draining becomes the held value, and stays held until level drops to shiftLevel. Disarms when level reaches startLevel."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -638,19 +740,18 @@
|
|||||||
"description": "Volume percentage below which dry run protection activates."
|
"description": "Volume percentage below which dry run protection activates."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryRunDebounceSeconds": {
|
|
||||||
"default": 30,
|
|
||||||
"rules": {
|
|
||||||
"type": "number",
|
|
||||||
"min": 0,
|
|
||||||
"description": "Time the low-volume condition must persist before dry-run protection engages (seconds)."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enableOverfillProtection": {
|
"enableOverfillProtection": {
|
||||||
"default": true,
|
"default": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "If true, high level alarms and shutdowns will be enforced to prevent overfilling."
|
"description": "Deprecated alias for enableHighVolumeSafety. If true, high level alarms and shutdowns will be enforced to preserve overflow margin."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enableHighVolumeSafety": {
|
||||||
|
"default": true,
|
||||||
|
"rules": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, high-volume safety actions run before the basin reaches physical overflow."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overfillThresholdPercent": {
|
"overfillThresholdPercent": {
|
||||||
@@ -659,15 +760,16 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"max": 100,
|
"max": 100,
|
||||||
"description": "Volume percentage above which overfill protection activates."
|
"description": "Deprecated alias for highVolumeSafetyThresholdPercent."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overfillDebounceSeconds": {
|
"highVolumeSafetyThresholdPercent": {
|
||||||
"default": 30,
|
"default": 98,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Time the high-volume condition must persist before overfill protection engages (seconds)."
|
"max": 100,
|
||||||
|
"description": "Percentage of maxVolAtOverflow where high-volume safety activates before actual overflow."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timeleftToFullOrEmptyThresholdSeconds": {
|
"timeleftToFullOrEmptyThresholdSeconds": {
|
||||||
|
|||||||
@@ -91,6 +91,54 @@
|
|||||||
],
|
],
|
||||||
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"distance": {
|
||||||
|
"default": null,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Optional spatial offset from the parent equipment reference. Populated from the editor when hasDistance is enabled; null otherwise."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"distanceUnit": {
|
||||||
|
"default": "m",
|
||||||
|
"rules": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unit for the functionality.distance offset (e.g. 'm', 'cm')."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"distanceDescription": {
|
||||||
|
"default": "",
|
||||||
|
"rules": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Free-text description of what the distance offset represents (e.g. 'cable length from control panel to motor')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"process": {
|
||||||
|
"default": "process",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "process", "description": "Delta-compressed process message (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the process payload emitted on output port 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dbase": {
|
||||||
|
"default": "influxdb",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the telemetry payload emitted on output port 1."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
@@ -234,42 +282,8 @@
|
|||||||
},
|
},
|
||||||
"machineCurve": {
|
"machineCurve": {
|
||||||
"default": {
|
"default": {
|
||||||
"nq": {
|
"nq": {},
|
||||||
"1": {
|
"np": {}
|
||||||
"x": [
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"y": [
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
30,
|
|
||||||
40,
|
|
||||||
50
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"np": {
|
|
||||||
"1": {
|
|
||||||
"x": [
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"y": [
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
30,
|
|
||||||
40,
|
|
||||||
50
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "machineCurve",
|
"type": "machineCurve",
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
// Map a child's raw softwareType (the lowercased node name from
|
||||||
|
// buildConfig) to the "role" key that parent registerChild() handlers
|
||||||
|
// dispatch on. Without this, MGC/pumpingStation register-handlers (which
|
||||||
|
// branch on 'machine' / 'machinegroup' / 'pumpingstation' / 'measurement')
|
||||||
|
// silently miss every real production child because rotatingMachine
|
||||||
|
// reports softwareType='rotatingmachine' and machineGroupControl reports
|
||||||
|
// 'machinegroupcontrol'. Existing tests that pass already-aliased keys
|
||||||
|
// ('machine', 'machinegroup') stay green because those aren't in the
|
||||||
|
// alias map and pass through unchanged.
|
||||||
|
const SOFTWARE_TYPE_ALIASES = {
|
||||||
|
rotatingmachine: 'machine',
|
||||||
|
machinegroupcontrol: 'machinegroup',
|
||||||
|
};
|
||||||
|
|
||||||
class ChildRegistrationUtils {
|
class ChildRegistrationUtils {
|
||||||
constructor(mainClass) {
|
constructor(mainClass) {
|
||||||
this.mainClass = mainClass;
|
this.mainClass = mainClass;
|
||||||
@@ -15,7 +29,8 @@ class ChildRegistrationUtils {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const softwareType = (child.config.functionality.softwareType || '').toLowerCase();
|
const rawSoftwareType = (child.config.functionality.softwareType || '').toLowerCase();
|
||||||
|
const softwareType = SOFTWARE_TYPE_ALIASES[rawSoftwareType] || rawSoftwareType;
|
||||||
const name = child.config.general.name || child.config.general.id || 'unknown';
|
const name = child.config.general.name || child.config.general.id || 'unknown';
|
||||||
const id = child.config.general.id || name;
|
const id = child.config.general.id || name;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,51 @@ const EventEmitter = require('events');
|
|||||||
const convertModule = require('../convert/index');
|
const convertModule = require('../convert/index');
|
||||||
const { POSITIONS } = require('../constants/positions');
|
const { POSITIONS } = require('../constants/positions');
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* MeasurementContainer — measurement storage with chainable type/variant/
|
||||||
|
* position/child addressing.
|
||||||
|
*
|
||||||
|
* INTERNAL STORAGE SHAPE
|
||||||
|
* measurements[type][variant][position][childId] = Measurement instance
|
||||||
|
*
|
||||||
|
* The childId layer is ALWAYS present, even when the caller doesn't specify
|
||||||
|
* one. _getOrCreateMeasurement defaults childId to 'default' when no
|
||||||
|
* .child(...) is in the chain. So writing
|
||||||
|
*
|
||||||
|
* mc.type('level').variant('measured').position('atequipment')
|
||||||
|
* .value(2.5, ts, 'm');
|
||||||
|
*
|
||||||
|
* stores the value at measurements.level.measured.atequipment.default.
|
||||||
|
*
|
||||||
|
* READING — the chainable getters resolve the default child transparently,
|
||||||
|
* so consumers usually don't see it:
|
||||||
|
*
|
||||||
|
* mc.type('level').variant('measured').position('atequipment')
|
||||||
|
* .getCurrentValue('m'); // returns 2.5
|
||||||
|
*
|
||||||
|
* FLATTENED OUTPUT — getFlattenedOutput() emits ONE key per child, including
|
||||||
|
* the implicit 'default' bucket:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* 'level.measured.atequipment.default': 2.5, // implicit child
|
||||||
|
* 'flow.predicted.in.manual-qin': 0.05, // explicit .child('manual-qin')
|
||||||
|
* 'flow.predicted.in.from-pump-A': 0.03,
|
||||||
|
* …
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* ⚠ DASHBOARDS / DOWNSTREAM PARSERS MUST INCLUDE THE CHILD KEY
|
||||||
|
* The flat key format is `${type}.${variant}.${position}.${childId}`.
|
||||||
|
* When you have not used .child(), the childId is the literal string
|
||||||
|
* 'default'. Use 'level.measured.atequipment.default', NOT
|
||||||
|
* 'level.measured.atequipment'. This trips up new consumers — see the
|
||||||
|
* pumpingStation basic-dashboard parser for an example that gets it right.
|
||||||
|
*
|
||||||
|
* AGGREGATION — sum() folds all children of a position into one number:
|
||||||
|
*
|
||||||
|
* mc.sum('flow', 'predicted', ['in'], 'm3/s');
|
||||||
|
* // = manual-qin + from-pump-A + … + (default if any)
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
class MeasurementContainer {
|
class MeasurementContainer {
|
||||||
constructor(options = {},logger) {
|
constructor(options = {},logger) {
|
||||||
this.logger = logger || null;
|
this.logger = logger || null;
|
||||||
@@ -141,11 +186,17 @@ class MeasurementContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isUnitCompatible(measurementType, unit) {
|
isUnitCompatible(measurementType, unit) {
|
||||||
const desc = this._describeUnit(unit);
|
// Unknown type (not in measureMap): accept any unit. This lets user-
|
||||||
if (!desc) return false;
|
// defined measurement types (e.g. 'humidity', 'co2', arbitrary IoT
|
||||||
|
// channels in digital mode) pass through without being rejected just
|
||||||
|
// because their unit string ('%', 'ppm', …) is not a known physical
|
||||||
|
// unit to the convert module. Known types are still validated strictly.
|
||||||
const normalizedType = this._normalizeType(measurementType);
|
const normalizedType = this._normalizeType(measurementType);
|
||||||
const expectedMeasure = this.measureMap[normalizedType];
|
const expectedMeasure = this.measureMap[normalizedType];
|
||||||
if (!expectedMeasure) return true;
|
if (!expectedMeasure) return true;
|
||||||
|
|
||||||
|
const desc = this._describeUnit(unit);
|
||||||
|
if (!desc) return false;
|
||||||
return desc.measure === expectedMeasure;
|
return desc.measure === expectedMeasure;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,16 +425,34 @@ class MeasurementContainer {
|
|||||||
// Legacy single measurement
|
// Legacy single measurement
|
||||||
if (posBucket?.getCurrentValue) return posBucket;
|
if (posBucket?.getCurrentValue) return posBucket;
|
||||||
|
|
||||||
// Child-aware: pick requested child, otherwise fall back to default, otherwise first available
|
// Child-aware lookup. Two separate sources of "child-id" on the
|
||||||
|
// container, with DIFFERENT strictness:
|
||||||
|
//
|
||||||
|
// _currentChildId : transient, set by .child(name) inside a chain.
|
||||||
|
// Explicit per-call. STRICT — if the named child
|
||||||
|
// does not exist, return null. Silent fall-through
|
||||||
|
// to a sibling would mask a missing-stream read
|
||||||
|
// as a wrong-stream read (see pumpingStation
|
||||||
|
// spillPrev bug, 2026-05-06).
|
||||||
|
//
|
||||||
|
// this.childId : persistent, set by setChildId(id). HINT only —
|
||||||
|
// try it first, then fall back to 'default' then
|
||||||
|
// first available. Containers registered with a
|
||||||
|
// persistent id (rotatingMachine, etc.) write
|
||||||
|
// under composed child ids (e.g. 'up-<id>') that
|
||||||
|
// don't equal the persistent id, and reads must
|
||||||
|
// still resolve to those writes.
|
||||||
if (posBucket && typeof posBucket === 'object') {
|
if (posBucket && typeof posBucket === 'object') {
|
||||||
const requestedKey = this._currentChildId || this.childId;
|
|
||||||
const keys = Object.keys(posBucket);
|
const keys = Object.keys(posBucket);
|
||||||
if (!keys.length) return null;
|
if (!keys.length) return null;
|
||||||
const measurement =
|
|
||||||
(requestedKey && posBucket[requestedKey]) ||
|
if (this._currentChildId) {
|
||||||
|
return posBucket[this._currentChildId] || null;
|
||||||
|
}
|
||||||
|
return (this.childId && posBucket[this.childId]) ||
|
||||||
posBucket.default ||
|
posBucket.default ||
|
||||||
posBucket[keys[0]];
|
posBucket[keys[0]] ||
|
||||||
return measurement || null;
|
null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -529,18 +598,43 @@ class MeasurementContainer {
|
|||||||
.reduce((acc, v) => acc + v, 0);
|
.reduce((acc, v) => acc + v, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the entire container to a key→value map, suitable for
|
||||||
|
* dashboards / InfluxDB / debug dumps.
|
||||||
|
*
|
||||||
|
* KEY FORMAT — child-bucketed series (the common case):
|
||||||
|
* `${type}.${variant}.${position}.${childId}`
|
||||||
|
*
|
||||||
|
* Even measurements written without an explicit `.child(...)` end up
|
||||||
|
* here under `childId === 'default'` (see _getOrCreateMeasurement).
|
||||||
|
* Examples:
|
||||||
|
* level.measured.atequipment.default // implicit child
|
||||||
|
* flow.predicted.in.manual-qin // explicit child
|
||||||
|
* flow.predicted.in.from-pump-A // explicit child
|
||||||
|
*
|
||||||
|
* Consumers (Node-RED dashboards, parsers) MUST include the trailing
|
||||||
|
* `.default` when reading default-bucket measurements. Stripping it
|
||||||
|
* silently misses the value. This is the #1 footgun for new code that
|
||||||
|
* uses MeasurementContainer.
|
||||||
|
*
|
||||||
|
* The "Legacy single series" branch below catches a pre-v2 storage
|
||||||
|
* shape where a position held a Measurement directly (no child layer);
|
||||||
|
* new code never produces that shape but old serialized state may.
|
||||||
|
*/
|
||||||
getFlattenedOutput(options = {}) {
|
getFlattenedOutput(options = {}) {
|
||||||
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
|
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
|
||||||
const out = {};
|
const out = {};
|
||||||
Object.entries(this.measurements).forEach(([type, variants]) => {
|
Object.entries(this.measurements).forEach(([type, variants]) => {
|
||||||
Object.entries(variants).forEach(([variant, positions]) => {
|
Object.entries(variants).forEach(([variant, positions]) => {
|
||||||
Object.entries(positions).forEach(([position, entry]) => {
|
Object.entries(positions).forEach(([position, entry]) => {
|
||||||
// Legacy single series
|
// Legacy single series (no childId layer)
|
||||||
if (entry?.getCurrentValue) {
|
if (entry?.getCurrentValue) {
|
||||||
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
|
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Child-bucketed series
|
// Child-bucketed series — ALWAYS the case for new writes,
|
||||||
|
// including the implicit 'default' bucket when no .child() is
|
||||||
|
// used. The flat key carries the childId.
|
||||||
if (entry && typeof entry === 'object') {
|
if (entry && typeof entry === 'object') {
|
||||||
Object.entries(entry).forEach(([childId, m]) => {
|
Object.entries(entry).forEach(([childId, m]) => {
|
||||||
if (m?.getCurrentValue) {
|
if (m?.getCurrentValue) {
|
||||||
|
|||||||
@@ -193,8 +193,13 @@ class AssetMenu {
|
|||||||
return normalizeApiCategory(key, node.softwareType || key, payload.data);
|
return normalizeApiCategory(key, node.softwareType || key, payload.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-dispatching populate (matches the wireEvents version). The
|
||||||
|
// load path below explicitly walks supplier -> type -> model ->
|
||||||
|
// unit in order using saved node.* values, so auto-dispatched
|
||||||
|
// change events (which previously cascaded through wireEvents'
|
||||||
|
// listeners and double-populated everything) are no longer needed.
|
||||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
||||||
const previous = selectEl.value;
|
if (!selectEl) return;
|
||||||
const mapper = typeof mapFn === 'function'
|
const mapper = typeof mapFn === 'function'
|
||||||
? mapFn
|
? mapFn
|
||||||
: (value) => ({ value, label: value });
|
: (value) => ({ value, label: value });
|
||||||
@@ -227,9 +232,6 @@ class AssetMenu {
|
|||||||
} else {
|
} else {
|
||||||
selectEl.value = '';
|
selectEl.value = '';
|
||||||
}
|
}
|
||||||
if (selectEl.value !== previous) {
|
|
||||||
selectEl.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryKey = resolveCategoryKey();
|
const categoryKey = resolveCategoryKey();
|
||||||
@@ -305,6 +307,28 @@ class AssetMenu {
|
|||||||
getEventInjectionCode(nodeName) {
|
getEventInjectionCode(nodeName) {
|
||||||
return `
|
return `
|
||||||
// Asset event wiring for ${nodeName}
|
// Asset event wiring for ${nodeName}
|
||||||
|
//
|
||||||
|
// The supplier -> type -> model -> unit chain is a strict downward
|
||||||
|
// cascade: each select rebuilds the next based on the currently
|
||||||
|
// selected value above it. Two earlier bugs in this code:
|
||||||
|
//
|
||||||
|
// 1. populate() auto-dispatched a synthetic 'change' event whenever
|
||||||
|
// the value of the rebuilt select differed from before the
|
||||||
|
// rebuild. That triggered the *child* select's listener mid-way
|
||||||
|
// through the *parent* listener, which then continued and
|
||||||
|
// blindly overwrote the child select with empty content. Net
|
||||||
|
// effect: model dropdown showed 'Awaiting Type Selection' even
|
||||||
|
// though a type was clearly selected.
|
||||||
|
//
|
||||||
|
// 2. Each downstream wipe ran unconditionally inside the parent
|
||||||
|
// handler, instead of being driven by the actual current value
|
||||||
|
// of the child select.
|
||||||
|
//
|
||||||
|
// Fix: populate() no longer dispatches change. Cascade is explicit
|
||||||
|
// via cascadeFromSupplier() / cascadeFromType() / cascadeFromModel()
|
||||||
|
// which are called from each handler. The same helpers run on
|
||||||
|
// initial load so behaviour is identical whether the user picked the
|
||||||
|
// value or it came from a saved node.
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
||||||
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||||
const categories = menuAsset.categories || {};
|
const categories = menuAsset.categories || {};
|
||||||
@@ -316,11 +340,17 @@ class AssetMenu {
|
|||||||
unit: document.getElementById('node-input-unit')
|
unit: document.getElementById('node-input-unit')
|
||||||
};
|
};
|
||||||
|
|
||||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
// populate(): rebuild a <select> with a placeholder + items.
|
||||||
const previous = selectEl.value;
|
// No change-event dispatch — cascading is done explicitly by the
|
||||||
|
// caller via cascadeFrom*() so the order of operations is
|
||||||
|
// predictable.
|
||||||
|
function populate(selectEl, items, selectedValue, mapFn, placeholderText) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
if (!Array.isArray(items)) items = [];
|
||||||
|
if (!placeholderText) placeholderText = 'Select...';
|
||||||
const mapper = typeof mapFn === 'function'
|
const mapper = typeof mapFn === 'function'
|
||||||
? mapFn
|
? mapFn
|
||||||
: (value) => ({ value, label: value });
|
: (value) => ({ value: value, label: value });
|
||||||
|
|
||||||
selectEl.innerHTML = '';
|
selectEl.innerHTML = '';
|
||||||
|
|
||||||
@@ -331,11 +361,9 @@ class AssetMenu {
|
|||||||
placeholder.selected = true;
|
placeholder.selected = true;
|
||||||
selectEl.appendChild(placeholder);
|
selectEl.appendChild(placeholder);
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach(function (item) {
|
||||||
const option = mapper(item);
|
const option = mapper(item);
|
||||||
if (!option || typeof option.value === 'undefined') {
|
if (!option || typeof option.value === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = option.value;
|
opt.value = option.value;
|
||||||
opt.textContent = option.label;
|
opt.textContent = option.label;
|
||||||
@@ -344,111 +372,112 @@ class AssetMenu {
|
|||||||
|
|
||||||
if (selectedValue) {
|
if (selectedValue) {
|
||||||
selectEl.value = selectedValue;
|
selectEl.value = selectedValue;
|
||||||
if (!selectEl.value) {
|
if (!selectEl.value) selectEl.value = '';
|
||||||
selectEl.value = '';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
selectEl.value = '';
|
selectEl.value = '';
|
||||||
}
|
}
|
||||||
if (selectEl.value !== previous) {
|
|
||||||
selectEl.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveCategoryKey = () => {
|
function resolveCategoryKey() {
|
||||||
if (node.softwareType && categories[node.softwareType]) {
|
if (node.softwareType && categories[node.softwareType]) return node.softwareType;
|
||||||
return node.softwareType;
|
if (node.category && categories[node.category]) return node.category;
|
||||||
}
|
|
||||||
if (node.category && categories[node.category]) {
|
|
||||||
return node.category;
|
|
||||||
}
|
|
||||||
return defaultCategory;
|
return defaultCategory;
|
||||||
};
|
}
|
||||||
|
function getActiveCategory() {
|
||||||
const getActiveCategory = () => {
|
|
||||||
const key = resolveCategoryKey();
|
const key = resolveCategoryKey();
|
||||||
return key ? categories[key] : null;
|
return key ? categories[key] : null;
|
||||||
};
|
}
|
||||||
|
|
||||||
node.category = resolveCategoryKey();
|
node.category = resolveCategoryKey();
|
||||||
|
|
||||||
elems.supplier.addEventListener('change', () => {
|
// Lookup helpers — read from the *currently selected* values in the
|
||||||
const category = getActiveCategory();
|
// DOM, not from node.* (which may not yet be in sync).
|
||||||
const supplier = category
|
function findSupplier() {
|
||||||
? category.suppliers.find(
|
const cat = getActiveCategory();
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
if (!cat || !Array.isArray(cat.suppliers)) return null;
|
||||||
)
|
const id = String(elems.supplier.value);
|
||||||
: null;
|
return cat.suppliers.find(function (s) {
|
||||||
|
return String(s.id || s.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
function findType(supplier) {
|
||||||
|
if (!supplier || !Array.isArray(supplier.types)) return null;
|
||||||
|
const id = String(elems.type.value);
|
||||||
|
return supplier.types.find(function (t) {
|
||||||
|
return String(t.id || t.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
function findModel(type) {
|
||||||
|
if (!type || !Array.isArray(type.models)) return null;
|
||||||
|
const id = String(elems.model.value);
|
||||||
|
return type.models.find(function (m) {
|
||||||
|
return String(m.id || m.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Cascade rebuild functions ==========================
|
||||||
|
// Each one rebuilds the dropdown for the *level it owns* plus all
|
||||||
|
// levels below it, using the current values in the DOM. Called by
|
||||||
|
// the corresponding change handler AND by initial load so both
|
||||||
|
// paths produce identical state.
|
||||||
|
|
||||||
|
function cascadeFromSupplier() {
|
||||||
|
const supplier = findSupplier();
|
||||||
const types = supplier ? supplier.types : [];
|
const types = supplier ? supplier.types : [];
|
||||||
populate(
|
populate(
|
||||||
elems.type,
|
elems.type,
|
||||||
types,
|
types,
|
||||||
node.assetType,
|
node.assetType,
|
||||||
(type) => ({ value: type.id || type.name, label: type.name }),
|
function (t) { return { value: t.id || t.name, label: t.name }; },
|
||||||
supplier ? 'Select...' : 'Awaiting Supplier Selection'
|
supplier ? 'Select...' : 'Awaiting Supplier Selection'
|
||||||
);
|
);
|
||||||
node.modelMetadata = null;
|
// After repopulating type, propagate down. cascadeFromType()
|
||||||
populate(elems.model, [], '', undefined, 'Awaiting Type Selection');
|
// will read the new elems.type.value (which was set by populate
|
||||||
populate(elems.unit, [], '', undefined, 'Awaiting Type Selection');
|
// to either the saved node.assetType or '') and rebuild model.
|
||||||
});
|
cascadeFromType();
|
||||||
|
}
|
||||||
|
|
||||||
elems.type.addEventListener('change', () => {
|
function cascadeFromType() {
|
||||||
const category = getActiveCategory();
|
const supplier = findSupplier();
|
||||||
const supplier = category
|
const type = findType(supplier);
|
||||||
? category.suppliers.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const type = supplier
|
|
||||||
? supplier.types.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const models = type ? type.models : [];
|
const models = type ? type.models : [];
|
||||||
populate(
|
populate(
|
||||||
elems.model,
|
elems.model,
|
||||||
models,
|
models,
|
||||||
node.model,
|
node.model,
|
||||||
(model) => ({ value: model.id || model.name, label: model.name }),
|
function (m) { return { value: m.id || m.name, label: m.name }; },
|
||||||
type ? 'Select...' : 'Awaiting Type Selection'
|
type ? 'Select...' : 'Awaiting Type Selection'
|
||||||
);
|
);
|
||||||
node.modelMetadata = null;
|
node.modelMetadata = null;
|
||||||
populate(
|
cascadeFromModel();
|
||||||
elems.unit,
|
}
|
||||||
[],
|
|
||||||
'',
|
|
||||||
undefined,
|
|
||||||
type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
elems.model.addEventListener('change', () => {
|
function cascadeFromModel() {
|
||||||
const category = getActiveCategory();
|
const supplier = findSupplier();
|
||||||
const supplier = category
|
const type = findType(supplier);
|
||||||
? category.suppliers.find(
|
const model = findModel(type);
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const type = supplier
|
|
||||||
? supplier.types.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const model = type
|
|
||||||
? type.models.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.model.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
node.modelMetadata = model;
|
node.modelMetadata = model;
|
||||||
node.modelName = model ? model.name : '';
|
node.modelName = model ? model.name : '';
|
||||||
populate(
|
populate(
|
||||||
elems.unit,
|
elems.unit,
|
||||||
model ? model.units || [] : [],
|
model ? (model.units || []) : [],
|
||||||
node.unit,
|
node.unit,
|
||||||
(unit) => ({ value: unit, label: unit }),
|
function (u) { return { value: u, label: u }; },
|
||||||
model ? 'Select...' : type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
model ? 'Select...' : (type ? 'Awaiting Model Selection' : 'Awaiting Type Selection')
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
elems.supplier.addEventListener('change', cascadeFromSupplier);
|
||||||
|
elems.type.addEventListener('change', cascadeFromType);
|
||||||
|
elems.model.addEventListener('change', cascadeFromModel);
|
||||||
|
|
||||||
|
// Expose the cascades so loadData() (or future code) can re-run
|
||||||
|
// them after async data arrives without duplicating logic.
|
||||||
|
window.EVOLV.nodes.${nodeName}.assetMenu._cascade = {
|
||||||
|
fromSupplier: cascadeFromSupplier,
|
||||||
|
fromType: cascadeFromType,
|
||||||
|
fromModel: cascadeFromModel,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ const Interpolation = require('./interpolation');
|
|||||||
class Predict {
|
class Predict {
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
|
|
||||||
|
// Capture share-source BEFORE config validation strips it (ConfigUtils
|
||||||
|
// mutates the input config to drop unknown keys, which would remove
|
||||||
|
// shareInputsFrom because it's not in predictConfig.json's schema).
|
||||||
|
const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
|
||||||
|
? config.shareInputsFrom
|
||||||
|
: null;
|
||||||
|
|
||||||
// Initialize dependencies
|
// Initialize dependencies
|
||||||
this.emitter = new EventEmitter(); // Own EventEmitter
|
this.emitter = new EventEmitter(); // Own EventEmitter
|
||||||
this.configUtils = new ConfigUtils(defaultConfig);
|
this.configUtils = new ConfigUtils(defaultConfig);
|
||||||
@@ -107,8 +114,29 @@ class Predict {
|
|||||||
this.calculationPoints = this.config.normalization.parameters.curvePoints;
|
this.calculationPoints = this.config.normalization.parameters.curvePoints;
|
||||||
this.interpolationType = this.config.interpolation.type;
|
this.interpolationType = this.config.interpolation.type;
|
||||||
|
|
||||||
// Load curve if provided
|
// Load curve if provided.
|
||||||
if (config.curve) {
|
// shareInputsFrom: an existing Predict instance whose pre-built input
|
||||||
|
// curves and splines we adopt by reference. Used to create a parallel
|
||||||
|
// "view" of the same source curves (e.g. an MGC group-scope predict
|
||||||
|
// that mirrors a pump's individual predict). Per-instance state —
|
||||||
|
// currentF / currentX / currentFxyCurve / currentFxySplines /
|
||||||
|
// currentFxyY/X Min/Max / outputY — stays freshly initialised so the
|
||||||
|
// two views have independent operating points. Curve mutations on the
|
||||||
|
// source via updateCurve() are propagated through the source's
|
||||||
|
// "curveUpdated" emitter (see updateCurve below).
|
||||||
|
if (_sharedSource) {
|
||||||
|
this._adoptInputsFrom(_sharedSource);
|
||||||
|
this._sharedInputsSource = _sharedSource;
|
||||||
|
this._sharedInputsHandler = (newCurve) => {
|
||||||
|
this._adoptInputsFrom(this._sharedInputsSource);
|
||||||
|
// Keep our currentF in range; constrain re-uses the new fValues.
|
||||||
|
this.fDimension = this.constrain(this.currentF, this.fValues.min, this.fValues.max);
|
||||||
|
};
|
||||||
|
this._sharedInputsSource.emitter.on('curveUpdated', this._sharedInputsHandler);
|
||||||
|
// Initialise our own operating point to the source's min, same as
|
||||||
|
// the standard buildAllFxyCurves flow does at end of curve load.
|
||||||
|
this.fDimension = this.fValues.min;
|
||||||
|
} else if (config.curve) {
|
||||||
this.inputCurveData = config.curve;
|
this.inputCurveData = config.curve;
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default");
|
this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default");
|
||||||
@@ -117,6 +145,31 @@ class Predict {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adopt another Predict's input curves and splines by reference. Used by
|
||||||
|
// the shareInputsFrom constructor option and by the curveUpdated emitter
|
||||||
|
// handler to re-sync after the source's curves change. Does NOT touch
|
||||||
|
// per-instance state (currentF, currentX, currentFxy* etc.).
|
||||||
|
//
|
||||||
|
// Also copies the scalar parameters (calculationPoints, normMin/Max,
|
||||||
|
// interpolationType) so the clone uses the SAME pointsCount the source
|
||||||
|
// built fSplines with — otherwise buildSingleFxyCurve can iterate past
|
||||||
|
// the end of the shared fSplines.
|
||||||
|
_adoptInputsFrom(source) {
|
||||||
|
this.inputCurve = source.inputCurve;
|
||||||
|
this.normalizedCurve = source.normalizedCurve;
|
||||||
|
this.calculatedCurve = source.calculatedCurve;
|
||||||
|
this.fCurve = source.fCurve;
|
||||||
|
this.fSplines = source.fSplines;
|
||||||
|
this.normalizedSplines = source.normalizedSplines;
|
||||||
|
this.xValues = source.xValues;
|
||||||
|
this.fValues = source.fValues;
|
||||||
|
this.yValues = source.yValues;
|
||||||
|
this.calculationPoints = source.calculationPoints;
|
||||||
|
this.normMin = source.normMin;
|
||||||
|
this.normMax = source.normMax;
|
||||||
|
this.interpolationType = source.interpolationType;
|
||||||
|
}
|
||||||
|
|
||||||
// Improved function to get a local peak in an array by starting in the middle.
|
// Improved function to get a local peak in an array by starting in the middle.
|
||||||
// It also handles the case of a tie by preferring the left side (arbitrary choice)
|
// It also handles the case of a tie by preferring the left side (arbitrary choice)
|
||||||
// when array[start] == leftValue or array[start] == rightValue.
|
// when array[start] == leftValue or array[start] == rightValue.
|
||||||
@@ -348,6 +401,9 @@ class Predict {
|
|||||||
|
|
||||||
this.buildAllFxyCurves(validatedCurve);
|
this.buildAllFxyCurves(validatedCurve);
|
||||||
|
|
||||||
|
// Notify shared-input clones (see shareInputsFrom in the constructor).
|
||||||
|
// They re-adopt our inputs and clamp their own operating point.
|
||||||
|
this.emitter.emit('curveUpdated', validatedCurve);
|
||||||
}
|
}
|
||||||
|
|
||||||
constrain(value,min,max) {
|
constrain(value,min,max) {
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ class state{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.stateManager.getCurrentState() !== "operational") {
|
if (this.stateManager.getCurrentState() !== "operational") {
|
||||||
|
// 'accelerating' / 'decelerating' here is post-abort residue —
|
||||||
|
// the previous moveTo was aborted (e.g. MGC's per-tick
|
||||||
|
// abortActiveMovements) and the catch block intentionally
|
||||||
|
// doesn't auto-return to operational (avoids a bounce loop).
|
||||||
|
// BUT a new setpoint just arrived, so there's nothing for the
|
||||||
|
// anti-bounce policy to protect: the caller IS asking for a
|
||||||
|
// move. Fall through to operational and execute it. Without
|
||||||
|
// this the FSM gets parked, all subsequent setpoints land in
|
||||||
|
// delayedMove which never fires, and currentPosition freezes —
|
||||||
|
// see test/integration/abort-deadlock.integration.test.js for
|
||||||
|
// the exact deadlock scenario.
|
||||||
|
const movementResidueStates = ['accelerating', 'decelerating'];
|
||||||
|
if (movementResidueStates.includes(this.stateManager.getCurrentState())) {
|
||||||
|
this.logger.debug(`moveTo(${targetPosition}) arrived while parked in '${this.stateManager.getCurrentState()}' (post-abort). Returning to operational to service the new setpoint.`);
|
||||||
|
try {
|
||||||
|
await this.transitionToState("operational");
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Could not transition out of '${this.stateManager.getCurrentState()}': ${e?.message || e}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fall through — state is now operational, proceed with new move.
|
||||||
|
} else {
|
||||||
|
// Genuine non-operational state (starting, warmingup, stopping,
|
||||||
|
// coolingdown, idle, off, emergencystop, maintenance) — these
|
||||||
|
// are sequence steps the caller can't legitimately interrupt
|
||||||
|
// with a setpoint. Save for later, exactly as before.
|
||||||
if (this.config.mode.current === "auto") {
|
if (this.config.mode.current === "auto") {
|
||||||
this.delayedMove = targetPosition;
|
this.delayedMove = targetPosition;
|
||||||
this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`);
|
this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`);
|
||||||
@@ -73,9 +99,9 @@ class state{
|
|||||||
else{
|
else{
|
||||||
this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`);
|
this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`);
|
||||||
}
|
}
|
||||||
//return early
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
const { signal } = this.abortController;
|
const { signal } = this.abortController;
|
||||||
try {
|
try {
|
||||||
@@ -85,15 +111,46 @@ class state{
|
|||||||
this.emitter.emit("movementComplete", { position: targetPosition });
|
this.emitter.emit("movementComplete", { position: targetPosition });
|
||||||
await this.transitionToState("operational");
|
await this.transitionToState("operational");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Abort path: only return to 'operational' when explicitly requested
|
||||||
|
// (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC
|
||||||
|
// demand-update aborts must NOT auto-transition — doing so causes a
|
||||||
|
// bounce loop where every tick aborts → operational → new move →
|
||||||
|
// abort → operational → ... and the pump never reaches its setpoint.
|
||||||
|
const msg = typeof error === 'string' ? error : error?.message;
|
||||||
|
if (msg === 'Transition aborted' || msg === 'Movement aborted') {
|
||||||
|
if (this._returnToOperationalOnAbort) {
|
||||||
|
this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`);
|
||||||
|
try {
|
||||||
|
await this.transitionToState("operational");
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Movement aborted; staying in current state (routine abort).`);
|
||||||
|
}
|
||||||
|
this._returnToOperationalOnAbort = false;
|
||||||
|
this.emitter.emit("movementAborted", { position: targetPosition });
|
||||||
|
} else {
|
||||||
this.logger.error(error);
|
this.logger.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------- State Transition Methods -------- //
|
// -------- State Transition Methods -------- //
|
||||||
|
|
||||||
abortCurrentMovement(reason = "group override") {
|
/**
|
||||||
|
* @param {string} reason - human-readable abort reason
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.returnToOperational=false] - when true the FSM
|
||||||
|
* transitions back to 'operational' after the abort so a subsequent
|
||||||
|
* shutdown/emergency-stop sequence can proceed. Set to false (default)
|
||||||
|
* for routine demand updates where the caller will send a new movement
|
||||||
|
* immediately — auto-transitioning would cause a bounce loop.
|
||||||
|
*/
|
||||||
|
abortCurrentMovement(reason = "group override", options = {}) {
|
||||||
if (this.abortController && !this.abortController.signal.aborted) {
|
if (this.abortController && !this.abortController.signal.aborted) {
|
||||||
this.logger.warn(`Aborting movement: ${reason}`);
|
this.logger.warn(`Aborting movement: ${reason}`);
|
||||||
|
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user