diff --git a/monster.html b/monster.html
index a9d205c..4d7a373 100644
--- a/monster.html
+++ b/monster.html
@@ -14,7 +14,11 @@
// Define specific properties
samplingtime: { value: 0 },
minvolume: { value: 5 },
- maxweight: { value: 22 },
+ maxweight: { value: 22 },
+ nominalFlowMin: { value: 0 },
+ flowMax: { value: 0 },
+ maxRainRef: { value: 10 },
+ minSampleIntervalSec: { value: 60 },
emptyWeightBucket: { value: 3 },
aquon_sample_name: { value: "" },
@@ -64,8 +68,95 @@
document.getElementById("node-input-samplingtime");
document.getElementById("node-input-minvolume");
document.getElementById("node-input-maxweight");
+ document.getElementById("node-input-nominalFlowMin");
+ document.getElementById("node-input-flowMax");
+ document.getElementById("node-input-maxRainRef");
+ document.getElementById("node-input-minSampleIntervalSec");
document.getElementById("node-input-emptyWeightBucket");
- document.getElementById("node-input-aquon_sample_name");
+ const aquonSelect = document.getElementById("node-input-aquon_sample_name");
+
+ if (aquonSelect) {
+ const menuData = window.EVOLV?.nodes?.monster?.menuData?.aquon || {};
+ const options = menuData.samples || [];
+ const specs = menuData.specs || {};
+ const defaultSpec = specs.defaults || {};
+ const specMap = specs.bySample || {};
+
+ const setReadOnly = () => {};
+
+ const applySpec = (spec) => {
+ const merged = {
+ samplingtime: defaultSpec.samplingtime,
+ minvolume: defaultSpec.minvolume,
+ maxweight: defaultSpec.maxweight,
+ emptyWeightBucket: defaultSpec.emptyWeightBucket,
+ ...(spec || {})
+ };
+
+ const samplingTimeEl = document.getElementById("node-input-samplingtime");
+ const minVolumeEl = document.getElementById("node-input-minvolume");
+ const maxWeightEl = document.getElementById("node-input-maxweight");
+ const nominalFlowMinEl = document.getElementById("node-input-nominalFlowMin");
+ const flowMaxEl = document.getElementById("node-input-flowMax");
+ const maxRainEl = document.getElementById("node-input-maxRainRef");
+ const minSampleIntervalEl = document.getElementById("node-input-minSampleIntervalSec");
+ const emptyWeightEl = document.getElementById("node-input-emptyWeightBucket");
+
+ if (samplingTimeEl && merged.samplingtime !== undefined) {
+ samplingTimeEl.value = merged.samplingtime;
+ }
+ if (minVolumeEl && merged.minvolume !== undefined) {
+ minVolumeEl.value = merged.minvolume;
+ }
+ if (maxWeightEl && merged.maxweight !== undefined) {
+ maxWeightEl.value = merged.maxweight;
+ }
+ if (nominalFlowMinEl && merged.nominalFlowMin !== undefined) {
+ nominalFlowMinEl.value = merged.nominalFlowMin;
+ }
+ if (flowMaxEl && merged.flowMax !== undefined) {
+ flowMaxEl.value = merged.flowMax;
+ }
+ if (maxRainEl && merged.maxRainRef !== undefined) {
+ maxRainEl.value = merged.maxRainRef;
+ }
+ if (minSampleIntervalEl && merged.minSampleIntervalSec !== undefined) {
+ minSampleIntervalEl.value = merged.minSampleIntervalSec;
+ }
+ if (emptyWeightEl && merged.emptyWeightBucket !== undefined) {
+ emptyWeightEl.value = merged.emptyWeightBucket;
+ }
+
+ };
+
+ aquonSelect.innerHTML = "";
+
+ const emptyOption = document.createElement("option");
+ emptyOption.value = "";
+ emptyOption.textContent = "Select sample...";
+ aquonSelect.appendChild(emptyOption);
+
+ options.forEach((option) => {
+ const optionElement = document.createElement("option");
+ optionElement.value = option.code;
+ optionElement.textContent = `${option.code} - ${option.description}`;
+ optionElement.title = option.description || option.code;
+ aquonSelect.appendChild(optionElement);
+ });
+
+ if (this.aquon_sample_name) {
+ aquonSelect.value = this.aquon_sample_name;
+ }
+
+ aquonSelect.addEventListener("change", () => {
+ const selected = aquonSelect.value;
+ if (!selected) {
+ return;
+ }
+ const selectedSpec = specMap[selected] || {};
+ applySpec(selectedSpec);
+ });
+ }
},
oneditsave: function() {
@@ -84,9 +175,17 @@
window.EVOLV.nodes.monster.positionMenu.saveEditor(this);
}
- ["samplingtime", "minvolume", "maxweight", "emptyWeightBucket"].forEach((field) => {
+ const normalizeNumber = (value) => {
+ if (typeof value !== "string") {
+ return value;
+ }
+ return value.replace(",", ".");
+ };
+
+ ["samplingtime", "minvolume", "maxweight", "nominalFlowMin", "flowMax", "maxRainRef", "minSampleIntervalSec", "emptyWeightBucket"].forEach((field) => {
const element = document.getElementById(`node-input-${field}`);
- const value = parseFloat(element?.value) || 0;
+ const rawValue = normalizeNumber(element?.value || "");
+ const value = parseFloat(rawValue) || 0;
console.log(`----------------> Saving ${field}: ${value}`);
node[field] = value;
});
@@ -105,7 +204,8 @@
diff --git a/monster.js b/monster.js
index 3850feb..ae13f64 100644
--- a/monster.js
+++ b/monster.js
@@ -16,7 +16,7 @@ module.exports = function(RED) {
// Serve /monster/menu.js
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
try {
- const script = menuMgr.createEndpoint(nameOfNode, ['logger','position']);
+ const script = menuMgr.createEndpoint(nameOfNode, ['logger', 'position', 'aquon']);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating menu: ${err.message}`);
@@ -32,4 +32,4 @@ module.exports = function(RED) {
res.status(500).send(`// Error generating configData: ${err.message}`);
}
});
-};
\ No newline at end of file
+};
diff --git a/package.json b/package.json
index 5714188..72d658f 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "Control module Monsternamekast",
"main": "monster.js",
"scripts": {
- "test": "node monster.js"
+ "test": "node test/monster.specific.test.js"
},
"repository": {
"type": "git",
diff --git a/src/nodeClass.js b/src/nodeClass.js
index bbe71af..d92c73f 100644
--- a/src/nodeClass.js
+++ b/src/nodeClass.js
@@ -4,7 +4,7 @@
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
*/
-const { outputUtils, configManager } = require('generalFunctions');
+const { outputUtils, configManager, convert } = require('generalFunctions');
const Specific = require("./specificClass");
class nodeClass {
@@ -69,6 +69,10 @@ class nodeClass {
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),
},
functionality: {
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
@@ -110,20 +114,33 @@ try{
const bucketVol = m.bucketVol;
const maxVolume = m.maxVolume;
const state = m.running;
- const mode = "AI" ; //m.mode;
+ const mode = "AI"; //m.mode;
+ const flowMin = m.nominalFlowMin;
+ const flowMax = m.flowMax;
- let status;
-
- switch (state) {
- case false:
- status = { fill: "red", shape: "dot", text: `${mode}: OFF` };
- break;
- case true:
- status = { fill: "green", shape: "dot", text: `${mode}: ON => ${bucketVol} | ${maxVolume}` };
- break;
+ if (m.invalidFlowBounds) {
+ return {
+ fill: "red",
+ shape: "ring",
+ text: `Config error: nominalFlowMin (${flowMin}) >= flowMax (${flowMax})`
+ };
}
- return status;
+ 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" };
@@ -182,6 +199,28 @@ try{
const m = this.source;
try {
switch(msg.topic) {
+ case 'input_q': {
+ const value = Number(msg.payload?.value);
+ const unit = msg.payload?.unit;
+ if (!Number.isFinite(value) || !unit) {
+ this.node.warn('input_q payload must include numeric value and unit.');
+ break;
+ }
+ let converted = value;
+ try {
+ converted = convert(value).from(unit).to('m3/h');
+ } catch (error) {
+ this.node.warn(`input_q unit conversion failed: ${error.message}`);
+ break;
+ }
+ m.handleInput('input_q', { value: converted, unit: 'm3/h' });
+ break;
+ }
+ case 'i_start':
+ case 'monsternametijden':
+ case 'rain_data':
+ m.handleInput(msg.topic, msg.payload);
+ break;
case 'registerChild': {
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
diff --git a/src/specificClass.js b/src/specificClass.js
index a3ce69d..205ca89 100644
--- a/src/specificClass.js
+++ b/src/specificClass.js
@@ -20,9 +20,19 @@ class Monster{
// -------------------------------------- fetch dependencies --------------------------
//this.math = require('mathjs');
- //place holders for output data
- this.output = {} ; // object to place all relevant outputs in and preform event change check on
+ //measurements
+ this.measurements = new MeasurementContainer({
+ autoConvert: true,
+ windowSize: 50,
+ defaultUnits: {
+ flow: 'm3/h',
+ volume: 'm3'
+ }
+ }, this.logger);
+
+ //child registration
this.child = {} ; // register childs
+ this.childRegistrationUtils = new childRegistrationUtils(this);
//Specific object info
this.aquonSampleName = "112100" ; // aquon sample name to start automatic sampling on the basis of the document
@@ -32,19 +42,26 @@ class Monster{
this.sumRain = 0 ; // total sum of rain over time window + n hours and - n hours
this.avgRain = 0 ; // total divided by number of locations to get average over total time
this.daysPerYear = 0 ; // how many days remaining for this year
+ this.lastRainUpdate = 0 ; // timestamp of last rain data update
+ this.rainMaxRef = 10 ; // mm reference for scaling linear prediction
+ this.rainStaleMs = 2 * 60 * 60 * 1000; // 2 hours
// outputs
this.pulse = false; // output pulse to sampling machine
this.bucketVol = 0; // how full is the sample?
this.sumPuls = 0; // number of pulses so far
this.predFlow = 0; // predicted flow over sampling time in hours, expressed in m3
- this.bucketWeight = 0; // actual weight of bucket
+ this.bucketWeight = 0; // actual weight of bucket
//inputs
- this.q = 0; // influent flow in m3/h
+ this.q = 0; // influent flow in m3/h (effective)
+ this.manualFlow = null; // manual flow override value in m3/h
this.i_start = false // when true, the program gets kicked off calculating what it needs to take samples
this.sampling_time = config.constraints.samplingtime; // time expressed in hours over which the sampling will run (currently 24)
this.emptyWeightBucket = config.asset.emptyWeightBucket; // empty weight of the bucket
+ this.nominalFlowMin = config.constraints.nominalFlowMin; // nominal dry-day flow in m3/h
+ this.flowMax = config.constraints.flowMax; // max inflow in m3/h
+ this.minSampleIntervalSec = config.constraints.minSampleIntervalSec || 60; // min seconds between samples
// internal vars
this.temp_pulse = 0; // each interval pulses send out 1 and then reset
@@ -63,6 +80,10 @@ class Monster{
this.m3PerTick = 0; // actual measured flow in m3 per second
this.m3Total = 0; // total measured flow over sampling time in m3
this.running = false; // define if sampling is running or not
+ this.invalidFlowBounds = false; // whether nominalFlowMin/flowMax are invalid
+ this.lastSampleTime = 0; // last sample (pulse) timestamp
+ this.lastSampleWarnTime = 0; // last warning timestamp for cooldown
+ this.missedSamples = 0; // count blocked samples due to cooldown
this.qLineRaw = {}; // see example
this.minSeen = {}; // keeps track of minimum ever seen so far in a time period for each hour (over totals not every value)
@@ -80,7 +101,7 @@ class Monster{
//old prediction factor
this.predFactor = 0.7; // define factor as multiplier for prediction
-
+
//track program start and stop
this.start_time = Date.now(); // default start time
this.stop_time = Date.now(); // default stop time
@@ -89,6 +110,9 @@ class Monster{
this.timeLeft = 0; // time in seconds
this.currHour = new Date().getHours(); // on init define in which hour we are 0 - 23
+ if (Number.isFinite(config?.constraints?.maxRainRef)) {
+ this.rainMaxRef = config.constraints.maxRainRef;
+ }
this.init = true; // end of constructor
@@ -98,129 +122,247 @@ class Monster{
}
- _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){
-
- //check if push is in valid format and not null
- if(
- typeof value[0].SAMPLE_NAME !== 'undefined'
- &&
- typeof value[0].DESCRIPTION !== 'undefined'
- &&
- typeof value[0].SAMPLED_DATE !== 'undefined'
- &&
- typeof value[0].START_DATE !== 'undefined'
- &&
- typeof value[0].END_DATE !== 'undefined'
- ){
-
- //each time this changes we load the next date applicable for this function
- this._monsternametijden = value;
-
- //fetch dates
- this.regNextDate(value);
-
- }
- else{
- // Monsternametijden object Wrong format contact AQUON
- }
- }
- else{
- // Monsternametijden object Wrong format contact AQUON
- }
+ /*------------------- INPUT HANDLING -------------------*/
+ handleInput(topic, payload) {
+ switch (topic) {
+ case 'i_start':
+ this.i_start = Boolean(payload);
+ break;
+ case 'monsternametijden':
+ this.updateMonsternametijden(payload);
+ break;
+ case 'rain_data':
+ this.updateRainData(payload);
+ break;
+ case 'input_q':
+ this.updateManualFlow(payload);
+ break;
+ default:
+ break;
}
}
- get monsternametijden(){
- return this._monsternametijden;
+ updateMonsternametijden(value) {
+ if (!this.init || !value || Object.keys(value).length === 0) {
+ return;
+ }
+
+ if (
+ typeof value[0]?.SAMPLE_NAME !== 'undefined' &&
+ typeof value[0]?.DESCRIPTION !== 'undefined' &&
+ typeof value[0]?.SAMPLED_DATE !== 'undefined' &&
+ typeof value[0]?.START_DATE !== 'undefined' &&
+ typeof value[0]?.END_DATE !== 'undefined'
+ ) {
+ this.monsternametijden = value;
+ this.regNextDate(value);
+ }
}
- set rain_data(value){
-
- //retrieve precipitation expected during the coming day and precipitation of yesterday
- this._rain_data = value;
+ updateRainData(value) {
+ this.rain_data = value;
+ this.lastRainUpdate = Date.now();
- //only update after init and is not running.
- if(this.init && !this.running){
+ if (this.init && !this.running) {
this.updatePredRain(value);
}
-
- }
-
- get rain_data(){
- return this._rain_data;
}
- set bucketVol(val){
-
- //Put val in local var
- this._bucketVol = val;
-
- //Place into output object
- this.output.bucketVol = val;
-
- // update bucket weight
+ updateBucketVol(val) {
+ this.bucketVol = val;
this.bucketWeight = val + this.emptyWeightBucket;
}
- get bucketVol(){
- return this._bucketVol;
+ getSampleCooldownMs() {
+ if (!this.lastSampleTime) {
+ return 0;
+ }
+ const remaining = (this.minSampleIntervalSec * 1000) - (Date.now() - this.lastSampleTime);
+ return Math.max(0, remaining);
}
- set minVolume(val){
-
- //Protect against 0
- val == 0 ? val = 1 : val = val;
-
- this._minVolume = val;
-
- //Place into output object
- this.output.minVolume = val;
+ validateFlowBounds() {
+ const min = Number(this.nominalFlowMin);
+ const max = Number(this.flowMax);
+ const valid = Number.isFinite(min) && Number.isFinite(max) && min >= 0 && max > 0 && min < max;
+ this.invalidFlowBounds = !valid;
+ if (!valid) {
+ this.logger.warn(`Invalid flow bounds. nominalFlowMin=${this.nominalFlowMin}, flowMax=${this.flowMax}`);
+ }
+ return valid;
}
- get minVolume(){
- return this._minVolume;
+ getRainIndex() {
+ if (!this.lastRainUpdate) {
+ return 0;
+ }
+ if (Date.now() - this.lastRainUpdate > this.rainStaleMs) {
+ return 0;
+ }
+ return Number.isFinite(this.avgRain) ? this.avgRain : 0;
}
- set q(val){
-
- //Put val in local var
- this._q = val;
-
- //Place into output object
- this.output.q = val;
-
+ getPredictedFlowRate() {
+ const min = Number(this.nominalFlowMin);
+ const max = Number(this.flowMax);
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max <= 0 || min >= max) {
+ return 0;
+ }
+ const rainIndex = this.getRainIndex();
+ const scale = Math.max(0, Math.min(1, this.rainMaxRef > 0 ? rainIndex / this.rainMaxRef : 0));
+ return min + (max - min) * scale;
}
- get q(){
- return this._q;
+
+ updateManualFlow(payload = {}) {
+ const value = Number(payload.value);
+ if (!Number.isFinite(value)) {
+ return;
+ }
+
+ const unit = payload.unit || 'm3/h';
+ this.manualFlow = value;
+ this.measurements
+ .type('flow')
+ .variant('manual')
+ .position('atequipment')
+ .value(value, Date.now(), unit);
+ }
+
+ handleMeasuredFlow(eventData) {
+ const value = Number(eventData?.value);
+ if (!Number.isFinite(value)) {
+ return;
+ }
+
+ const position = String(eventData.position || 'atequipment').toLowerCase();
+ const unit = eventData.unit || 'm3/h';
+ this.measurements
+ .type('flow')
+ .variant('measured')
+ .position(position)
+ .value(value, eventData.timestamp || Date.now(), unit);
+ }
+
+ getMeasuredFlow() {
+ const positions = ['upstream', 'downstream', 'atequipment'];
+ const values = [];
+
+ positions.forEach((position) => {
+ const measured = this.measurements
+ .type('flow')
+ .variant('measured')
+ .position(position)
+ .getCurrentValue();
+
+ if (Number.isFinite(measured)) {
+ values.push(measured);
+ }
+ });
+
+ if (!values.length) {
+ return null;
+ }
+
+ const sum = values.reduce((total, curr) => total + curr, 0);
+ return sum / values.length;
+ }
+
+ getManualFlow() {
+ const manual = this.measurements
+ .type('flow')
+ .variant('manual')
+ .position('atequipment')
+ .getCurrentValue();
+
+ return Number.isFinite(manual) ? manual : null;
+ }
+
+ getEffectiveFlow() {
+ const measured = this.getMeasuredFlow();
+ const manual = this.getManualFlow();
+
+ if (measured != null && manual != null) {
+ return (measured + manual) / 2;
+ }
+
+ if (measured != null) {
+ return measured;
+ }
+
+ if (manual != null) {
+ return manual;
+ }
+
+ return 0;
+ }
+
+ registerChild(child, softwareType) {
+ if (softwareType !== 'measurement' || !child?.measurements?.emitter) {
+ return;
+ }
+
+ const childType = child?.config?.asset?.type;
+ if (childType && childType !== 'flow') {
+ return;
+ }
+
+ const handler = (eventData) => this.handleMeasuredFlow(eventData);
+ child.measurements.emitter.on('flow.measured.upstream', handler);
+ child.measurements.emitter.on('flow.measured.downstream', handler);
+ child.measurements.emitter.on('flow.measured.atequipment', handler);
+ }
+
+ getOutput() {
+ const output = this.measurements.getFlattenedOutput();
+ const flowRate = Number(this.q) || 0;
+ const m3PerPulse = Number(this.m3PerPuls) || 0;
+ const pulseFraction = Number(this.temp_pulse) || 0;
+ const targetVolumeM3 = Number(this.targetVolume) > 0 ? this.targetVolume / 1000 : 0;
+ const flowToNextPulseM3 = m3PerPulse > 0 ? Math.max(0, (1 - pulseFraction) * m3PerPulse) : 0;
+ const timeToNextPulseSec = flowRate > 0 && flowToNextPulseM3 > 0
+ ? Math.round((flowToNextPulseM3 / (flowRate / 3600)) * 100) / 100
+ : 0;
+ const targetProgressPct = targetVolumeM3 > 0
+ ? Math.round((this.m3Total / targetVolumeM3) * 10000) / 100
+ : 0;
+ const targetDeltaM3 = targetVolumeM3 > 0
+ ? Math.round((this.m3Total - targetVolumeM3) * 10000) / 10000
+ : 0;
+
+ output.pulse = this.pulse;
+ output.running = this.running;
+ output.bucketVol = this.bucketVol;
+ output.bucketWeight = this.bucketWeight;
+ output.sumPuls = this.sumPuls;
+ output.predFlow = this.predFlow;
+ output.predM3PerSec = this.predM3PerSec;
+ output.timePassed = this.timePassed;
+ output.timeLeft = this.timeLeft;
+ output.m3Total = this.m3Total;
+ output.q = this.q;
+ output.nominalFlowMin = this.nominalFlowMin;
+ output.flowMax = this.flowMax;
+ output.invalidFlowBounds = this.invalidFlowBounds;
+ output.minSampleIntervalSec = this.minSampleIntervalSec;
+ output.missedSamples = this.missedSamples;
+ output.sampleCooldownMs = this.getSampleCooldownMs();
+ output.maxVolume = this.maxVolume;
+ output.minVolume = this.minVolume;
+ output.nextDate = this.nextDate;
+ output.daysPerYear = this.daysPerYear;
+ output.m3PerPuls = this.m3PerPuls;
+ output.m3PerPulse = this.m3PerPuls;
+ output.pulsesRemaining = Math.max(0, (this.targetPuls || 0) - (this.sumPuls || 0));
+ output.pulseFraction = pulseFraction;
+ output.flowToNextPulseM3 = flowToNextPulseM3;
+ output.timeToNextPulseSec = timeToNextPulseSec;
+ output.targetVolumeM3 = targetVolumeM3;
+ output.targetProgressPct = targetProgressPct;
+ output.targetDeltaM3 = targetDeltaM3;
+ output.predictedRateM3h = this.getPredictedFlowRate();
+
+ return output;
}
/*------------------- FUNCTIONS -------------------*/
@@ -357,15 +499,15 @@ class Monster{
}
-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.
+ get_model_prediction(){
+ // Linear predictor based on rain index with flow bounds.
const samplingHours = Number(this.sampling_time) || 0;
- const flowM3PerHour = Number(this.q) || 0;
+ const predictedRate = this.getPredictedFlowRate();
+ const fallbackRate = this.getEffectiveFlow();
+ const flowM3PerHour = predictedRate > 0 ? predictedRate : fallbackRate;
const fallback = Math.max(0, flowM3PerHour * samplingHours);
this.predFlow = fallback;
- this._syncOutput();
return this.predFlow;
}
@@ -487,13 +629,19 @@ async model_loader(inputs){
// ------------------ Run once on conditions and start sampling
if( ( (this.i_start ) || ( Date.now() >= this.nextDate ) ) && !this.running ){
+
+ if (!this.validateFlowBounds()) {
+ this.running = false;
+ this.i_start = false;
+ return;
+ }
this.running = true;
// reset persistent vars
this.temp_pulse = 0;
this.pulse = false;
- this.bucketVol = 0;
+ this.updateBucketVol(0);
this.sumPuls = 0;
this.m3Total = 0;
this.timePassed = 0; // time in seconds
@@ -534,14 +682,30 @@ async model_loader(inputs){
// check if we need to send out a pulse (stop sending pulses if capacity is reached)
if(this.temp_pulse >= 1 && this.sumPuls < this.absMaxPuls){
- // reset
- this.temp_pulse += -1;
- // send out a pulse and add to count
- this.pulse = true;
- // count pulses
- this.sumPuls++;
- // update bucket volume each puls
- this.bucketVol = Math.round(this.sumPuls * this.volume_pulse * 100) / 100;
+ const now = Date.now();
+ const cooldownMs = this.minSampleIntervalSec * 1000;
+ const blocked = this.lastSampleTime && (now - this.lastSampleTime) < cooldownMs;
+
+ if (blocked) {
+ this.missedSamples++;
+ this.pulse = false;
+ this.temp_pulse = Math.min(this.temp_pulse, 1);
+
+ if (!this.lastSampleWarnTime || (now - this.lastSampleWarnTime) > cooldownMs) {
+ this.lastSampleWarnTime = now;
+ this.logger.warn(`Sampling too fast. Cooldown active for ${Math.ceil((cooldownMs - (now - this.lastSampleTime)) / 1000)}s.`);
+ }
+ } else {
+ // reset
+ this.temp_pulse += -1;
+ // send out a pulse and add to count
+ this.pulse = true;
+ this.lastSampleTime = now;
+ // count pulses
+ this.sumPuls++;
+ // update bucket volume each pulse
+ this.updateBucketVol(Math.round(this.sumPuls * this.volume_pulse * 100) / 100);
+ }
}
else{
@@ -566,7 +730,7 @@ async model_loader(inputs){
this.m3PerPuls = 0;
this.temp_pulse = 0;
this.pulse = false;
- this.bucketVol = 0;
+ this.updateBucketVol(0);
this.sumPuls = 0;
this.timePassed = 0; // time in seconds
this.timeLeft = 0; // time in seconds
@@ -599,7 +763,10 @@ async model_loader(inputs){
// ------------------ 1.0 Main program loop ------------------
this.logger.debug('Monster tick running');
-
+
+ //resolve effective flow in m3/h
+ this.q = this.getEffectiveFlow();
+
//calculate flow based on input
this.flowCalc();
@@ -608,8 +775,6 @@ async model_loader(inputs){
//logQ for predictions / forecasts
this.logQoverTime();
-
- this._syncOutput();
}
regNextDate(monsternametijden){
@@ -707,11 +872,11 @@ const mConfig={
},
}
-const monster = new Monster(mConfig);
-(async () => {
- const intervalId = setInterval(() => {
- monster.tick()
- ;},1000)
-
-})();
-
+if (require.main === module) {
+ const monster = new Monster(mConfig);
+ (async () => {
+ const intervalId = setInterval(() => {
+ monster.tick();
+ }, 1000);
+ })();
+}
diff --git a/test/monster.specific.test.js b/test/monster.specific.test.js
new file mode 100644
index 0000000..7373244
--- /dev/null
+++ b/test/monster.specific.test.js
@@ -0,0 +1,256 @@
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const Monster = require('../src/specificClass');
+const { MeasurementContainer } = require('generalFunctions');
+
+function test(name, fn) {
+ try {
+ fn();
+ console.log(`ok - ${name}`);
+ } catch (err) {
+ console.error(`not ok - ${name}`);
+ console.error(err);
+ process.exitCode = 1;
+ }
+}
+
+function withMockedDate(iso, fn) {
+ const RealDate = Date;
+ let now = new RealDate(iso).getTime();
+
+ class MockDate extends RealDate {
+ constructor(...args) {
+ if (args.length === 0) {
+ super(now);
+ } else {
+ super(...args);
+ }
+ }
+
+ static now() {
+ return now;
+ }
+ }
+
+ global.Date = MockDate;
+ try {
+ return fn({
+ advance(ms) {
+ now += ms;
+ }
+ });
+ } finally {
+ global.Date = RealDate;
+ }
+}
+
+function buildConfig(overrides = {}) {
+ return {
+ general: {
+ name: 'Monster Test',
+ logging: { enabled: false, logLevel: 'error' }
+ },
+ asset: {
+ emptyWeightBucket: 3
+ },
+ constraints: {
+ samplingtime: 1,
+ minVolume: 5,
+ maxWeight: 23,
+ nominalFlowMin: 1,
+ flowMax: 10
+ },
+ ...overrides
+ };
+}
+
+function parseMonsternametijdenCsv(filePath) {
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
+ const lines = raw.split(/\r?\n/);
+ const header = lines.shift();
+ const columns = header.split(',');
+
+ return lines
+ .filter((line) => line && !line.startsWith('-----------'))
+ .map((line) => {
+ const parts = [];
+ let cur = '';
+ let inQ = false;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (ch === '"') {
+ inQ = !inQ;
+ continue;
+ }
+ if (ch === ',' && !inQ) {
+ parts.push(cur);
+ cur = '';
+ } else {
+ cur += ch;
+ }
+ }
+ parts.push(cur);
+ const obj = {};
+ columns.forEach((col, idx) => {
+ obj[col] = parts[idx];
+ });
+ return obj;
+ });
+}
+
+test('measured + manual flow averages into effective flow', () => {
+ withMockedDate('2024-10-15T00:00:00Z', ({ advance }) => {
+ const monster = new Monster(buildConfig());
+
+ const child = {
+ config: {
+ general: { id: 'child-1', name: 'FlowSensor' },
+ asset: { type: 'flow' }
+ },
+ measurements: new MeasurementContainer({
+ autoConvert: true,
+ defaultUnits: { flow: 'm3/h' }
+ })
+ };
+
+ monster.registerChild(child, 'measurement');
+
+ child.measurements
+ .type('flow')
+ .variant('measured')
+ .position('downstream')
+ .value(60, Date.now(), 'm3/h');
+
+ monster.handleInput('input_q', { value: 20, unit: 'm3/h' });
+
+ advance(1000);
+ monster.tick();
+
+ assert.strictEqual(monster.q, 40);
+ });
+});
+
+test('invalid flow bounds prevent sampling start', () => {
+ const monster = new Monster(buildConfig({
+ constraints: {
+ samplingtime: 1,
+ minVolume: 5,
+ maxWeight: 23,
+ nominalFlowMin: 10,
+ flowMax: 5
+ }
+ }));
+
+ monster.handleInput('i_start', true);
+ monster.sampling_program();
+
+ assert.strictEqual(monster.invalidFlowBounds, true);
+ assert.strictEqual(monster.running, false);
+ assert.strictEqual(monster.i_start, false);
+});
+
+test('flowCalc uses elapsed time to compute m3PerTick', () => {
+ withMockedDate('2024-10-15T00:00:00Z', ({ advance }) => {
+ const monster = new Monster(buildConfig());
+ monster.q = 36; // m3/h
+
+ monster.flowCalc();
+ assert.strictEqual(monster.m3PerTick, 0);
+
+ advance(10000);
+ monster.flowCalc();
+
+ const expected = 0.1; // 36 m3/h -> 0.01 m3/s over 10s
+ assert.ok(Math.abs(monster.m3PerTick - expected) < 1e-6);
+ });
+});
+
+test('prediction fallback uses nominalFlowMin * sampling_time when rain is stale', () => {
+ const monster = new Monster(buildConfig());
+ monster.nominalFlowMin = 4;
+ monster.flowMax = 10;
+ monster.rainMaxRef = 8;
+ monster.sampling_time = 24;
+ monster.lastRainUpdate = 0;
+ const pred = monster.get_model_prediction();
+ assert.strictEqual(pred, 96);
+});
+
+test('pulses increment when running with manual flow and zero nominalFlowMin', () => {
+ withMockedDate('2024-10-15T00:00:00Z', ({ advance }) => {
+ const monster = new Monster(buildConfig({
+ constraints: {
+ samplingtime: 1,
+ minVolume: 5,
+ maxWeight: 23,
+ nominalFlowMin: 0,
+ flowMax: 6000,
+ minSampleIntervalSec: 60,
+ maxRainRef: 10
+ }
+ }));
+
+ monster.handleInput('input_q', { value: 200, unit: 'm3/h' });
+ monster.handleInput('i_start', true);
+
+ for (let i = 0; i < 80; i++) {
+ advance(1000);
+ monster.tick();
+ }
+
+ assert.ok(monster.sumPuls > 0);
+ assert.ok(monster.bucketVol > 0);
+ assert.ok(monster.missedSamples > 0);
+ assert.ok(monster.getSampleCooldownMs() > 0);
+ });
+});
+
+test('rain data aggregation produces totals', () => {
+ const monster = new Monster(buildConfig());
+ const rainPath = path.join(__dirname, 'seed_data', 'raindataFormat.json');
+ const rainData = JSON.parse(fs.readFileSync(rainPath, 'utf8'));
+
+ monster.updateRainData(rainData);
+
+ assert.ok(Object.keys(monster.aggregatedOutput).length > 0);
+ assert.ok(monster.sumRain >= 0);
+ assert.ok(monster.avgRain >= 0);
+});
+
+test('monsternametijden schedule sets next date', () => {
+ withMockedDate('2024-10-15T00:00:00Z', () => {
+ const monster = new Monster(buildConfig());
+ const csvPath = path.join(__dirname, 'seed_data', 'monsternametijden.csv');
+ const rows = parseMonsternametijdenCsv(csvPath);
+
+ monster.aquonSampleName = '112100';
+ monster.updateMonsternametijden(rows);
+
+ const nextDate = monster.nextDate instanceof Date
+ ? monster.nextDate.getTime()
+ : Number(monster.nextDate);
+
+ assert.ok(Number.isFinite(nextDate));
+ assert.ok(nextDate > Date.now());
+ });
+});
+
+test('output includes pulse and flow fields', () => {
+ const monster = new Monster(buildConfig());
+ const output = monster.getOutput();
+
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'pulse'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'q'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'm3PerPuls'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'm3PerPulse'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'pulsesRemaining'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'pulseFraction'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'flowToNextPulseM3'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'timeToNextPulseSec'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'targetVolumeM3'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'targetProgressPct'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'targetDeltaM3'));
+ assert.ok(Object.prototype.hasOwnProperty.call(output, 'predictedRateM3h'));
+});