Merge commit 'ae46e00' into dev-Rene

# Conflicts:
#	config/exampleArray.js
#	dependencies/monster/monster_class.js
#	monster.html
#	monster.js
#	src/nodeClass.js
#	src/specificClass.js
This commit is contained in:
znetsixe
2026-03-31 18:17:09 +02:00
4 changed files with 177 additions and 123 deletions

View File

@@ -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 projectsettings & 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");

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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());
});
});