diff --git a/monster.html b/monster.html index 044c5d8..5f52b5b 100644 --- a/monster.html +++ b/monster.html @@ -10,6 +10,8 @@ // Define default properties name: { value: "" }, + processOutputFormat: { value: "process" }, + dbaseOutputFormat: { value: "influxdb" }, // Define specific properties samplingtime: { value: 0 }, @@ -64,7 +66,7 @@ }; waitForMenuData(); - // your existing project‐settings & asset dropdown logic can remain here + // your existing project-settings & asset dropdown logic can remain here document.getElementById("node-input-samplingtime"); document.getElementById("node-input-minvolume"); document.getElementById("node-input-maxweight"); diff --git a/monster.js b/monster.js index ae13f64..3f14963 100644 --- a/monster.js +++ b/monster.js @@ -1,5 +1,5 @@ const nameOfNode = 'monster'; -const nodeClass = require('./src/nodeClass.js'); +const nodeClass = require('./src/nodeClass.js'); const { MenuManager, configManager } = require('generalFunctions'); module.exports = function(RED) { diff --git a/src/nodeClass.js b/src/nodeClass.js index 04887c2..6621a99 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -5,25 +5,25 @@ * This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers. */ const { outputUtils, configManager, convert } = require('generalFunctions'); -const Specific = require("./specificClass"); +const Specific = require('./specificClass'); class nodeClass { /** * Create a Node. * @param {object} uiConfig - Node-RED node configuration. * @param {object} RED - Node-RED runtime API. + * @param {object} nodeInstance - The Node-RED node instance. + * @param {string} nameOfNode - The name of the node. */ constructor(uiConfig, RED, nodeInstance, nameOfNode) { - - // Preserve RED reference for HTTP endpoints if needed - this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status - this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed - this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED - this.source = null; // Will hold the specific class instance - this.config = null; // Will hold the merged configuration + this.node = nodeInstance; + this.RED = RED; + this.name = nameOfNode; + this.source = null; + this.config = null; // Load default & UI config - this._loadConfig(uiConfig,this.node); + this._loadConfig(uiConfig); // Instantiate core class this._setupSpecificClass(uiConfig); @@ -38,46 +38,34 @@ class nodeClass { /** * Load and merge default config with user-defined settings. + * Uses ConfigManager.buildConfig() for base sections, then adds monster-specific domain config. * @param {object} uiConfig - Raw config from Node-RED UI. */ - _loadConfig(uiConfig,node) { + _loadConfig(uiConfig) { const cfgMgr = new configManager(); - this.defaultConfig = cfgMgr.getConfig(this.name); - // Merge UI config over defaults - this.config = { - general: { - name: uiConfig.name || uiConfig.category || this.name, - id: node.id, // node.id is for the child registration process - unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards) - logging: { - enabled: uiConfig.enableLog, - logLevel: uiConfig.logLevel - } - }, - asset: { - uuid: uiConfig.uuid, - tagCode: uiConfig.assetTagCode, - supplier: uiConfig.supplier, - category: uiConfig.category, //add later to define as the software type - type: uiConfig.assetType, - model: uiConfig.model, - unit: uiConfig.unit, - emptyWeightBucket: Number(uiConfig.emptyWeightBucket) - }, + // Build config: base sections + monster-specific domain config + this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, { constraints: { - samplingtime: Number(uiConfig.samplingtime), - minVolume: Number(uiConfig.minvolume), - maxWeight: Number(uiConfig.maxweight), - nominalFlowMin: Number(uiConfig.nominalFlowMin), - flowMax: Number(uiConfig.flowMax), - maxRainRef: Number(uiConfig.maxRainRef), - minSampleIntervalSec: Number(uiConfig.minSampleIntervalSec), + samplingtime: Number(uiConfig.samplingtime) || 0, + minVolume: Number(uiConfig.minvolume ?? uiConfig.minVolume) || 5, + maxWeight: Number(uiConfig.maxweight ?? uiConfig.maxWeight) || 23, + nominalFlowMin: Number(uiConfig.nominalFlowMin) || 0, + flowMax: Number(uiConfig.flowMax) || 0, + maxRainRef: Number(uiConfig.maxRainRef) || 10, + minSampleIntervalSec: Number(uiConfig.minSampleIntervalSec) || 60, }, - functionality: { - positionVsParent: uiConfig.positionVsParent || 'atEquipment', - distance: uiConfig.hasDistance ? uiConfig.distance : undefined - } + }); + + this.config.functionality = { + ...this.config.functionality, + role: 'samplingCabinet', + aquonSampleName: uiConfig.aquon_sample_name || undefined, + }; + + this.config.asset = { + ...this.config.asset, + emptyWeightBucket: Number(uiConfig.emptyWeightBucket) || 3, }; // Utility for formatting outputs @@ -85,67 +73,62 @@ class nodeClass { } /** - * Instantiate the core Measurement logic and store as source. + * Instantiate the core logic and store as source. */ _setupSpecificClass(uiConfig) { - const monsterConfig = this.config; - - this.source = new Specific(monsterConfig); + this.source = new Specific(this.config); if (uiConfig?.aquon_sample_name) { this.source.aquonSampleName = uiConfig.aquon_sample_name; } - //store in node - this.node.source = this.source; // Store the source in the node instance for easy access - + this.node.source = this.source; } /** - * Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES + * Bind events to Node-RED status updates. */ - _bindEvents() { - - } + _bindEvents() {} _updateNodeStatus() { const m = this.source; -try{ - const bucketVol = m.bucketVol; - const maxVolume = m.maxVolume; - const state = m.running; - const mode = "AI"; //m.mode; - const flowMin = m.nominalFlowMin; - const flowMax = m.flowMax; + try { + const bucketVol = m.bucketVol; + const maxVolume = m.maxVolume; + const state = m.running; + const mode = 'AI'; + const flowMin = m.nominalFlowMin; + const flowMax = m.flowMax; - if (m.invalidFlowBounds) { - return { - fill: "red", - shape: "ring", - text: `Config error: nominalFlowMin (${flowMin}) >= flowMax (${flowMax})` - }; - } - - if (state) { - const levelText = `${bucketVol}/${maxVolume} L`; - const cooldownMs = typeof m.getSampleCooldownMs === 'function' - ? m.getSampleCooldownMs() - : 0; - - if (cooldownMs > 0) { - const cooldownSec = Math.ceil(cooldownMs / 1000); - return { fill: "yellow", shape: "ring", text: `SAMPLING (${cooldownSec}s) ${levelText}` }; - } - - return { fill: "green", shape: "dot", text: `${mode}: RUNNING ${levelText}` }; - } - - return { fill: "grey", shape: "ring", text: `${mode}: IDLE` }; - } catch (error) { - this.node.error("Error in updateNodeStatus: " + error); - return { fill: "red", shape: "ring", text: "Status Error" }; + if (m.invalidFlowBounds) { + return { + fill: 'red', + shape: 'ring', + text: `Config error: nominalFlowMin (${flowMin}) >= flowMax (${flowMax})`, + }; } + + if (state) { + const levelText = `${bucketVol}/${maxVolume} L`; + const cooldownMs = typeof m.getSampleCooldownMs === 'function' + ? m.getSampleCooldownMs() + : 0; + + if (cooldownMs > 0) { + const cooldownSec = Math.ceil(cooldownMs / 1000); + return { fill: 'yellow', shape: 'ring', text: `SAMPLING (${cooldownSec}s) ${levelText}` }; + } + + return { fill: 'green', shape: 'dot', text: `${mode}: RUNNING ${levelText}` }; + } + + return { fill: 'grey', shape: 'ring', text: `${mode}: IDLE` }; + } catch (error) { + this.node.error(`Error in updateNodeStatus: ${error.message}`); + return { fill: 'red', shape: 'ring', text: 'Status Error' }; } + } + /** * Register this node as a child upstream and downstream. * Delayed to avoid Node-RED startup race conditions. @@ -166,13 +149,9 @@ try{ _startTickLoop() { setTimeout(() => { this._tickInterval = setInterval(() => this._tick(), 1000); - - // Update node status on nodered screen every second ( this is not the best way to do this, but it works for now) this._statusInterval = setInterval(() => { - const status = this._updateNodeStatus(); - this.node.status(status); + this.node.status(this._updateNodeStatus()); }, 1000); - }, 1000); } @@ -184,9 +163,8 @@ try{ const raw = this.source.getOutput(); const processMsg = this._output.formatMsg(raw, this.source.config, 'process'); - const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb'); + const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb'); - // Send only updated outputs on ports 0 & 1 this.node.send([processMsg, influxMsg]); } @@ -195,10 +173,9 @@ try{ */ _attachInputHandler() { this.node.on('input', (msg, send, done) => { - /* Update to complete event based node by putting the tick function after an input event */ const m = this.source; try { - switch(msg.topic) { + switch (msg.topic) { case 'input_q': { const value = Number(msg.payload?.value); const unit = msg.payload?.unit; @@ -232,33 +209,13 @@ try{ case 'setMode': m.setMode(msg.payload); break; - case 'execSequence': { - const { source, action, parameter } = msg.payload || {}; - m.handleInput(source, action, parameter); - break; - } - case 'execMovement': { - const { source: mvSource, action: mvAction, setpoint } = msg.payload || {}; - m.handleInput(mvSource, mvAction, Number(setpoint)); - break; - } - case 'flowMovement': { - const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload || {}; - m.handleInput(fmSource, fmAction, Number(fmSetpoint)); - break; - } - case 'emergencystop': { - const { source: esSource, action: esAction } = msg.payload || {}; - m.handleInput(esSource, esAction); - break; - } - case 'showWorkingCurves': - send({ topic : "Showing curve" , payload: m.showWorkingCurves() }); - break; - case 'CoG': - send({ topic : "Showing CoG" , payload: m.showCoG() }); + case 'model_prediction': + if (typeof m.setModelPrediction === 'function') { + m.setModelPrediction(msg.payload); + } break; default: + m.logger?.warn(`Unknown topic: ${msg.topic}`); break; } } catch (error) { diff --git a/test/specificClass.test.js b/test/specificClass.test.js new file mode 100644 index 0000000..8adbdb7 --- /dev/null +++ b/test/specificClass.test.js @@ -0,0 +1,95 @@ +const Monster = require('../src/specificClass'); + +describe('monster specificClass', () => { + function createMonster(overrides = {}) { + return new Monster({ + general: { + name: 'Monster Test', + unit: 'm3/h', + logging: { + enabled: false, + logLevel: 'error', + }, + }, + asset: { + emptyWeightBucket: 3, + }, + constraints: { + samplingtime: 1, + minVolume: 5, + maxWeight: 23, + }, + functionality: { + aquonSampleName: '112100', + }, + ...overrides, + }); + } + + test('aggregates rain data and exposes output state', () => { + const monster = createMonster(); + + monster.rain_data = [ + { + latitude: 51.7, + longitude: 4.81, + hourly: { + time: ['2026-03-12T00:00', '2026-03-12T01:00'], + precipitation: [1, 3], + precipitation_probability: [100, 50], + }, + }, + { + latitude: 51.8, + longitude: 4.91, + hourly: { + time: ['2026-03-12T00:00', '2026-03-12T01:00'], + precipitation: [2, 2], + precipitation_probability: [100, 100], + }, + }, + ]; + + const output = monster.getOutput(); + + expect(monster.sumRain).toBe(6.5); + expect(monster.avgRain).toBe(3.25); + expect(output.sumRain).toBe(6.5); + expect(output.avgRain).toBe(3.25); + }); + + test('supports external prediction input and starts sampling safely', () => { + const monster = createMonster(); + + monster.setModelPrediction(120); + monster.q = 3600; + monster.i_start = true; + monster.flowTime = Date.now() - 1000; + + monster.tick(); + + const output = monster.getOutput(); + expect(output.running).toBe(true); + expect(output.predFlow).toBe(120); + expect(output.predM3PerSec).toBeCloseTo(120 / 3600, 6); + }); + + test('calculates the next AQUON date from monsternametijden input', () => { + const monster = createMonster(); + const nextMonth = new Date(); + nextMonth.setMonth(nextMonth.getMonth() + 1); + + monster.monsternametijden = [ + { + SAMPLE_NAME: '112100', + DESCRIPTION: 'future sample', + SAMPLED_DATE: null, + START_DATE: nextMonth.toISOString(), + END_DATE: nextMonth.toISOString(), + }, + ]; + + expect(monster.daysPerYear).toBeGreaterThanOrEqual(0); + expect(monster.nextDate).toBeGreaterThan(Date.now()); + }); +});