diff --git a/index.js b/index.js index 281fe51..adc1aff 100644 --- a/index.js +++ b/index.js @@ -8,25 +8,27 @@ */ // Core helper modules -const outputUtils = require('./src/helper/outputUtils.js'); -const logger = require('./src/helper/logger.js'); -const validation = require('./src/helper/validationUtils.js'); -const configUtils = require('./src/helper/configUtils.js'); -const assertions = require('./src/helper/assertionUtils.js') +const helper = require('./src/helper/index.js'); +const { + outputUtils, + logger, + validation, + configUtils, + assertions, + childRegistrationUtils, + gravity, +} = helper; const coolprop = require('./src/coolprop-node/src/index.js'); -const gravity = require('./src/helper/gravity.js') const assetApiConfig = require('./src/configs/assetApiConfig.js'); // Domain-specific modules const { MeasurementContainer } = require('./src/measurements/index.js'); const configManager = require('./src/configs/index.js'); -const nrmse = require('./src/nrmse/errorMetrics.js'); -const state = require('./src/state/state.js'); +const { nrmse } = require('./src/nrmse/index.js'); +const { state } = require('./src/state/index.js'); const convert = require('./src/convert/index.js'); const MenuManager = require('./src/menu/index.js'); -const predict = require('./src/predict/predict_class.js'); -const interpolation = require('./src/predict/interpolation.js'); -const childRegistrationUtils = require('./src/helper/childRegistrationUtils.js'); +const { predict, interpolation } = require('./src/predict/index.js'); const { loadCurve } = require('./datasets/assetData/curves/index.js'); //deprecated replace with load model data const { loadModel } = require('./datasets/assetData/modelData/index.js'); diff --git a/package.json b/package.json index 944f4a0..030a360 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,16 @@ "./menuUtils": "./src/helper/menuUtils.js", "./mathUtils": "./src/helper/mathUtils.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": { - "test": "node test.js" + "test": "node --test test/*.test.js src/nrmse/errorMetric.test.js" }, "repository": { "type": "git", @@ -26,4 +31,4 @@ ], "author": "Rene de Ren", "license": "SEE LICENSE" -} \ No newline at end of file +} diff --git a/src/helper/childRegistrationUtils.js b/src/helper/childRegistrationUtils.js index 6433a1e..8e51002 100644 --- a/src/helper/childRegistrationUtils.js +++ b/src/helper/childRegistrationUtils.js @@ -6,8 +6,18 @@ class ChildRegistrationUtils { } async registerChild(child, positionVsParent, distance) { - const { softwareType } = child.config.functionality; - const { name, id } = child.config.general; + if (!child || typeof child !== 'object') { + 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}`); @@ -43,19 +53,21 @@ class ChildRegistrationUtils { } this.logger.info(`✅ Child ${name} registered successfully`); + return true; } _storeChild(child, softwareType) { // Maintain your existing structure 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 || {}; - if (!this.mainClass.child[softwareType][category]) { - this.mainClass.child[softwareType][category] = []; + if (!this.mainClass.child[typeKey][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 @@ -95,4 +107,4 @@ class ChildRegistrationUtils { } } -module.exports = ChildRegistrationUtils; \ No newline at end of file +module.exports = ChildRegistrationUtils; diff --git a/src/helper/configUtils.js b/src/helper/configUtils.js index 58cb334..e30a430 100644 --- a/src/helper/configUtils.js +++ b/src/helper/configUtils.js @@ -73,17 +73,25 @@ class ConfigUtils { 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 mergeObjects(obj1, obj2) { for (let key in obj2) { if (obj2.hasOwnProperty(key)) { - if (typeof obj2[key] === 'object') { - if (!obj1[key]) { + const nextValue = obj2[key]; + + if (Array.isArray(nextValue)) { + obj1[key] = [...nextValue]; + } else if (this._isPlainObject(nextValue)) { + if (!this._isPlainObject(obj1[key])) { obj1[key] = {}; } - this.mergeObjects(obj1[key], obj2[key]); + this.mergeObjects(obj1[key], nextValue); } else { - obj1[key] = obj2[key]; + obj1[key] = nextValue; } } } diff --git a/src/helper/endpointUtils.js b/src/helper/endpointUtils.js index 4a20522..2594c14 100644 --- a/src/helper/endpointUtils.js +++ b/src/helper/endpointUtils.js @@ -18,13 +18,102 @@ class EndpointUtils { * @param {string} nodeName the name of the node (used in the URL) * @param {object} customHelpers additional helper functions to inject */ - createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) { - RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, (req, res) => { - console.log(`Serving menuUtils.js for ${nodeName} node`); - res.set('Content-Type', 'application/javascript'); - const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers); - res.send(browserCode); + createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}, options = {}) { + const basePath = `/${nodeName}/resources`; + + RED.httpAdmin.get(`${basePath}/menuUtilsData.json`, (req, res) => { + res.json(this.generateMenuUtilsData(nodeName, customHelpers, options)); }); + + 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 * @returns {string} a JS snippet to run in the browser */ - generateMenuUtilsCode(nodeName, customHelpers = {}) { + generateLegacyMenuUtilsCode(nodeName, customHelpers = {}) { // Default helper implementations to expose alongside MenuUtils const defaultHelpers = { validateRequired: `function(value) { @@ -101,6 +190,11 @@ ${helpersCode} console.log('Loaded EVOLV.nodes.${nodeName}.utils.menuUtils'); `; } + + // Backward-compatible alias. + generateMenuUtilsCode(nodeName, customHelpers = {}) { + return this.generateLegacyMenuUtilsCode(nodeName, customHelpers); + } } module.exports = EndpointUtils; diff --git a/src/helper/index.js b/src/helper/index.js new file mode 100644 index 0000000..73f432d --- /dev/null +++ b/src/helper/index.js @@ -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, +}; diff --git a/src/helper/menuUtils.js b/src/helper/menuUtils.js index 4cb6b84..a45c786 100644 --- a/src/helper/menuUtils.js +++ b/src/helper/menuUtils.js @@ -480,17 +480,26 @@ generateHtml(htmlElement, options, savedValue) { } } -createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) { - RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, function(req, res) { - console.log(`Serving menuUtils.js for ${nodeName} node`); +createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}, options = {}) { + const basePath = `/${nodeName}/resources`; + + 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'); - - const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers); + const browserCode = this.generateLegacyMenuUtilsCode(nodeName, customHelpers); res.send(browserCode); }.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 = { validateRequired: `function(value) { 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) .map(([name, func]) => ` ${name}: ${func}`) @@ -533,6 +606,11 @@ ${helpersCode} `; } +// Backward-compatible alias +generateMenuUtilsCode(nodeName, customHelpers = {}) { + return this.generateLegacyMenuUtilsCode(nodeName, customHelpers); } -module.exports = MenuUtils; \ No newline at end of file +} + +module.exports = MenuUtils; diff --git a/src/helper/nodeTemplates.js b/src/helper/nodeTemplates.js index da259e4..9f61b6d 100644 --- a/src/helper/nodeTemplates.js +++ b/src/helper/nodeTemplates.js @@ -53,4 +53,4 @@ const nodeTemplates = { // …add more node “templates” here… }; -export default nodeTemplates; +module.exports = nodeTemplates; diff --git a/src/helper/outputUtils.js b/src/helper/outputUtils.js index 744b2ea..6674733 100644 --- a/src/helper/outputUtils.js +++ b/src/helper/outputUtils.js @@ -7,6 +7,9 @@ class OutputUtils { } checkForChanges(output, format) { + if (!output || typeof output !== 'object') { + return {}; + } const changedFields = {}; for (const key in output) { if (output.hasOwnProperty(key) && output[key] !== this.output[format][key]) { @@ -54,11 +57,11 @@ class OutputUtils { break; default: - console.log('Unknown format in output utils'); - break; + return null; } return msg; } + return null; } diff --git a/src/measurements/MeasurementContainer.js b/src/measurements/MeasurementContainer.js index 21412d0..682cf08 100644 --- a/src/measurements/MeasurementContainer.js +++ b/src/measurements/MeasurementContainer.js @@ -4,6 +4,7 @@ const convertModule = require('../convert/index'); class MeasurementContainer { constructor(options = {},logger) { + this.logger = logger || null; this.emitter = new EventEmitter(); this.measurements = {}; this.windowSize = options.windowSize || 10; // Default window size @@ -96,7 +97,10 @@ class MeasurementContainer { variant(variantName) { 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._currentPosition = null; @@ -106,11 +110,13 @@ class MeasurementContainer { position(positionValue) { 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; } @@ -429,7 +435,10 @@ class MeasurementContainer { // Difference calculations between positions difference({ from = "downstream", to = "upstream", unit: requestedUnit } = {}) { 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 => { @@ -510,7 +519,10 @@ class MeasurementContainer { getVariants() { 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] ? Object.keys(this.measurements[this._currentType]) : []; @@ -518,7 +530,10 @@ class MeasurementContainer { getPositions() { 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] || @@ -628,7 +643,10 @@ class MeasurementContainer { if (positionValue > 0) { return "downstream"; } - console.log(`Invalid position provided: ${positionValue}`); + if (this.logger) { + this.logger.warn(`Invalid position provided: ${positionValue}`); + } + return null; } } diff --git a/src/nrmse/index.js b/src/nrmse/index.js new file mode 100644 index 0000000..4952fb4 --- /dev/null +++ b/src/nrmse/index.js @@ -0,0 +1,7 @@ +const nrmse = require('./errorMetrics.js'); +const nrmseConfig = require('./nrmseConfig.json'); + +module.exports = { + nrmse, + nrmseConfig, +}; diff --git a/src/outliers/index.js b/src/outliers/index.js new file mode 100644 index 0000000..40e6b23 --- /dev/null +++ b/src/outliers/index.js @@ -0,0 +1,5 @@ +const outlierDetection = require('./outlierDetection.js'); + +module.exports = { + outlierDetection, +}; diff --git a/src/outliers/outlierDetection.js b/src/outliers/outlierDetection.js index 2cdca81..91db9b0 100644 --- a/src/outliers/outlierDetection.js +++ b/src/outliers/outlierDetection.js @@ -61,6 +61,8 @@ class DynamicClusterDeviation { } } +module.exports = DynamicClusterDeviation; + // Rolling window simulation with outlier detection /* const detector = new DynamicClusterDeviation(); @@ -86,4 +88,4 @@ dataStream.forEach((value, index) => { }); console.log("\nFinal detector cluster states:", JSON.stringify(detector.clusters, null, 2)); -*/ \ No newline at end of file +*/ diff --git a/src/predict/index.js b/src/predict/index.js new file mode 100644 index 0000000..a34da31 --- /dev/null +++ b/src/predict/index.js @@ -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, +}; diff --git a/src/state/index.js b/src/state/index.js new file mode 100644 index 0000000..daea547 --- /dev/null +++ b/src/state/index.js @@ -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, +}; diff --git a/test/endpoint-utils.test.js b/test/endpoint-utils.test.js new file mode 100644 index 0000000..3970349 --- /dev/null +++ b/test/endpoint-utils.test.js @@ -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/); +}); + diff --git a/test/output-utils.test.js b/test/output-utils.test.js index d901b2d..1824940 100644 --- a/test/output-utils.test.js +++ b/test/output-utils.test.js @@ -24,7 +24,7 @@ test('process format emits message with changed fields only', () => { assert.deepEqual(first.payload, { a: 1, b: 2 }); 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'); assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) });