upgrades
This commit is contained in:
132
monster.html
132
monster.html
@@ -15,6 +15,10 @@
|
|||||||
samplingtime: { value: 0 },
|
samplingtime: { value: 0 },
|
||||||
minvolume: { value: 5 },
|
minvolume: { value: 5 },
|
||||||
maxweight: { value: 22 },
|
maxweight: { value: 22 },
|
||||||
|
nominalFlowMin: { value: 0 },
|
||||||
|
flowMax: { value: 0 },
|
||||||
|
maxRainRef: { value: 10 },
|
||||||
|
minSampleIntervalSec: { value: 60 },
|
||||||
emptyWeightBucket: { value: 3 },
|
emptyWeightBucket: { value: 3 },
|
||||||
aquon_sample_name: { value: "" },
|
aquon_sample_name: { value: "" },
|
||||||
|
|
||||||
@@ -64,8 +68,95 @@
|
|||||||
document.getElementById("node-input-samplingtime");
|
document.getElementById("node-input-samplingtime");
|
||||||
document.getElementById("node-input-minvolume");
|
document.getElementById("node-input-minvolume");
|
||||||
document.getElementById("node-input-maxweight");
|
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-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() {
|
oneditsave: function() {
|
||||||
@@ -84,9 +175,17 @@
|
|||||||
window.EVOLV.nodes.monster.positionMenu.saveEditor(this);
|
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 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}`);
|
console.log(`----------------> Saving ${field}: ${value}`);
|
||||||
node[field] = value;
|
node[field] = value;
|
||||||
});
|
});
|
||||||
@@ -105,7 +204,8 @@
|
|||||||
<!-- Main UI Template -->
|
<!-- Main UI Template -->
|
||||||
<script type="text/html" data-template-name="monster">
|
<script type="text/html" data-template-name="monster">
|
||||||
|
|
||||||
<!-- speficic input -->
|
<!-- specific input -->
|
||||||
|
<h3>Sampling constraints</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-samplingtime"><i class="fa fa-clock-o"></i> Sampling time (h)</label>
|
<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%;" />
|
<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>
|
<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%;" />
|
<input type="number" id="node-input-maxweight" style="width:60%;" />
|
||||||
</div>
|
</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">
|
<div class="form-row">
|
||||||
<label for="node-input-emptyWeightBucket"><i class="fa fa-clock-o"></i> Empty weight of bucket (kg)</label>
|
<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%;" />
|
<input type="number" id="node-input-emptyWeightBucket" style="width:60%;" />
|
||||||
</div>
|
</div>
|
||||||
|
<h3>Aquon</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-aquon_sample_name"><i class="fa fa-clock-o"></i> Aquon sample name</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Asset fields injected here -->
|
<!-- Asset fields injected here -->
|
||||||
@@ -141,6 +261,6 @@
|
|||||||
<script type="text/html" data-help-name="monster">
|
<script type="text/html" data-help-name="monster">
|
||||||
<p><b>Monster node</b>: Configure a monster asset.</p>
|
<p><b>Monster node</b>: Configure a monster asset.</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li><b>Beta note:</b> values load from specs but remain editable in the editor for testing.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ module.exports = function(RED) {
|
|||||||
// Serve /monster/menu.js
|
// Serve /monster/menu.js
|
||||||
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
|
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const script = menuMgr.createEndpoint(nameOfNode, ['logger','position']);
|
const script = menuMgr.createEndpoint(nameOfNode, ['logger', 'position', 'aquon']);
|
||||||
res.type('application/javascript').send(script);
|
res.type('application/javascript').send(script);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).send(`// Error generating menu: ${err.message}`);
|
res.status(500).send(`// Error generating menu: ${err.message}`);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Control module Monsternamekast",
|
"description": "Control module Monsternamekast",
|
||||||
"main": "monster.js",
|
"main": "monster.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node monster.js"
|
"test": "node test/monster.specific.test.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -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.
|
* 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.
|
* 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");
|
const Specific = require("./specificClass");
|
||||||
|
|
||||||
class nodeClass {
|
class nodeClass {
|
||||||
@@ -69,6 +69,10 @@ class nodeClass {
|
|||||||
samplingtime: Number(uiConfig.samplingtime),
|
samplingtime: Number(uiConfig.samplingtime),
|
||||||
minVolume: Number(uiConfig.minvolume),
|
minVolume: Number(uiConfig.minvolume),
|
||||||
maxWeight: Number(uiConfig.maxweight),
|
maxWeight: Number(uiConfig.maxweight),
|
||||||
|
nominalFlowMin: Number(uiConfig.nominalFlowMin),
|
||||||
|
flowMax: Number(uiConfig.flowMax),
|
||||||
|
maxRainRef: Number(uiConfig.maxRainRef),
|
||||||
|
minSampleIntervalSec: Number(uiConfig.minSampleIntervalSec),
|
||||||
},
|
},
|
||||||
functionality: {
|
functionality: {
|
||||||
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
||||||
@@ -110,20 +114,33 @@ try{
|
|||||||
const bucketVol = m.bucketVol;
|
const bucketVol = m.bucketVol;
|
||||||
const maxVolume = m.maxVolume;
|
const maxVolume = m.maxVolume;
|
||||||
const state = m.running;
|
const state = m.running;
|
||||||
const mode = "AI" ; //m.mode;
|
const mode = "AI"; //m.mode;
|
||||||
|
const flowMin = m.nominalFlowMin;
|
||||||
|
const flowMax = m.flowMax;
|
||||||
|
|
||||||
let status;
|
if (m.invalidFlowBounds) {
|
||||||
|
return {
|
||||||
switch (state) {
|
fill: "red",
|
||||||
case false:
|
shape: "ring",
|
||||||
status = { fill: "red", shape: "dot", text: `${mode}: OFF` };
|
text: `Config error: nominalFlowMin (${flowMin}) >= flowMax (${flowMax})`
|
||||||
break;
|
};
|
||||||
case true:
|
|
||||||
status = { fill: "green", shape: "dot", text: `${mode}: ON => ${bucketVol} | ${maxVolume}` };
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
this.node.error("Error in updateNodeStatus: " + error);
|
this.node.error("Error in updateNodeStatus: " + error);
|
||||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
return { fill: "red", shape: "ring", text: "Status Error" };
|
||||||
@@ -182,6 +199,28 @@ try{
|
|||||||
const m = this.source;
|
const m = this.source;
|
||||||
try {
|
try {
|
||||||
switch(msg.topic) {
|
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': {
|
case 'registerChild': {
|
||||||
const childId = msg.payload;
|
const childId = msg.payload;
|
||||||
const childObj = this.RED.nodes.getNode(childId);
|
const childObj = this.RED.nodes.getNode(childId);
|
||||||
|
|||||||
@@ -20,9 +20,19 @@ class Monster{
|
|||||||
// -------------------------------------- fetch dependencies --------------------------
|
// -------------------------------------- fetch dependencies --------------------------
|
||||||
//this.math = require('mathjs');
|
//this.math = require('mathjs');
|
||||||
|
|
||||||
//place holders for output data
|
//measurements
|
||||||
this.output = {} ; // object to place all relevant outputs in and preform event change check on
|
this.measurements = new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
windowSize: 50,
|
||||||
|
defaultUnits: {
|
||||||
|
flow: 'm3/h',
|
||||||
|
volume: 'm3'
|
||||||
|
}
|
||||||
|
}, this.logger);
|
||||||
|
|
||||||
|
//child registration
|
||||||
this.child = {} ; // register childs
|
this.child = {} ; // register childs
|
||||||
|
this.childRegistrationUtils = new childRegistrationUtils(this);
|
||||||
|
|
||||||
//Specific object info
|
//Specific object info
|
||||||
this.aquonSampleName = "112100" ; // aquon sample name to start automatic sampling on the basis of the document
|
this.aquonSampleName = "112100" ; // aquon sample name to start automatic sampling on the basis of the document
|
||||||
@@ -32,6 +42,9 @@ class Monster{
|
|||||||
this.sumRain = 0 ; // total sum of rain over time window + n hours and - n hours
|
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.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.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
|
// outputs
|
||||||
this.pulse = false; // output pulse to sampling machine
|
this.pulse = false; // output pulse to sampling machine
|
||||||
@@ -41,10 +54,14 @@ class Monster{
|
|||||||
this.bucketWeight = 0; // actual weight of bucket
|
this.bucketWeight = 0; // actual weight of bucket
|
||||||
|
|
||||||
//inputs
|
//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.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.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.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
|
// internal vars
|
||||||
this.temp_pulse = 0; // each interval pulses send out 1 and then reset
|
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.m3PerTick = 0; // actual measured flow in m3 per second
|
||||||
this.m3Total = 0; // total measured flow over sampling time in m3
|
this.m3Total = 0; // total measured flow over sampling time in m3
|
||||||
this.running = false; // define if sampling is running or not
|
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.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)
|
this.minSeen = {}; // keeps track of minimum ever seen so far in a time period for each hour (over totals not every value)
|
||||||
@@ -89,6 +110,9 @@ class Monster{
|
|||||||
this.timeLeft = 0; // time in seconds
|
this.timeLeft = 0; // time in seconds
|
||||||
this.currHour = new Date().getHours(); // on init define in which hour we are 0 - 23
|
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
|
this.init = true; // end of constructor
|
||||||
|
|
||||||
@@ -98,129 +122,247 @@ class Monster{
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_syncOutput() {
|
/*------------------- INPUT HANDLING -------------------*/
|
||||||
this.output = this.output || {};
|
handleInput(topic, payload) {
|
||||||
this.output.pulse = this.pulse;
|
switch (topic) {
|
||||||
this.output.running = this.running;
|
case 'i_start':
|
||||||
this.output.bucketVol = this.bucketVol;
|
this.i_start = Boolean(payload);
|
||||||
this.output.bucketWeight = this.bucketWeight;
|
break;
|
||||||
this.output.sumPuls = this.sumPuls;
|
case 'monsternametijden':
|
||||||
this.output.predFlow = this.predFlow;
|
this.updateMonsternametijden(payload);
|
||||||
this.output.predM3PerSec = this.predM3PerSec;
|
break;
|
||||||
this.output.timePassed = this.timePassed;
|
case 'rain_data':
|
||||||
this.output.timeLeft = this.timeLeft;
|
this.updateRainData(payload);
|
||||||
this.output.m3Total = this.m3Total;
|
break;
|
||||||
this.output.q = this.q;
|
case 'input_q':
|
||||||
this.output.maxVolume = this.maxVolume;
|
this.updateManualFlow(payload);
|
||||||
this.output.minVolume = this.minVolume;
|
break;
|
||||||
this.output.nextDate = this.nextDate;
|
default:
|
||||||
this.output.daysPerYear = this.daysPerYear;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutput() {
|
updateMonsternametijden(value) {
|
||||||
this._syncOutput();
|
if (!this.init || !value || Object.keys(value).length === 0) {
|
||||||
return this.output;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*------------------- GETTER/SETTERS Dynamics -------------------*/
|
if (
|
||||||
set monsternametijden(value){
|
typeof value[0]?.SAMPLE_NAME !== 'undefined' &&
|
||||||
|
typeof value[0]?.DESCRIPTION !== 'undefined' &&
|
||||||
if(this.init){
|
typeof value[0]?.SAMPLED_DATE !== 'undefined' &&
|
||||||
if(Object.keys(value).length > 0){
|
typeof value[0]?.START_DATE !== 'undefined' &&
|
||||||
|
typeof value[0]?.END_DATE !== 'undefined'
|
||||||
//check if push is in valid format and not null
|
) {
|
||||||
if(
|
this.monsternametijden = value;
|
||||||
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);
|
this.regNextDate(value);
|
||||||
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
// Monsternametijden object Wrong format contact AQUON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
// Monsternametijden object Wrong format contact AQUON
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get monsternametijden(){
|
updateRainData(value) {
|
||||||
return this._monsternametijden;
|
this.rain_data = value;
|
||||||
}
|
this.lastRainUpdate = Date.now();
|
||||||
|
|
||||||
set rain_data(value){
|
if (this.init && !this.running) {
|
||||||
|
|
||||||
//retrieve precipitation expected during the coming day and precipitation of yesterday
|
|
||||||
this._rain_data = value;
|
|
||||||
|
|
||||||
//only update after init and is not running.
|
|
||||||
if(this.init && !this.running){
|
|
||||||
this.updatePredRain(value);
|
this.updatePredRain(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get rain_data(){
|
updateBucketVol(val) {
|
||||||
return this._rain_data;
|
this.bucketVol = val;
|
||||||
}
|
|
||||||
|
|
||||||
set bucketVol(val){
|
|
||||||
|
|
||||||
//Put val in local var
|
|
||||||
this._bucketVol = val;
|
|
||||||
|
|
||||||
//Place into output object
|
|
||||||
this.output.bucketVol = val;
|
|
||||||
|
|
||||||
// update bucket weight
|
|
||||||
this.bucketWeight = val + this.emptyWeightBucket;
|
this.bucketWeight = val + this.emptyWeightBucket;
|
||||||
}
|
}
|
||||||
|
|
||||||
get bucketVol(){
|
getSampleCooldownMs() {
|
||||||
return this._bucketVol;
|
if (!this.lastSampleTime) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const remaining = (this.minSampleIntervalSec * 1000) - (Date.now() - this.lastSampleTime);
|
||||||
|
return Math.max(0, remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
set minVolume(val){
|
validateFlowBounds() {
|
||||||
|
const min = Number(this.nominalFlowMin);
|
||||||
//Protect against 0
|
const max = Number(this.flowMax);
|
||||||
val == 0 ? val = 1 : val = val;
|
const valid = Number.isFinite(min) && Number.isFinite(max) && min >= 0 && max > 0 && min < max;
|
||||||
|
this.invalidFlowBounds = !valid;
|
||||||
this._minVolume = val;
|
if (!valid) {
|
||||||
|
this.logger.warn(`Invalid flow bounds. nominalFlowMin=${this.nominalFlowMin}, flowMax=${this.flowMax}`);
|
||||||
//Place into output object
|
}
|
||||||
this.output.minVolume = val;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
get minVolume(){
|
getRainIndex() {
|
||||||
return this._minVolume;
|
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){
|
getPredictedFlowRate() {
|
||||||
|
const min = Number(this.nominalFlowMin);
|
||||||
//Put val in local var
|
const max = Number(this.flowMax);
|
||||||
this._q = val;
|
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max <= 0 || min >= max) {
|
||||||
|
return 0;
|
||||||
//Place into output object
|
}
|
||||||
this.output.q = val;
|
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 -------------------*/
|
/*------------------- FUNCTIONS -------------------*/
|
||||||
@@ -357,15 +499,15 @@ class Monster{
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
get_model_prediction(){
|
get_model_prediction(){
|
||||||
// Offline-safe fallback: assume constant inflow `q` during `sampling_time`.
|
// Linear predictor based on rain index with flow bounds.
|
||||||
// `q` is in m3/h; `sampling_time` is in hours; result is total predicted volume in m3.
|
|
||||||
const samplingHours = Number(this.sampling_time) || 0;
|
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);
|
const fallback = Math.max(0, flowM3PerHour * samplingHours);
|
||||||
|
|
||||||
this.predFlow = fallback;
|
this.predFlow = fallback;
|
||||||
this._syncOutput();
|
|
||||||
return this.predFlow;
|
return this.predFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,12 +630,18 @@ async model_loader(inputs){
|
|||||||
// ------------------ Run once on conditions and start sampling
|
// ------------------ Run once on conditions and start sampling
|
||||||
if( ( (this.i_start ) || ( Date.now() >= this.nextDate ) ) && !this.running ){
|
if( ( (this.i_start ) || ( Date.now() >= this.nextDate ) ) && !this.running ){
|
||||||
|
|
||||||
|
if (!this.validateFlowBounds()) {
|
||||||
|
this.running = false;
|
||||||
|
this.i_start = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.running = true;
|
this.running = true;
|
||||||
|
|
||||||
// reset persistent vars
|
// reset persistent vars
|
||||||
this.temp_pulse = 0;
|
this.temp_pulse = 0;
|
||||||
this.pulse = false;
|
this.pulse = false;
|
||||||
this.bucketVol = 0;
|
this.updateBucketVol(0);
|
||||||
this.sumPuls = 0;
|
this.sumPuls = 0;
|
||||||
this.m3Total = 0;
|
this.m3Total = 0;
|
||||||
this.timePassed = 0; // time in seconds
|
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)
|
// 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){
|
if(this.temp_pulse >= 1 && this.sumPuls < this.absMaxPuls){
|
||||||
|
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
|
// reset
|
||||||
this.temp_pulse += -1;
|
this.temp_pulse += -1;
|
||||||
// send out a pulse and add to count
|
// send out a pulse and add to count
|
||||||
this.pulse = true;
|
this.pulse = true;
|
||||||
|
this.lastSampleTime = now;
|
||||||
// count pulses
|
// count pulses
|
||||||
this.sumPuls++;
|
this.sumPuls++;
|
||||||
// update bucket volume each puls
|
// update bucket volume each pulse
|
||||||
this.bucketVol = Math.round(this.sumPuls * this.volume_pulse * 100) / 100;
|
this.updateBucketVol(Math.round(this.sumPuls * this.volume_pulse * 100) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
@@ -566,7 +730,7 @@ async model_loader(inputs){
|
|||||||
this.m3PerPuls = 0;
|
this.m3PerPuls = 0;
|
||||||
this.temp_pulse = 0;
|
this.temp_pulse = 0;
|
||||||
this.pulse = false;
|
this.pulse = false;
|
||||||
this.bucketVol = 0;
|
this.updateBucketVol(0);
|
||||||
this.sumPuls = 0;
|
this.sumPuls = 0;
|
||||||
this.timePassed = 0; // time in seconds
|
this.timePassed = 0; // time in seconds
|
||||||
this.timeLeft = 0; // time in seconds
|
this.timeLeft = 0; // time in seconds
|
||||||
@@ -600,6 +764,9 @@ async model_loader(inputs){
|
|||||||
// ------------------ 1.0 Main program loop ------------------
|
// ------------------ 1.0 Main program loop ------------------
|
||||||
this.logger.debug('Monster tick running');
|
this.logger.debug('Monster tick running');
|
||||||
|
|
||||||
|
//resolve effective flow in m3/h
|
||||||
|
this.q = this.getEffectiveFlow();
|
||||||
|
|
||||||
//calculate flow based on input
|
//calculate flow based on input
|
||||||
this.flowCalc();
|
this.flowCalc();
|
||||||
|
|
||||||
@@ -608,8 +775,6 @@ async model_loader(inputs){
|
|||||||
|
|
||||||
//logQ for predictions / forecasts
|
//logQ for predictions / forecasts
|
||||||
this.logQoverTime();
|
this.logQoverTime();
|
||||||
|
|
||||||
this._syncOutput();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
regNextDate(monsternametijden){
|
regNextDate(monsternametijden){
|
||||||
@@ -707,11 +872,11 @@ const mConfig={
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const monster = new Monster(mConfig);
|
if (require.main === module) {
|
||||||
(async () => {
|
const monster = new Monster(mConfig);
|
||||||
|
(async () => {
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
monster.tick()
|
monster.tick();
|
||||||
;},1000)
|
}, 1000);
|
||||||
|
})();
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
|||||||
256
test/monster.specific.test.js
Normal file
256
test/monster.specific.test.js
Normal 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'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user