This commit is contained in:
znetsixe
2026-01-20 18:17:40 +01:00
parent b6e2474a1c
commit ed9409fc29
6 changed files with 735 additions and 155 deletions

View File

@@ -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 @@
<!-- Main UI Template -->
<script type="text/html" data-template-name="monster">
<!-- speficic input -->
<!-- specific input -->
<h3>Sampling constraints</h3>
<div class="form-row">
<label for="node-input-samplingtime"><i class="fa fa-clock-o"></i> Sampling time (h)</label>
<input type="number" id="node-input-samplingtime" style="width:60%;" />
@@ -118,13 +218,33 @@
<label for="node-input-maxweight"><i class="fa fa-clock-o"></i> Max weight (kg)</label>
<input type="number" id="node-input-maxweight" style="width:60%;" />
</div>
<h3>Hydraulic bounds</h3>
<div class="form-row">
<label for="node-input-nominalFlowMin"><i class="fa fa-clock-o"></i> Nominal min flow (m3/h)</label>
<input type="number" id="node-input-nominalFlowMin" style="width:60%;" />
</div>
<div class="form-row">
<label for="node-input-flowMax"><i class="fa fa-clock-o"></i> Max flow (m3/h)</label>
<input type="number" id="node-input-flowMax" style="width:60%;" />
</div>
<h3>Rain scaling</h3>
<div class="form-row">
<label for="node-input-maxRainRef"><i class="fa fa-cloud-rain"></i> Max rain reference (mm)</label>
<input type="number" id="node-input-maxRainRef" style="width:60%;" />
</div>
<div class="form-row">
<label for="node-input-minSampleIntervalSec"><i class="fa fa-hourglass"></i> Min sample interval (s)</label>
<input type="number" id="node-input-minSampleIntervalSec" style="width:60%;" />
</div>
<h3>Bucket</h3>
<div class="form-row">
<label for="node-input-emptyWeightBucket"><i class="fa fa-clock-o"></i> Empty weight of bucket (kg)</label>
<input type="number" id="node-input-emptyWeightBucket" style="width:60%;" />
</div>
<h3>Aquon</h3>
<div class="form-row">
<label for="node-input-aquon_sample_name"><i class="fa fa-clock-o"></i> Aquon sample name</label>
<input type="text" id="node-input-aquon_sample_name" style="width:60%;" />
<select id="node-input-aquon_sample_name" style="width:60%;"></select>
</div>
<!-- Asset fields injected here -->
@@ -141,6 +261,6 @@
<script type="text/html" data-help-name="monster">
<p><b>Monster node</b>: Configure a monster asset.</p>
<ul>
<li><b>Beta note:</b> values load from specs but remain editable in the editor for testing.</li>
</ul>
</script>

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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