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

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