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:
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
199
src/nodeClass.js
199
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) {
|
||||
|
||||
95
test/specificClass.test.js
Normal file
95
test/specificClass.test.js
Normal 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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user