This commit is contained in:
znetsixe
2026-02-23 13:17:47 +01:00
parent 1cfb36f604
commit c60aa40666
17 changed files with 358 additions and 53 deletions

View File

@@ -8,25 +8,27 @@
*/ */
// Core helper modules // Core helper modules
const outputUtils = require('./src/helper/outputUtils.js'); const helper = require('./src/helper/index.js');
const logger = require('./src/helper/logger.js'); const {
const validation = require('./src/helper/validationUtils.js'); outputUtils,
const configUtils = require('./src/helper/configUtils.js'); logger,
const assertions = require('./src/helper/assertionUtils.js') validation,
configUtils,
assertions,
childRegistrationUtils,
gravity,
} = helper;
const coolprop = require('./src/coolprop-node/src/index.js'); const coolprop = require('./src/coolprop-node/src/index.js');
const gravity = require('./src/helper/gravity.js')
const assetApiConfig = require('./src/configs/assetApiConfig.js'); const assetApiConfig = require('./src/configs/assetApiConfig.js');
// Domain-specific modules // Domain-specific modules
const { MeasurementContainer } = require('./src/measurements/index.js'); const { MeasurementContainer } = require('./src/measurements/index.js');
const configManager = require('./src/configs/index.js'); const configManager = require('./src/configs/index.js');
const nrmse = require('./src/nrmse/errorMetrics.js'); const { nrmse } = require('./src/nrmse/index.js');
const state = require('./src/state/state.js'); const { state } = require('./src/state/index.js');
const convert = require('./src/convert/index.js'); const convert = require('./src/convert/index.js');
const MenuManager = require('./src/menu/index.js'); const MenuManager = require('./src/menu/index.js');
const predict = require('./src/predict/predict_class.js'); const { predict, interpolation } = require('./src/predict/index.js');
const interpolation = require('./src/predict/interpolation.js');
const childRegistrationUtils = require('./src/helper/childRegistrationUtils.js');
const { loadCurve } = require('./datasets/assetData/curves/index.js'); //deprecated replace with load model data const { loadCurve } = require('./datasets/assetData/curves/index.js'); //deprecated replace with load model data
const { loadModel } = require('./datasets/assetData/modelData/index.js'); const { loadModel } = require('./datasets/assetData/modelData/index.js');

View File

@@ -9,11 +9,16 @@
"./menuUtils": "./src/helper/menuUtils.js", "./menuUtils": "./src/helper/menuUtils.js",
"./mathUtils": "./src/helper/mathUtils.js", "./mathUtils": "./src/helper/mathUtils.js",
"./assetUtils": "./src/helper/assetUtils.js", "./assetUtils": "./src/helper/assetUtils.js",
"./outputUtils": "./src/helper/outputUtils.js" "./outputUtils": "./src/helper/outputUtils.js",
"./helper": "./src/helper/index.js",
"./state": "./src/state/index.js",
"./predict": "./src/predict/index.js",
"./nrmse": "./src/nrmse/index.js",
"./outliers": "./src/outliers/index.js"
}, },
"scripts": { "scripts": {
"test": "node test.js" "test": "node --test test/*.test.js src/nrmse/errorMetric.test.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -26,4 +31,4 @@
], ],
"author": "Rene de Ren", "author": "Rene de Ren",
"license": "SEE LICENSE" "license": "SEE LICENSE"
} }

View File

@@ -6,8 +6,18 @@ class ChildRegistrationUtils {
} }
async registerChild(child, positionVsParent, distance) { async registerChild(child, positionVsParent, distance) {
const { softwareType } = child.config.functionality; if (!child || typeof child !== 'object') {
const { name, id } = child.config.general; this.logger?.warn('registerChild skipped: invalid child payload');
return false;
}
if (!child.config?.functionality || !child.config?.general) {
this.logger?.warn('registerChild skipped: missing child config/functionality/general');
return false;
}
const softwareType = child.config.functionality.softwareType;
const name = child.config.general.name || child.config.general.id || 'unknown';
const id = child.config.general.id || name;
this.logger.debug(`Registering child: ${name} (${id}) as ${softwareType} at ${positionVsParent}`); this.logger.debug(`Registering child: ${name} (${id}) as ${softwareType} at ${positionVsParent}`);
@@ -43,19 +53,21 @@ class ChildRegistrationUtils {
} }
this.logger.info(`✅ Child ${name} registered successfully`); this.logger.info(`✅ Child ${name} registered successfully`);
return true;
} }
_storeChild(child, softwareType) { _storeChild(child, softwareType) {
// Maintain your existing structure // Maintain your existing structure
if (!this.mainClass.child) this.mainClass.child = {}; if (!this.mainClass.child) this.mainClass.child = {};
if (!this.mainClass.child[softwareType]) this.mainClass.child[softwareType] = {}; const typeKey = softwareType || 'unknown';
if (!this.mainClass.child[typeKey]) this.mainClass.child[typeKey] = {};
const { category = "sensor" } = child.config.asset || {}; const { category = "sensor" } = child.config.asset || {};
if (!this.mainClass.child[softwareType][category]) { if (!this.mainClass.child[typeKey][category]) {
this.mainClass.child[softwareType][category] = []; this.mainClass.child[typeKey][category] = [];
} }
this.mainClass.child[softwareType][category].push(child); this.mainClass.child[typeKey][category].push(child);
} }
// NEW: Utility methods for parent to use // NEW: Utility methods for parent to use
@@ -95,4 +107,4 @@ class ChildRegistrationUtils {
} }
} }
module.exports = ChildRegistrationUtils; module.exports = ChildRegistrationUtils;

View File

@@ -73,17 +73,25 @@ class ConfigUtils {
return updatedConfig; return updatedConfig;
} }
_isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]';
}
// loop through objects and merge them obj1 will be updated with obj2 values // loop through objects and merge them obj1 will be updated with obj2 values
mergeObjects(obj1, obj2) { mergeObjects(obj1, obj2) {
for (let key in obj2) { for (let key in obj2) {
if (obj2.hasOwnProperty(key)) { if (obj2.hasOwnProperty(key)) {
if (typeof obj2[key] === 'object') { const nextValue = obj2[key];
if (!obj1[key]) {
if (Array.isArray(nextValue)) {
obj1[key] = [...nextValue];
} else if (this._isPlainObject(nextValue)) {
if (!this._isPlainObject(obj1[key])) {
obj1[key] = {}; obj1[key] = {};
} }
this.mergeObjects(obj1[key], obj2[key]); this.mergeObjects(obj1[key], nextValue);
} else { } else {
obj1[key] = obj2[key]; obj1[key] = nextValue;
} }
} }
} }

View File

@@ -18,13 +18,102 @@ class EndpointUtils {
* @param {string} nodeName the name of the node (used in the URL) * @param {string} nodeName the name of the node (used in the URL)
* @param {object} customHelpers additional helper functions to inject * @param {object} customHelpers additional helper functions to inject
*/ */
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) { createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}, options = {}) {
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, (req, res) => { const basePath = `/${nodeName}/resources`;
console.log(`Serving menuUtils.js for ${nodeName} node`);
res.set('Content-Type', 'application/javascript'); RED.httpAdmin.get(`${basePath}/menuUtilsData.json`, (req, res) => {
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers); res.json(this.generateMenuUtilsData(nodeName, customHelpers, options));
res.send(browserCode);
}); });
RED.httpAdmin.get(`${basePath}/menuUtils.legacy.js`, (req, res) => {
res.set('Content-Type', 'application/javascript');
res.send(this.generateLegacyMenuUtilsCode(nodeName, customHelpers));
});
RED.httpAdmin.get(`${basePath}/menuUtils.js`, (req, res) => {
res.set('Content-Type', 'application/javascript');
res.send(this.generateMenuUtilsBootstrap(nodeName));
});
}
generateMenuUtilsData(nodeName, customHelpers = {}, options = {}) {
const defaultHelpers = {
validateRequired: `function(value) {
return value != null && value.toString().trim() !== '';
}`,
formatDisplayValue: `function(value, unit) {
return \`${'${'}value} ${'${'}unit || ''}\`.trim();
}`,
validateScaling: `function(min, max) {
return !isNaN(min) && !isNaN(max) && Number(min) < Number(max);
}`,
validateUnit: `function(unit) {
return typeof unit === 'string' && unit.trim() !== '';
}`,
};
return {
nodeName,
helpers: { ...defaultHelpers, ...customHelpers },
options: {
autoLoadLegacy: options.autoLoadLegacy !== false,
},
};
}
generateMenuUtilsBootstrap(nodeName) {
return `
// Stable bootstrap for EVOLV menu utils (${nodeName})
(function() {
const nodeName = ${JSON.stringify(nodeName)};
const basePath = '/' + nodeName + '/resources';
window.EVOLV = window.EVOLV || {};
window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes[nodeName] = window.EVOLV.nodes[nodeName] || {};
window.EVOLV.nodes[nodeName].utils = window.EVOLV.nodes[nodeName].utils || {};
function parseHelper(fnBody) {
try {
return (new Function('return (' + fnBody + ')'))();
} catch (error) {
console.error('[menuUtils] helper parse failed:', error);
return function() { return null; };
}
}
function loadLegacyIfNeeded(autoLoadLegacy) {
if (!autoLoadLegacy || typeof window.MenuUtils === 'function') return Promise.resolve();
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = basePath + '/menuUtils.legacy.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
fetch(basePath + '/menuUtilsData.json')
.then(function(res) { return res.json(); })
.then(function(payload) {
const helperFns = {};
Object.entries(payload.helpers || {}).forEach(function(entry) {
helperFns[entry[0]] = parseHelper(entry[1]);
});
window.EVOLV.nodes[nodeName].utils.helpers = helperFns;
return loadLegacyIfNeeded(payload.options && payload.options.autoLoadLegacy);
})
.then(function() {
if (typeof window.MenuUtils === 'function' && !window.EVOLV.nodes[nodeName].utils.menuUtils) {
window.EVOLV.nodes[nodeName].utils.menuUtils = new window.MenuUtils();
}
})
.catch(function(error) {
console.error('[menuUtils] bootstrap failed for ' + nodeName, error);
});
})();
`;
} }
/** /**
@@ -33,7 +122,7 @@ class EndpointUtils {
* @param {object} customHelpers map of name: functionString pairs * @param {object} customHelpers map of name: functionString pairs
* @returns {string} a JS snippet to run in the browser * @returns {string} a JS snippet to run in the browser
*/ */
generateMenuUtilsCode(nodeName, customHelpers = {}) { generateLegacyMenuUtilsCode(nodeName, customHelpers = {}) {
// Default helper implementations to expose alongside MenuUtils // Default helper implementations to expose alongside MenuUtils
const defaultHelpers = { const defaultHelpers = {
validateRequired: `function(value) { validateRequired: `function(value) {
@@ -101,6 +190,11 @@ ${helpersCode}
console.log('Loaded EVOLV.nodes.${nodeName}.utils.menuUtils'); console.log('Loaded EVOLV.nodes.${nodeName}.utils.menuUtils');
`; `;
} }
// Backward-compatible alias.
generateMenuUtilsCode(nodeName, customHelpers = {}) {
return this.generateLegacyMenuUtilsCode(nodeName, customHelpers);
}
} }
module.exports = EndpointUtils; module.exports = EndpointUtils;

25
src/helper/index.js Normal file
View File

@@ -0,0 +1,25 @@
const assertions = require('./assertionUtils.js');
const assetUtils = require('./assetUtils.js');
const childRegistrationUtils = require('./childRegistrationUtils.js');
const configUtils = require('./configUtils.js');
const endpointUtils = require('./endpointUtils.js');
const gravity = require('./gravity.js');
const logger = require('./logger.js');
const menuUtils = require('./menuUtils.js');
const nodeTemplates = require('./nodeTemplates.js');
const outputUtils = require('./outputUtils.js');
const validation = require('./validationUtils.js');
module.exports = {
assertions,
assetUtils,
childRegistrationUtils,
configUtils,
endpointUtils,
gravity,
logger,
menuUtils,
nodeTemplates,
outputUtils,
validation,
};

View File

@@ -480,17 +480,26 @@ generateHtml(htmlElement, options, savedValue) {
} }
} }
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) { createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}, options = {}) {
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, function(req, res) { const basePath = `/${nodeName}/resources`;
console.log(`Serving menuUtils.js for ${nodeName} node`);
RED.httpAdmin.get(`${basePath}/menuUtilsData.json`, function(req, res) {
res.json(this.generateMenuUtilsData(nodeName, customHelpers, options));
}.bind(this));
RED.httpAdmin.get(`${basePath}/menuUtils.legacy.js`, function(req, res) {
res.set('Content-Type', 'application/javascript'); res.set('Content-Type', 'application/javascript');
const browserCode = this.generateLegacyMenuUtilsCode(nodeName, customHelpers);
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers);
res.send(browserCode); res.send(browserCode);
}.bind(this)); }.bind(this));
RED.httpAdmin.get(`${basePath}/menuUtils.js`, function(req, res) {
res.set('Content-Type', 'application/javascript');
res.send(this.generateMenuUtilsBootstrap(nodeName));
}.bind(this));
} }
generateMenuUtilsCode(nodeName, customHelpers = {}) { generateMenuUtilsData(nodeName, customHelpers = {}, options = {}) {
const defaultHelpers = { const defaultHelpers = {
validateRequired: `function(value) { validateRequired: `function(value) {
return value && value.toString().trim() !== ''; return value && value.toString().trim() !== '';
@@ -500,7 +509,71 @@ generateMenuUtilsCode(nodeName, customHelpers = {}) {
}` }`
}; };
const allHelpers = { ...defaultHelpers, ...customHelpers }; return {
nodeName,
helpers: { ...defaultHelpers, ...customHelpers },
options: {
autoLoadLegacy: options.autoLoadLegacy !== false,
},
};
}
generateMenuUtilsBootstrap(nodeName) {
return `
// Stable bootstrap for EVOLV menu utils (${nodeName})
(function() {
const nodeName = ${JSON.stringify(nodeName)};
const basePath = '/' + nodeName + '/resources';
window.EVOLV = window.EVOLV || {};
window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes[nodeName] = window.EVOLV.nodes[nodeName] || {};
window.EVOLV.nodes[nodeName].utils = window.EVOLV.nodes[nodeName].utils || {};
function parseHelper(fnBody) {
try {
return (new Function('return (' + fnBody + ')'))();
} catch (error) {
console.error('[menuUtils] helper parse failed:', error);
return function() { return null; };
}
}
function loadLegacyIfNeeded(autoLoadLegacy) {
if (!autoLoadLegacy || typeof window.MenuUtils === 'function') return Promise.resolve();
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = basePath + '/menuUtils.legacy.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
fetch(basePath + '/menuUtilsData.json')
.then(function(res) { return res.json(); })
.then(function(payload) {
const helperFns = {};
Object.entries(payload.helpers || {}).forEach(function(entry) {
helperFns[entry[0]] = parseHelper(entry[1]);
});
window.EVOLV.nodes[nodeName].utils.helpers = helperFns;
return loadLegacyIfNeeded(payload.options && payload.options.autoLoadLegacy);
})
.then(function() {
if (typeof window.MenuUtils === 'function' && !window.EVOLV.nodes[nodeName].utils.menuUtils) {
window.EVOLV.nodes[nodeName].utils.menuUtils = new window.MenuUtils();
}
})
.catch(function(error) {
console.error('[menuUtils] bootstrap failed for ' + nodeName, error);
});
})();
`;
}
generateLegacyMenuUtilsCode(nodeName, customHelpers = {}) {
const allHelpers = { ...this.generateMenuUtilsData(nodeName).helpers, ...customHelpers };
const helpersCode = Object.entries(allHelpers) const helpersCode = Object.entries(allHelpers)
.map(([name, func]) => ` ${name}: ${func}`) .map(([name, func]) => ` ${name}: ${func}`)
@@ -533,6 +606,11 @@ ${helpersCode}
`; `;
} }
// Backward-compatible alias
generateMenuUtilsCode(nodeName, customHelpers = {}) {
return this.generateLegacyMenuUtilsCode(nodeName, customHelpers);
} }
module.exports = MenuUtils; }
module.exports = MenuUtils;

View File

@@ -53,4 +53,4 @@ const nodeTemplates = {
// …add more node “templates” here… // …add more node “templates” here…
}; };
export default nodeTemplates; module.exports = nodeTemplates;

View File

@@ -7,6 +7,9 @@ class OutputUtils {
} }
checkForChanges(output, format) { checkForChanges(output, format) {
if (!output || typeof output !== 'object') {
return {};
}
const changedFields = {}; const changedFields = {};
for (const key in output) { for (const key in output) {
if (output.hasOwnProperty(key) && output[key] !== this.output[format][key]) { if (output.hasOwnProperty(key) && output[key] !== this.output[format][key]) {
@@ -54,11 +57,11 @@ class OutputUtils {
break; break;
default: default:
console.log('Unknown format in output utils'); return null;
break;
} }
return msg; return msg;
} }
return null;
} }

View File

@@ -4,6 +4,7 @@ const convertModule = require('../convert/index');
class MeasurementContainer { class MeasurementContainer {
constructor(options = {},logger) { constructor(options = {},logger) {
this.logger = logger || null;
this.emitter = new EventEmitter(); this.emitter = new EventEmitter();
this.measurements = {}; this.measurements = {};
this.windowSize = options.windowSize || 10; // Default window size this.windowSize = options.windowSize || 10; // Default window size
@@ -96,7 +97,10 @@ class MeasurementContainer {
variant(variantName) { variant(variantName) {
if (!this._currentType) { if (!this._currentType) {
throw new Error('Type must be specified before variant'); if (this.logger) {
this.logger.warn('variant() ignored: type must be specified before variant');
}
return this;
} }
this._currentVariant = variantName; this._currentVariant = variantName;
this._currentPosition = null; this._currentPosition = null;
@@ -106,11 +110,13 @@ class MeasurementContainer {
position(positionValue) { position(positionValue) {
if (!this._currentVariant) { if (!this._currentVariant) {
throw new Error('Variant must be specified before position'); if (this.logger) {
this.logger.warn('position() ignored: variant must be specified before position');
}
return this;
} }
this._currentPosition = positionValue.toString().toLowerCase();
this._currentPosition = positionValue.toString().toLowerCase();;
return this; return this;
} }
@@ -429,7 +435,10 @@ class MeasurementContainer {
// Difference calculations between positions // Difference calculations between positions
difference({ from = "downstream", to = "upstream", unit: requestedUnit } = {}) { difference({ from = "downstream", to = "upstream", unit: requestedUnit } = {}) {
if (!this._currentType || !this._currentVariant) { if (!this._currentType || !this._currentVariant) {
throw new Error("Type and variant must be specified for difference calculation"); if (this.logger) {
this.logger.warn('difference() ignored: type and variant must be specified');
}
return null;
} }
const get = pos => { const get = pos => {
@@ -510,7 +519,10 @@ class MeasurementContainer {
getVariants() { getVariants() {
if (!this._currentType) { if (!this._currentType) {
throw new Error('Type must be specified before listing variants'); if (this.logger) {
this.logger.warn('getVariants() ignored: type must be specified first');
}
return [];
} }
return this.measurements[this._currentType] ? return this.measurements[this._currentType] ?
Object.keys(this.measurements[this._currentType]) : []; Object.keys(this.measurements[this._currentType]) : [];
@@ -518,7 +530,10 @@ class MeasurementContainer {
getPositions() { getPositions() {
if (!this._currentType || !this._currentVariant) { if (!this._currentType || !this._currentVariant) {
throw new Error('Type and variant must be specified before listing positions'); if (this.logger) {
this.logger.warn('getPositions() ignored: type and variant must be specified first');
}
return [];
} }
if (!this.measurements[this._currentType] || if (!this.measurements[this._currentType] ||
@@ -628,7 +643,10 @@ class MeasurementContainer {
if (positionValue > 0) { if (positionValue > 0) {
return "downstream"; return "downstream";
} }
console.log(`Invalid position provided: ${positionValue}`); if (this.logger) {
this.logger.warn(`Invalid position provided: ${positionValue}`);
}
return null;
} }
} }

7
src/nrmse/index.js Normal file
View File

@@ -0,0 +1,7 @@
const nrmse = require('./errorMetrics.js');
const nrmseConfig = require('./nrmseConfig.json');
module.exports = {
nrmse,
nrmseConfig,
};

5
src/outliers/index.js Normal file
View File

@@ -0,0 +1,5 @@
const outlierDetection = require('./outlierDetection.js');
module.exports = {
outlierDetection,
};

View File

@@ -61,6 +61,8 @@ class DynamicClusterDeviation {
} }
} }
module.exports = DynamicClusterDeviation;
// Rolling window simulation with outlier detection // Rolling window simulation with outlier detection
/* /*
const detector = new DynamicClusterDeviation(); const detector = new DynamicClusterDeviation();
@@ -86,4 +88,4 @@ dataStream.forEach((value, index) => {
}); });
console.log("\nFinal detector cluster states:", JSON.stringify(detector.clusters, null, 2)); console.log("\nFinal detector cluster states:", JSON.stringify(detector.clusters, null, 2));
*/ */

9
src/predict/index.js Normal file
View File

@@ -0,0 +1,9 @@
const predict = require('./predict_class.js');
const interpolation = require('./interpolation.js');
const predictConfig = require('./predictConfig.json');
module.exports = {
predict,
interpolation,
predictConfig,
};

11
src/state/index.js Normal file
View File

@@ -0,0 +1,11 @@
const state = require('./state.js');
const stateManager = require('./stateManager.js');
const movementManager = require('./movementManager.js');
const stateConfig = require('./stateConfig.json');
module.exports = {
state,
stateManager,
movementManager,
stateConfig,
};

View File

@@ -0,0 +1,26 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const EndpointUtils = require('../src/helper/endpointUtils.js');
test('generateMenuUtilsData returns helpers and compatibility options', () => {
const endpointUtils = new EndpointUtils();
const data = endpointUtils.generateMenuUtilsData('measurement', {
customCheck: 'function(value) { return !!value; }',
});
assert.equal(data.nodeName, 'measurement');
assert.equal(typeof data.helpers.validateRequired, 'string');
assert.equal(typeof data.helpers.customCheck, 'string');
assert.equal(data.options.autoLoadLegacy, true);
});
test('generateMenuUtilsBootstrap points to data and legacy endpoints', () => {
const endpointUtils = new EndpointUtils();
const script = endpointUtils.generateMenuUtilsBootstrap('measurement');
assert.match(script, /menuUtilsData\.json/);
assert.match(script, /menuUtils\.legacy\.js/);
assert.match(script, /window\.EVOLV\.nodes/);
});

View File

@@ -24,7 +24,7 @@ test('process format emits message with changed fields only', () => {
assert.deepEqual(first.payload, { a: 1, b: 2 }); assert.deepEqual(first.payload, { a: 1, b: 2 });
const second = out.formatMsg({ a: 1, b: 2 }, config, 'process'); const second = out.formatMsg({ a: 1, b: 2 }, config, 'process');
assert.equal(second, undefined); assert.equal(second, null);
const third = out.formatMsg({ a: 1, b: 3, c: { x: 1 } }, config, 'process'); const third = out.formatMsg({ a: 1, b: 3, c: { x: 1 } }, config, 'process');
assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) }); assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) });