From ed9409fc2937b0f7ae5991e535666780242fc7f7 Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:17:40 +0100 Subject: [PATCH] upgrades --- monster.html | 134 ++++++++++- monster.js | 4 +- package.json | 2 +- src/nodeClass.js | 63 ++++- src/specificClass.js | 431 +++++++++++++++++++++++----------- test/monster.specific.test.js | 256 ++++++++++++++++++++ 6 files changed, 735 insertions(+), 155 deletions(-) create mode 100644 test/monster.specific.test.js 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')); +});