diff --git a/dependencies/monster/SpeficicClass.js b/dependencies/monster/SpeficicClass.js index eeb9c21..cfc8b30 100644 --- a/dependencies/monster/SpeficicClass.js +++ b/dependencies/monster/SpeficicClass.js @@ -105,10 +105,34 @@ class Monster{ this.set_boundries_and_targets(); - } + } - /*------------------- GETTER/SETTERS Dynamics -------------------*/ - set monsternametijden(value){ + _syncOutput() { + this.output = this.output || {}; + this.output.pulse = this.pulse; + this.output.running = this.running; + this.output.bucketVol = this.bucketVol; + this.output.bucketWeight = this.bucketWeight; + this.output.sumPuls = this.sumPuls; + this.output.predFlow = this.predFlow; + this.output.predM3PerSec = this.predM3PerSec; + this.output.timePassed = this.timePassed; + this.output.timeLeft = this.timeLeft; + this.output.m3Total = this.m3Total; + this.output.q = this.q; + this.output.maxVolume = this.maxVolume; + this.output.minVolume = this.minVolume; + this.output.nextDate = this.nextDate; + this.output.daysPerYear = this.daysPerYear; + } + + getOutput() { + this._syncOutput(); + return this.output; + } + + /*------------------- GETTER/SETTERS Dynamics -------------------*/ + set monsternametijden(value){ if(this.init){ if(Object.keys(value).length > 0){ @@ -351,6 +375,19 @@ zip(...arrays) { } get_model_prediction(){ + // Offline-safe fallback: assume constant inflow `q` during `sampling_time`. + // `q` is in m3/h; `sampling_time` is in hours; result is total predicted volume in m3. + const samplingHours = Number(this.sampling_time) || 0; + const flowM3PerHour = Number(this.q) || 0; + const fallback = Math.max(0, flowM3PerHour * samplingHours); + + this.predFlow = fallback; + this._syncOutput(); + return this.predFlow; +} + +// Legacy/experimental model-based prediction (kept for reference; not used by default). +get_model_prediction_from_rain(){ // combine 24 hourly predictions to make one daily prediction (for the next 24 hours including the current hour) let inputs = []; @@ -591,6 +628,8 @@ async model_loader(inputs){ //logQ for predictions / forecasts this.logQoverTime(); + + this._syncOutput(); } regNextDate(monsternametijden){ @@ -675,6 +714,8 @@ async model_loader(inputs){ module.exports = Monster; +// Local smoke-test harness (kept for debugging) should not run when this file is imported by Node-RED. +if (require.main === module) { const mConfig={ general: { @@ -704,3 +745,5 @@ monster.get_model_prediction(); //const intervalId = setInterval(() => {monster.tick();},1000) }) +} + diff --git a/monster.html b/monster.html index 90e425e..a9d205c 100644 --- a/monster.html +++ b/monster.html @@ -6,12 +6,15 @@ RED.nodes.registerType("monster", { category: "EVOLV", color: "#4f8582", - defaults: { + defaults: { - // Define specific properties - samplingtime: { value: 0 }, - minvolume: { value: 5 }, - maxweight: { value: 22 }, + // Define default properties + name: { value: "" }, + + // Define specific properties + samplingtime: { value: 0 }, + minvolume: { value: 5 }, + maxweight: { value: 22 }, emptyWeightBucket: { value: 3 }, aquon_sample_name: { value: "" }, @@ -140,4 +143,4 @@ - \ No newline at end of file + diff --git a/src/nodeClass.js b/src/nodeClass.js index a72ca9c..bbe71af 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -41,10 +41,13 @@ class nodeClass { * @param {object} uiConfig - Raw config from Node-RED UI. */ _loadConfig(uiConfig,node) { + 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: { @@ -53,16 +56,23 @@ class nodeClass { } }, asset: { - uuid: uiConfig.assetUuid, //need to add this later to the asset model - tagCode: uiConfig.assetTagCode, //need to add this later to the asset model + 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 + unit: uiConfig.unit, + emptyWeightBucket: Number(uiConfig.emptyWeightBucket) + }, + constraints: { + samplingtime: Number(uiConfig.samplingtime), + minVolume: Number(uiConfig.minvolume), + maxWeight: Number(uiConfig.maxweight), }, functionality: { - positionVsParent: uiConfig.positionVsParent + positionVsParent: uiConfig.positionVsParent || 'atEquipment', + distance: uiConfig.hasDistance ? uiConfig.distance : undefined } }; @@ -78,6 +88,10 @@ class nodeClass { this.source = new Specific(monsterConfig); + 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 @@ -111,7 +125,7 @@ try{ return status; } catch (error) { - node.error("Error in updateNodeStatus: " + error); + this.node.error("Error in updateNodeStatus: " + error); return { fill: "red", shape: "ring", text: "Status Error" }; } } @@ -166,42 +180,53 @@ try{ 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; - switch(msg.topic) { - case 'registerChild': - // Register this node as a child of the parent node - const childId = msg.payload; - const childObj = this.RED.nodes.getNode(childId); - m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent); - break; - 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': - m.showWorkingCurves(); - send({ topic : "Showing curve" , payload: m.showWorkingCurves() }); - break; - case 'CoG': - m.showCoG(); - send({ topic : "Showing CoG" , payload: m.showCoG() }); - break; + try { + switch(msg.topic) { + case 'registerChild': { + const childId = msg.payload; + const childObj = this.RED.nodes.getNode(childId); + if (childObj?.source) { + m.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent); + } + break; } + 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() }); + break; + default: + break; + } + } catch (error) { + this.node.error(`Error handling input (${msg?.topic}): ${error?.message || error}`); + } finally { + done(); + } }); } diff --git a/src/specificClass.js b/src/specificClass.js new file mode 100644 index 0000000..a902f81 --- /dev/null +++ b/src/specificClass.js @@ -0,0 +1,2 @@ +module.exports = require("../dependencies/monster/SpeficicClass"); + diff --git a/test/monster.test.js b/test/monster.test.js new file mode 100644 index 0000000..3f638ce --- /dev/null +++ b/test/monster.test.js @@ -0,0 +1,121 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Monster = require('../src/specificClass'); + +function createConfig(overrides = {}) { + return { + general: { + name: 'monster-test', + logging: { enabled: false, logLevel: 'error' }, + }, + asset: { + emptyWeightBucket: 3, + ...overrides.asset, + }, + constraints: { + samplingtime: 24, + minVolume: 5, + maxWeight: 23, + ...overrides.constraints, + }, + ...overrides, + }; +} + +test('constructor derives boundaries and targets from config', () => { + const monster = new Monster(createConfig()); + + assert.equal(monster.maxVolume, 20); + assert.equal(monster.minPuls, 100); + assert.equal(monster.maxPuls, 400); + assert.equal(monster.absMaxPuls, 1100); + assert.equal(monster.targetVolume, 10); + assert.equal(monster.targetPuls, 200); + assert.equal(monster.running, false); +}); + +test('bucket volume updates output and bucket weight', () => { + const monster = new Monster(createConfig()); + monster.bucketVol = 1.5; + + assert.equal(monster.bucketVol, 1.5); + assert.equal(monster.bucketWeight, 4.5); + + const out = monster.getOutput(); + assert.equal(out.bucketVol, 1.5); + assert.equal(out.bucketWeight, 4.5); +}); + +test('monsternametijden setter registers next date for matching sample', () => { + const monster = new Monster(createConfig()); + const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + monster.monsternametijden = [ + { + SAMPLE_NAME: monster.aquonSampleName, + DESCRIPTION: 'test', + SAMPLED_DATE: future, + START_DATE: future, + END_DATE: future, + }, + ]; + + assert.ok(monster.nextDate >= Date.now()); + assert.ok(monster.daysPerYear >= 1); +}); + +test('sampling_program starts and emits pulses based on m3PerTick', () => { + const monster = new Monster( + createConfig({ + constraints: { + samplingtime: 1, + minVolume: 0.1, + maxWeight: 23, + }, + }) + ); + + monster.nextDate = Date.now() + 60_000; + monster.i_start = true; + monster.q = 28; // => predFlow = 28 m3 for 1 hour (fallback) + monster.m3PerTick = 1; // with m3PerPuls ~1 this should trigger a pulse + + monster.sampling_program(); + + assert.equal(monster.running, true); + assert.equal(monster.sumPuls, 1); + assert.equal(monster.pulse, true); + assert.equal(monster.bucketVol, 0.05); + + // Next loop without flow should stop pulsing + monster.m3PerTick = 0; + monster.sampling_program(); + assert.equal(monster.pulse, false); +}); + +test('sampling_program stops and resets after stop_time has passed', () => { + const monster = new Monster( + createConfig({ + constraints: { samplingtime: 1, minVolume: 0.1, maxWeight: 23 }, + }) + ); + + monster.running = true; + monster.stop_time = Date.now() - 1; + monster.sumPuls = 10; + monster.bucketVol = 0.5; + monster.predFlow = 123; + monster.predM3PerSec = 1; + monster.m3Total = 10; + + monster.sampling_program(); + + assert.equal(monster.running, false); + assert.equal(monster.sumPuls, 0); + assert.equal(monster.bucketVol, 0); + assert.equal(monster.predFlow, 0); + assert.equal(monster.predM3PerSec, 0); + assert.equal(monster.m3Total, 0); +}); +