From 2f180ae37dc0b23549494dbe5840355afc5a2cec Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Wed, 14 May 2025 08:23:29 +0200 Subject: [PATCH] updates to machinegroupcontrol to work in new gitea repo --- dependencies/machineGroup/machineGroup.js | 1056 +++++++++++++++++ .../machineGroup/machineGroup.test.js | 566 +++++++++ .../machineGroup/machineGroupConfig.json | 188 +++ dependencies/test.js | 137 +++ mgc.html | 154 +++ mgc.js | 171 +++ package.json | 28 + 7 files changed, 2300 insertions(+) create mode 100644 dependencies/machineGroup/machineGroup.js create mode 100644 dependencies/machineGroup/machineGroup.test.js create mode 100644 dependencies/machineGroup/machineGroupConfig.json create mode 100644 dependencies/test.js create mode 100644 mgc.html create mode 100644 mgc.js create mode 100644 package.json diff --git a/dependencies/machineGroup/machineGroup.js b/dependencies/machineGroup/machineGroup.js new file mode 100644 index 0000000..7e8214e --- /dev/null +++ b/dependencies/machineGroup/machineGroup.js @@ -0,0 +1,1056 @@ +/** + * @file machine.js + * + * Permission is hereby granted to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to use it for personal + * or non-commercial purposes, with the following restrictions: + * + * 1. **No Copying or Redistribution**: The Software or any of its parts may not + * be copied, merged, distributed, sublicensed, or sold without explicit + * prior written permission from the author. + * + * 2. **Commercial Use**: Any use of the Software for commercial purposes requires + * a valid license, obtainable only with the explicit consent of the author. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, + * OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * Ownership of this code remains solely with the original author. Unauthorized + * use of this Software is strictly prohibited. + * + * @summary A class to interact and manipulate machines with a non-euclidian curve + * @description A class to interact and manipulate machines with a non-euclidian curve + * @module machineGroup + * @exports machineGroup + * @version 0.1.0 + * @since 0.1.0 + * + * Author: + * - Rene De Ren + * Email: + * - rene@thegoldenbasket.nl + */ + +//load local dependencies +const EventEmitter = require("events"); +const Logger = require('../../../generalFunctions/helper/logger'); +const Interpolation = require('../../../predict/dependencies/predict/interpolation'); +const { MeasurementContainer } = require('../../../generalFunctions/helper/measurements/index'); + +//load all config modules +const defaultConfig = require('./machineGroupConfig.json'); +const ConfigUtils = require('../../../generalFunctions/helper/configUtils'); + +//load registration utility +const ChildRegistrationUtils = require('../../../generalFunctions/helper/childRegistrationUtils'); + +class MachineGroup { + constructor(machineGroupConfig = {}) { + + this.emitter = new EventEmitter(); // Own EventEmitter + this.configUtils = new ConfigUtils(defaultConfig); + this.config = this.configUtils.initConfig(machineGroupConfig); + + // Init after config is set + this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name); + + // Initialize measurements + this.measurements = new MeasurementContainer(); + this.interpolation = new Interpolation(); + + // Machines and children data + this.machines = {}; + this.child = {}; + this.scaling = this.config.scaling.current; + this.mode = this.config.mode.current; + this.absDistFromPeak = 0 ; + this.relDistFromPeak = 0; + + // Combination curve data + this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } , NCog : 0}; + this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }}; + //this always last in the constructor + this.childRegistrationUtils = new ChildRegistrationUtils(this); + + this.logger.info("MachineGroup initialized."); + + } + + + // when a child gets updated do something + handleChildChange() { + this.absoluteTotals = this.calcAbsoluteTotals(); + //for reference and not to recalc these values continiously + this.dynamicTotals = this.calcDynamicTotals(); + } + + calcAbsoluteTotals() { + + const absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } }; + + Object.values(this.machines).forEach(machine => { + const totals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } }; + //fetch min flow ever seen over all machines + Object.entries(machine.predictFlow.inputCurve).forEach(([pressure, xyCurve], index) => { + const minFlow = Math.min(...xyCurve.y); + const maxFlow = Math.max(...xyCurve.y); + + const minPower = Math.min(...machine.predictPower.inputCurve[pressure].y); + const maxPower = Math.max(...machine.predictPower.inputCurve[pressure].y); + + // min ever seen for 1 machine + if (minFlow < totals.flow.min) { totals.flow.min = minFlow; } + if (minPower < totals.power.min) { totals.power.min = minPower; } + if( maxFlow > totals.flow.max ){ totals.flow.max = maxFlow; } + if( maxPower > totals.power.max ){ totals.power.max = maxPower; } + + }); + //surplus machines for max flow and power + if( totals.flow.min < absoluteTotals.flow.min ){ absoluteTotals.flow.min = totals.flow.min; } + if( totals.power.min < absoluteTotals.power.min ){ absoluteTotals.power.min = totals.power.min; } + absoluteTotals.flow.max += totals.flow.max; + absoluteTotals.power.max += totals.power.max; + + }); + + return absoluteTotals; + + } + + //max and min current flow and power based on their actual pressure curve + calcDynamicTotals() { + + const dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog : 0 }; + + Object.values(this.machines).forEach(machine => { + //fetch min flow ever seen over all machines + const minFlow = machine.predictFlow.currentFxyYMin; + const maxFlow = machine.predictFlow.currentFxyYMax; + const minPower = machine.predictPower.currentFxyYMin; + const maxPower = machine.predictPower.currentFxyYMax; + + if( minFlow < dynamicTotals.flow.min ){ dynamicTotals.flow.min = minFlow; } + if( minPower < dynamicTotals.power.min ){ dynamicTotals.power.min = minPower; } + + dynamicTotals.flow.max += maxFlow; + dynamicTotals.power.max += maxPower; + + //fetch total Normalized Cog over all machines + dynamicTotals.NCog += machine.NCog; + + }); + + return dynamicTotals; + } + + activeTotals() { + const totals = { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 }, countActiveMachines: 0 }; + + Object.entries(this.machines).forEach(([id, machine]) => { + this.logger.debug(`Processing machine with id: ${id}`); + if(this.isMachineActive(id)){ + //fetch min flow ever seen over all machines + const minFlow = machine.predictFlow.currentFxyYMin; + const maxFlow = machine.predictFlow.currentFxyYMax; + const minPower = machine.predictPower.currentFxyYMin; + const maxPower = machine.predictPower.currentFxyYMax; + + + totals.flow.min += minFlow; + totals.flow.max += maxFlow; + totals.power.min += minPower; + totals.power.max += maxPower; + totals.countActiveMachines++; + } + + }); + + return totals; + } + + handlePressureChange() { + this.logger.info("Pressure change detected."); + this.calcDynamicTotals(); + const { maxEfficiency, lowestEfficiency } = this.calcGroupEfficiency(this.machines); + const efficiency = this.measurements.type("efficiency").variant("predicted").position("downstream").getCurrentValue(); + this.calcDistanceBEP(efficiency,maxEfficiency,lowestEfficiency); + } + + calcDistanceFromPeak(currentEfficiency,peakEfficiency){ + return Math.abs(currentEfficiency - peakEfficiency); + } + + calcRelativeDistanceFromPeak(currentEfficiency,maxEfficiency,minEfficiency){ + let distance = 1; + if(currentEfficiency != null){ + distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1); + } + return distance; + } + + calcDistanceBEP(efficiency,maxEfficiency,minEfficiency){ + const absDistFromPeak = this.calcDistanceFromPeak(efficiency,maxEfficiency); + const relDistFromPeak = this.calcRelativeDistanceFromPeak(efficiency,maxEfficiency,minEfficiency); + + //store internally + this.absDistFromPeak = absDistFromPeak ; + this.relDistFromPeak = relDistFromPeak; + + return { absDistFromPeak: absDistFromPeak, relDistFromPeak: relDistFromPeak }; + } + + checkSpecialCases(machines, Qd) { + Object.values(machines).forEach(machine => { + const state = machine.state.getCurrentState(); + const mode = machine.currentMode; + + //add special cases + if( state === "operational" && ( mode == "virtualControl" || mode === "fysicalControl") ){ + let flow = 0; + if(machine.measurements.type("flow").variant("measured").position("downstream").getCurrentValue()){ + flow = machine.measurements.type("flow").variant("measured").position("downstream").getCurrentValue(); + } + else if(machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue()){ + flow = machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); + } + else{ + this.logger.error("Dont perform calculation at all seeing that there is a machine working but we dont know the flow its producing"); + //abort the calculation + return false; + } + + //Qd is less because we allready have machines delivering flow on manual control + Qd = Qd - flow; + } + }); + return Qd ; + } + + validPumpCombinations(machines, Qd, PowerCap = Infinity) { + let subsets = [[]]; + + // adjust demand flow when there are machines being controlled by a manual source + Qd = this.checkSpecialCases(machines, Qd); + + // Generate all possible subsets of machines (power set) + Object.keys(machines).forEach(machineId => { + machineId = parseInt(machineId); + + const state = machines[machineId].state.getCurrentState(); + + const validSourceForMode = machines[machineId].isValidSourceForMode("parent", "auto"); + const validActionForMode = machines[machineId].isValidActionForMode("execSequence", "auto"); + + + // Reasons why a machine is not valid for the combination + if( state === "off" || state === "coolingdown" || state === "stopping" || state === "emergencystop" || !validSourceForMode || !validActionForMode){ + return; + } + + // go through each machine and add it to the subsets + let newSubsets = subsets.map(set => [...set, machineId]); + subsets = subsets.concat(newSubsets); + }); + + // Filter for non-empty subsets that can meet or exceed demand flow + const combinations = subsets.filter(subset => { + if (subset.length === 0) return false; + + // Calculate total and minimum flow for the subset in one pass + const { maxFlow, minFlow, maxPower } = subset.reduce( + (acc, machineId) => { + const machine = machines[machineId]; + const minFlow = machine.predictFlow.currentFxyYMin; + const maxFlow = machine.predictFlow.currentFxyYMax; + const maxPower = machine.predictPower.currentFxyYMax; + + return { + maxFlow: acc.maxFlow + maxFlow, + minFlow: acc.minFlow + minFlow, + maxPower: acc.maxPower + maxPower + + }; + }, + { maxFlow: 0, minFlow: 0 , maxPower: 0 } + ); + + // If total flow can deliver the demand + if(maxFlow >= Qd && minFlow <= Qd && maxPower <= PowerCap){ + return true; + } + else{ + return false; + } + }); + + return combinations; + } + + calcBestCombination(combinations, Qd) { + let bestCombination = null; + + //keep track of totals + let bestPower = Infinity; + let bestFlow = 0; + let bestCog = 0; + + combinations.forEach(combination => { + + let flowDistribution = []; // Stores the flow distribution for the best combination + let totalCoG = 0; + let totalPower = 0; + let totalFlow = 0; + + // Calculate the total CoG for the current combination + combination.forEach(machineId => { totalCoG += ( Math.round(this.machines[machineId].NCog * 100 ) /100 ) ; }); + + // Calculate the total power for the current combination + combination.forEach(machineId => { + let flow = 0; + + // Prevent division by zero + if (totalCoG === 0) { + // Distribute flow equally among all pumps + flow = Qd / combination.length; + + } else { + // Normal CoG-based distribution + flow = (this.machines[machineId].NCog / totalCoG) * Qd ; + this.logger.debug(`Machine Normalized CoG-based distribution ${machineId} flow: ${flow}`); + } + totalFlow += flow; + totalPower += this.machines[machineId].inputFlowCalcPower(flow); + flowDistribution.push({ machineId: machineId,flow: flow }); + }); + + // Update the best combination if the current one is better + if (totalPower < bestPower) { + this.logger.debug(`New best combination found: ${totalPower} < ${bestPower}`); + this.logger.debug(`combination ${JSON.stringify(flowDistribution)}`); + bestPower = totalPower; + bestFlow = totalFlow; + bestCog = totalCoG; + bestCombination = flowDistribution; + } + }); + + return { bestCombination, bestPower, bestFlow, bestCog }; + } + + // -------- Mode and Input Management -------- // + isValidSourceForMode(source, mode) { + const allowedSourcesSet = this.config.mode.allowedSources[mode] || []; + return allowedSourcesSet.has(source); + } + + isValidActionForMode(action, mode) { + const allowedActionsSet = this.config.mode.allowedActions[mode] || []; + return allowedActionsSet.has(action); + } + + setScaling(scaling) { + const scalingSet = new Set(defaultConfig.scaling.current.rules.values.map( (value) => value.value)); + scalingSet.has(scaling)? this.scaling = scaling : this.logger.warn(`${scaling} is not a valid scaling option.`); + } + + //handle input from parent / user / UI + async optimalControl(Qd, powerCap = Infinity) { + try{ + //we need to force the pressures of all machines to be equal to the highest pressure measured in the group + // this is to ensure a correct evaluation of the flow and power consumption + const pressures = Object.entries(this.machines).map(([machineId, machine]) => { + return { + downstream: machine.measurements.type("pressure").variant("measured").position("downstream").getCurrentValue(), + upstream: machine.measurements.type("pressure").variant("measured").position("upstream").getCurrentValue() + }; + }); + + const maxDownstream = Math.max(...pressures.map(p => p.downstream)); + const minUpstream = Math.min(...pressures.map(p => p.upstream)); + + //set the pressures + Object.entries(this.machines).forEach(([machineId, machine]) => { + if(machine.state.getCurrentState() !== "operational" && machine.state.getCurrentState() !== "accelerating" && machine.state.getCurrentState() !== "decelerating"){ + machine.measurements.type("pressure").variant("measured").position("downstream").value(maxDownstream); + machine.measurements.type("pressure").variant("measured").position("upstream").value(minUpstream); + // after updating the measurement directly we need to force the update of the value OLIFANT this is not so clear now in the code + // we need to find a better way to do this but for now it works + machine.getMeasuredPressure(); + } + }); + + + //update dynamic totals + const dynamicTotals = this.calcDynamicTotals(); + const machineStates = Object.entries(this.machines).reduce((acc, [machineId, machine]) => { + acc[machineId] = machine.state.getCurrentState(); + return acc; + }, {}); + + if( Qd <= 0 ) { + //if Qd is 0 turn all machines off and exit early + } + + if( Qd < dynamicTotals.flow.min && Qd > 0 ){ + //Capping Qd to lowest possible value + this.logger.warn(`Flow demand ${Qd} is below minimum possible flow ${dynamicTotals.flow.min}. Capping to minimum flow.`); + Qd = dynamicTotals.flow.min; + } + else if( Qd > dynamicTotals.flow.max ){ + //Capping Qd to highest possible value + this.logger.warn(`Flow demand ${Qd} is above maximum possible flow ${dynamicTotals.flow.max}. Capping to maximum flow.`); + Qd = dynamicTotals.flow.max; + } + + // fetch all valid combinations that meet expectations + const combinations = this.validPumpCombinations(this.machines, Qd, powerCap); + // + const bestResult = this.calcBestCombination(combinations, Qd); + + if(bestResult.bestCombination === null){ + this.logger.warn(`Demand: ${Qd.toFixed(2)} -> No valid combination found => not updating control `); + return; + } + + const debugInfo = bestResult.bestCombination.map(({ machineId, flow }) => `${machineId}: ${flow.toFixed(2)} units`).join(" | "); + this.logger.debug(`Moving to demand: ${Qd.toFixed(2)} -> Pumps: [${debugInfo}] => Total Power: ${bestResult.bestPower.toFixed(2)}`); + + //store the total delivered power + this.measurements.type("power").variant("predicted").position("upstream").value(bestResult.bestPower); + this.measurements.type("flow").variant("predicted").position("downstream").value(bestResult.bestFlow); + this.measurements.type("efficiency").variant("predicted").position("downstream").value(bestResult.bestFlow / bestResult.bestPower); + this.measurements.type("Ncog").variant("predicted").position("downstream").value(bestResult.bestCog); + + await Promise.all(Object.entries(this.machines).map(async ([machineId, machine]) => { + + const pumpInfo = bestResult.bestCombination.find(item => item.machineId == machineId); + let flow; + if(pumpInfo !== undefined){ + flow = pumpInfo.flow; + } else { + this.logger.debug(`Machine ${machineId} not in best combination, setting flow to 0`); + flow = 0; + } + + + if( (flow <= 0 ) && ( machineStates[machineId] === "operational" || machineStates[machineId] === "accelerating" || machineStates[machineId] === "decelerating" ) ){ + await machine.handleInput("parent", "execSequence", "shutdown"); + } + else if(machineStates[machineId] === "idle" && flow > 0){ + await machine.handleInput("parent", "execSequence", "startup"); + } + else if(machineStates[machineId] === "operational" && flow > 0 ){ + await machine.handleInput("parent", "flowMovement", flow); + } + + })); + + } + catch(err){ + this.logger.error(err); + } + } + + // Equalize pressure across all machines for machines that are not running. This is needed to ensure accurate flow and power predictions. + equalizePressure(){ + // Get current pressures from all machines + const pressures = Object.entries(this.machines).map(([machineId, machine]) => { + return { + downstream: machine.measurements.type("pressure").variant("measured").position("downstream").getCurrentValue(), + upstream: machine.measurements.type("pressure").variant("measured").position("upstream").getCurrentValue() + }; + }); + + // Find the highest downstream and lowest upstream pressure + const maxDownstream = Math.max(...pressures.map(p => p.downstream)); + const minUpstream = Math.min(...pressures.map(p => p.upstream)); + + // Set consistent pressures across machines + Object.entries(this.machines).forEach(([machineId, machine]) => { + if(!this.isMachineActive(machineId)){ + machine.measurements.type("pressure").variant("measured").position("downstream").value(maxDownstream); + machine.measurements.type("pressure").variant("measured").position("upstream").value(minUpstream); + // Update the measured pressure value + const pressure = machine.getMeasuredPressure(); + this.logger.debug(`Setting pressure for machine ${machineId} to ${pressure}`); + } + }); + } + + isMachineActive(machineId){ + if(this.machines[machineId].state.getCurrentState() === "operational" || this.machines[machineId].state.getCurrentState() === "accelerating" || this.machines[machineId].state.getCurrentState() === "decelerating"){ + return true; + } + return false; + } + + capFlowDemand(Qd,dynamicTotals){ + + if (Qd < dynamicTotals.flow.min && Qd > 0) { + this.logger.warn(`Flow demand ${Qd} is below minimum possible flow ${dynamicTotals.flow.min}. Capping to minimum flow.`); + Qd = dynamicTotals.flow.min; + } else if (Qd > dynamicTotals.flow.max) { + this.logger.warn(`Flow demand ${Qd} is above maximum possible flow ${dynamicTotals.flow.max}. Capping to maximum flow.`); + Qd = dynamicTotals.flow.max; + } + + return Qd; + } + + sortMachinesByPriority(priorityList) { + let machinesInPriorityOrder; + + if (priorityList && Array.isArray(priorityList)) { + machinesInPriorityOrder = priorityList + .filter(id => this.machines[id]) + .map(id => ({ id, machine: this.machines[id] })); + } else { + machinesInPriorityOrder = Object.entries(this.machines) + .map(([id, machine]) => ({ id: parseInt(id), machine })) + .sort((a, b) => a.id - b.id); + } + return machinesInPriorityOrder; + } + + filterOutUnavailableMachines(list) { + const newList = list.filter(({ id, machine }) => { + const state = machine.state.getCurrentState(); + const validSourceForMode = machine.isValidSourceForMode("parent", "auto"); + const validActionForMode = machine.isValidActionForMode("execSequence", "auto"); + + return !(state === "off" || state === "coolingdown" || state === "stopping" || state === "emergencystop" || !validSourceForMode || !validActionForMode); + }); + return newList; + } + + calcGroupEfficiency(machines){ + let cumEfficiency = 0; + let machineCount = 0; + let lowestEfficiency = Infinity; + + // Calculate the average efficiency of all machines -> peak is the average of them all + Object.entries(machines).forEach(([machineId, machine]) => { + cumEfficiency += machine.cog; + if(machine.cog < lowestEfficiency){ + lowestEfficiency = machine.cog; + } + machineCount++; + }); + + const maxEfficiency = cumEfficiency / machineCount; + + return { maxEfficiency, lowestEfficiency }; + + } + + //move machines assuming equal control in flow and a priority list + async equalFlowControl(Qd, powerCap = Infinity, priorityList = null) { + try { + + // equalize pressure across all machines + this.equalizePressure(); + + // Update dynamic totals + const dynamicTotals = this.calcDynamicTotals(); + + // Handle zero demand by shutting down all machines early exit + if (Qd <= 0) { + await Promise.all(Object.entries(this.machines).map(async ([machineId, machine]) => { + if (this.isMachineActive(machineId)) { await machine.handleInput("parent", "execSequence", "shutdown"); } + })); + return; + } + + // Cap flow demand to min/max possible values + Qd = this.capFlowDemand(Qd,dynamicTotals); + + // Get machines sorted by priority + let machinesInPriorityOrder = this.sortMachinesByPriority(priorityList); + + // Filter out machines that are unavailable for control + machinesInPriorityOrder = this.filterOutUnavailableMachines(machinesInPriorityOrder); + + // Initialize flow distribution + let flowDistribution = []; + let totalFlow = 0; + let totalPower = 0; + let totalCog = 0; + + const activeTotals = this.activeTotals(); + + // Distribute flow equally among all available machines + switch (true) { + case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0):{ + let availableFlow = activeTotals.flow.min; + for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) { + const machine = machinesInPriorityOrder[i]; + if (this.isMachineActive(machine.id)) { + flowDistribution.push({ machineId: machine.id, flow: 0 }); + availableFlow -= machine.machine.predictFlow.currentFxyYMin; + } + } + + // Determine remaining active machines (not shut down). + const remainingMachines = machinesInPriorityOrder.filter( + ({ id }) => + this.isMachineActive(id) && + !flowDistribution.some(item => item.machineId === id) + ); + + // Evenly distribute Qd among the remaining machines. + const distributedFlow = Qd / remainingMachines.length; + for (let machine of remainingMachines) { + flowDistribution.push({ machineId: machine.id, flow: distributedFlow }); + totalFlow += distributedFlow; + totalPower += machine.machine.inputFlowCalcPower(distributedFlow); + } + break; + } + + case (Qd > activeTotals.flow.max): + // Case 2: Demand is above the maximum available flow. + // Start the non-active machine with the highest priority and distribute Qd over all available machines. + let i = 1; + while (totalFlow < Qd && i <= machinesInPriorityOrder.length) { + Qd = Qd / i; + + if(machinesInPriorityOrder[i-1].machine.predictFlow.currentFxyYMax >= Qd){ + for ( let i2 = 0; i2 < i ; i2++){ + if(! this.isMachineActive(machinesInPriorityOrder[i2].id)){ + flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd }); + totalFlow += Qd; + totalPower += machinesInPriorityOrder[i2].machine.inputFlowCalcPower(Qd); + } + } + } + i++; + } + + break; + + + default: + // Default case: Demand is within the active range. + const countActiveMachines = machinesInPriorityOrder.filter(({ id }) => this.isMachineActive(id)).length; + + Qd /= countActiveMachines; + // Simply distribute the demand equally among all available machines. + for ( let i = 0 ; i < countActiveMachines ; i++){ + + flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd}); + totalFlow += Qd ; + totalPower += machinesInPriorityOrder[i].machine.inputFlowCalcPower(Qd); + + } + break; + } + + // Log information about flow distribution + const debugInfo = flowDistribution + .filter(({ flow }) => flow > 0) + .map(({ machineId, flow }) => `${machineId}: ${flow.toFixed(2)} units`) + .join(" | "); + + this.logger.debug(`Priority control for demand: ${totalFlow.toFixed(2)} -> Active pumps: [${debugInfo}] => Total Power: ${totalPower.toFixed(2)}`); + + // Store measurements + this.measurements.type("power").variant("predicted").position("upstream").value(totalPower); + this.measurements.type("flow").variant("predicted").position("downstream").value(totalFlow); + this.measurements.type("efficiency").variant("predicted").position("downstream").value(totalFlow / totalPower); + this.measurements.type("Ncog").variant("predicted").position("downstream").value(totalCog); + + // Apply the flow distribution to machines + await Promise.all(flowDistribution.map(async ({ machineId, flow }) => { + const machine = this.machines[machineId]; + const currentState = this.machines[machineId].state.getCurrentState(); + + if (flow <= 0 && (currentState === "operational" || currentState === "accelerating" || currentState === "decelerating")) { + await machine.handleInput("parent", "execSequence", "shutdown"); + } + else if (currentState === "idle" && flow > 0) { + await machine.handleInput("parent", "execSequence", "startup"); + } + else if (currentState === "operational" && flow > 0) { + await machine.handleInput("parent", "flowMovement", flow); + } + })); + } + catch (err) { + this.logger.error(err); + } + } + + //only valid with equal machines + async prioPercentageControl(input, priorityList = null) { + try{ + // stop all machines if input is negative + if(input < 0 ){ + //turn all machines off + await Promise.all(Object.entries(this.machines).map(async ([machineId, machine]) => { + if (this.isMachineActive(machineId)) { await machine.handleInput("parent", "execSequence", "shutdown"); } + })); + return; + } + + //capp input to 100 + input > 100 ? input = 100 : input = input; + + const numOfMachines = Object.keys(this.machines).length; + const procentTotal = numOfMachines * input; + const machinesNeeded = Math.ceil(procentTotal/100); + const activeTotals = this.activeTotals(); + const machinesActive = activeTotals.countActiveMachines; + // Get machines sorted by priority + let machinesInPriorityOrder = this.sortMachinesByPriority(priorityList); + const ctrlDistribution = []; //{machineId : 0, flow : 0} push for each machine + + if(machinesNeeded > machinesActive){ + + //start extra machine and put all active machines at min control + machinesInPriorityOrder.forEach(({ id, machine }, index) => { + if(index < machinesNeeded){ + ctrlDistribution.push({machineId : id, ctrl : 0}); + } + }); + } + + if(machinesNeeded < machinesActive){ + + machinesInPriorityOrder.forEach(({ id, machine }, index) => { + if(this.isMachineActive(id)){ + if(index < machinesNeeded){ + ctrlDistribution.push({machineId : id, ctrl : 100}); + } + else{ + //turn machine off + ctrlDistribution.push({machineId : id, ctrl : -1}); + } + } + }); + } + + if (machinesNeeded === machinesActive) { + // distribute input equally among active machines (0 - 100%) + const ctrlPerMachine = procentTotal / machinesActive; + + machinesInPriorityOrder.forEach(({ id, machine }) => { + if (this.isMachineActive(id)) { + // ensure ctrl is capped between 0 and 100% + const ctrlValue = Math.max(0, Math.min(ctrlPerMachine, 100)); + ctrlDistribution.push({ machineId: id, ctrl: ctrlValue }); + } + }); + } + + const debugInfo = ctrlDistribution.map(({ machineId, ctrl }) => `${machineId}: ${ctrl.toFixed(2)}%`).join(" | "); + this.logger.debug(`Priority control for input: ${input.toFixed(2)} -> Active pumps: [${debugInfo}]`); + + // Apply the ctrl distribution to machines + await Promise.all(ctrlDistribution.map(async ({ machineId, ctrl }) => { + const machine = this.machines[machineId]; + const currentState = this.machines[machineId].state.getCurrentState(); + + if (ctrl < 0 && (currentState === "operational" || currentState === "accelerating" || currentState === "decelerating")) { + await machine.handleInput("parent", "execSequence", "shutdown"); + } + else if (currentState === "idle" && ctrl >= 0) { + await machine.handleInput("parent", "execSequence", "startup"); + } + else if (currentState === "operational" && ctrl > 0) { + await machine.handleInput("parent", "execMovement", ctrl); + } + })); + + const totalPower = []; + const totalFlow = []; + + // fetch and store measurements + Object.entries(this.machines).forEach(([machineId, machine]) => { + const powerValue = machine.measurements.type("power").variant("predicted").position("upstream").getCurrentValue(); + const flowValue = machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); + if (powerValue !== null) { + totalPower.push(powerValue); + } + if (flowValue !== null) { + totalFlow.push(flowValue); + } + }); + + this.measurements.type("power").variant("predicted").position("upstream").value(totalPower.reduce((a, b) => a + b, 0)); + this.measurements.type("flow").variant("predicted").position("downstream").value(totalFlow.reduce((a, b) => a + b, 0)); + if(totalPower.reduce((a, b) => a + b, 0) > 0){ + this.measurements.type("efficiency").variant("predicted").position("downstream").value(totalFlow.reduce((a, b) => a + b, 0) / totalPower.reduce((a, b) => a + b, 0)); + } + + } + catch(err){ + this.logger.error(err); + } + } + + async handleInput(source, Qd, powerCap = Infinity, priorityList = null) { + + + if (!this.isValidSourceForMode(source, this.mode)) { + this.logger.warn(`Invalid source ${source} for mode ${this.mode}`); + return; + } + + const scaling = this.scaling; + const mode = this.mode; + let rawInput = Qd; + + switch (scaling) { + case "absolute": + // No scaling needed but cap range + if (Qd < this.absoluteTotals.flow.min) { + this.logger.warn(`Flow demand ${Qd} is below minimum possible flow ${this.absoluteTotals.flow.min}. Capping to minimum flow.`); + Qd = this.absoluteTotals.flow.min; + } else if (Qd > this.absoluteTotals.flow.max) { + this.logger.warn(`Flow demand ${Qd} is above maximum possible flow ${this.absoluteTotals.flow.max}. Capping to maximum flow.`); + Qd = this.absoluteTotals.flow.max; + } + break; + + case "normalized": + // Scale demand to 0-100% linear between min and max flow this is auto capped + Qd = this.interpolation.interpolate_lin_single_point(Qd, 0, 100, this.dynamicTotals.flow.min, this.dynamicTotals.flow.max); + break; + } + + switch(mode) { + case "prioritycontrol": + await this.equalFlowControl(Qd,powerCap,priorityList); + break; + case "prioritypercentagecontrol": + if(scaling !== "normalized"){ + this.logger.warn("Priority percentage control is only valid with normalized scaling."); + return; + } + await this.prioPercentageControl(rawInput,priorityList); + break; + case "optimalcontrol": + await this.optimalControl(Qd,powerCap); + break; + } + + //recalc distance from BEP + const { maxEfficiency, lowestEfficiency } = this.calcGroupEfficiency(this.machines); + const efficiency = this.measurements.type("efficiency").variant("predicted").position("downstream").getCurrentValue(); + this.calcDistanceBEP(efficiency,maxEfficiency,lowestEfficiency); + + } + + setMode(source,mode) { + this.isValidSourceForMode(source, mode) ? this.mode = mode : this.logger.warn(`Invalid source ${source} for mode ${mode}`); + } + + getOutput() { + + // Improved output object generation + const output = {}; + + //build the output object + this.measurements.getTypes().forEach(type => { + this.measurements.getVariants(type).forEach(variant => { + + const downstreamVal = this.measurements.type(type).variant(variant).position("downstream").getCurrentValue(); + const upstreamVal = this.measurements.type(type).variant(variant).position("upstream").getCurrentValue(); + + if (downstreamVal != null) { + output[`downstream_${variant}_${type}`] = downstreamVal; + } + if (upstreamVal != null) { + output[`upstream_${variant}_${type}`] = upstreamVal; + } + if (downstreamVal != null && upstreamVal != null) { + const diffVal = this.measurements.type(type).variant(variant).difference().value; + output[`differential_${variant}_${type}`] = diffVal; + } + }); + }); + + //fill in the rest of the output object + output["mode"] = this.mode; + output["scaling"] = this.scaling; + output["flow"] = this.flow; + output["power"] = this.power; + output["NCog"] = this.NCog; // normalized cog + output["absDistFromPeak"] = this.absDistFromPeak; + output["relDistFromPeak"] = this.relDistFromPeak; + //this.logger.debug(`Output: ${JSON.stringify(output)}`); + + return output; + } + +} + +module.exports = MachineGroup; + +/* +const Machine = require('../../../rotatingMachine/dependencies/machine/machine'); +const Measurement = require('../../../measurement/dependencies/measurement/measurement'); +const specs = require('../../../generalFunctions/datasets/assetData/pumps/hydrostal/centrifugal pumps/models.json'); +const power = require("../../../convert/dependencies/definitions/power"); +const { machine } = require("os"); + +function createBaseMachineConfig(name,specs) { + return { + general: { + logging: { enabled: true, logLevel: "warn" }, + name: name, + unit: "m3/h" + }, + functionality: { + softwareType: "machine", + role: "RotationalDeviceController" + }, + asset: { + type: "pump", + subType: "Centrifugal", + model: "TestModel", + supplier: "Hydrostal", + machineCurve: specs[0].machineCurve + }, + mode: { + current: "auto", + allowedActions: { + auto: ["execSequence", "execMovement", "statusCheck"], + virtualControl: ["execMovement", "statusCheck"], + fysicalControl: ["statusCheck"] + }, + allowedSources: { + auto: ["parent", "GUI"], + virtualControl: ["GUI"], + fysicalControl: ["fysical"] + } + }, + sequences: { + startup: ["starting", "warmingup", "operational"], + shutdown: ["stopping", "coolingdown", "idle"], + emergencystop: ["emergencystop", "off"], + boot: ["idle", "starting", "warmingup", "operational"] + } + }; +} + +function createBaseMachineGroupConfig(name) { + return { + general: { + logging: { enabled: true, logLevel: "debug" }, + name: name + }, + functionality: { + softwareType: "machineGroup", + role: "GroupController" + }, + scaling: { + current: "normalized" + }, + mode: { + current: "optimalControl" + } + }; +} + +const machineGroupConfig = createBaseMachineGroupConfig("TestMachineGroup"); +const machineConfig = createBaseMachineConfig("TestMachine",specs); +const ptConfig = { + general: { + logging: { enabled: true, logLevel: "debug" }, + name: "TestPT", + unit: "mbar", + }, + functionality: { + softwareType: "measurement", + role: "Sensor" + }, + asset: { + type: "sensor", + subType: "pressure", + model: "TestModel", + supplier: "vega" + }, + scaling:{ + absMin:0, + absMax: 4000, + } +} + +async function makeMachines(){ + const mg = new MachineGroup(machineGroupConfig); + const pt1 = new Measurement(ptConfig); + const numofMachines = 2; + for(let i = 0; i < numofMachines; i++){ + const machine = new Machine(machineConfig); + //mg.machines[i] = machine; + mg.childRegistrationUtils.registerChild(machine, "downstream"); + } + mg.machines[1].childRegistrationUtils.registerChild(pt1, "downstream"); + mg.machines[2].childRegistrationUtils.registerChild(pt1, "downstream"); + + mg.setMode("parent","prioritycontrol"); + mg.setScaling("normalized"); + + const absMax = mg.dynamicTotals.flow.max; + const absMin = mg.dynamicTotals.flow.min; + const percMin = 0; + const percMax = 100; + + try{ + /* + for(let demand = mg.dynamicTotals.flow.min ; demand <= mg.dynamicTotals.flow.max ; demand += 2){ + //set pressure + + console.log("------------------------------------"); + await mg.handleInput("parent",demand); + pt1.calculateInput(1400); + console.log("Waiting for 0.2 sec "); + //await new Promise(resolve => setTimeout(resolve, 200)); + console.log("------------------------------------"); + + } + + for(let demand = 240 ; demand >= mg.dynamicTotals.flow.min ; demand -= 40){ + //set pressure + + console.log("------------------------------------"); + + await mg.handleInput("parent",demand); + pt1.calculateInput(1400); + console.log("Waiting for 0.2 sec "); + //await new Promise(resolve => setTimeout(resolve, 200)); + console.log("------------------------------------"); + + } + +*//* + for(let demand = 0 ; demand <= 100 ; demand += 1){ + //set pressure + + console.log("------------------------------------"); + + await mg.handleInput("parent",demand); + pt1.calculateInput(1400); + console.log("Waiting for 0.2 sec "); + //await new Promise(resolve => setTimeout(resolve, 200)); + console.log("------------------------------------"); + + } + } + catch(err){ + console.log(err); + } + + + +} + + +makeMachines(); +//*/ diff --git a/dependencies/machineGroup/machineGroup.test.js b/dependencies/machineGroup/machineGroup.test.js new file mode 100644 index 0000000..80cc246 --- /dev/null +++ b/dependencies/machineGroup/machineGroup.test.js @@ -0,0 +1,566 @@ +const MachineGroup = require('./machineGroup'); +const Machine = require('../../../rotatingMachine/dependencies/machine/machine'); +const specs = require('../../../generalFunctions/datasets/assetData/pumps/hydrostal/centrifugal pumps/models.json'); + +class MachineGroupTester { + constructor() { + this.totalTests = 0; + this.passedTests = 0; + this.failedTests = 0; + this.machineCurve = specs[0].machineCurve; + } + + assert(condition, message) { + this.totalTests++; + if (condition) { + console.log(`✓ PASS: ${message}`); + this.passedTests++; + } else { + console.log(`✗ FAIL: ${message}`); + this.failedTests++; + } + } + + createBaseMachineConfig(name) { + return { + general: { + logging: { enabled: true, logLevel: "debug" }, + name: name, + unit: "m3/h" + }, + functionality: { + softwareType: "machine", + role: "RotationalDeviceController" + }, + asset: { + type: "pump", + subType: "Centrifugal", + model: "TestModel", + supplier: "Hydrostal", + machineCurve: this.machineCurve + }, + mode: { + current: "auto", + allowedActions: { + auto: ["execSequence", "execMovement", "statusCheck"], + virtualControl: ["execMovement", "statusCheck"], + fysicalControl: ["statusCheck"] + }, + allowedSources: { + auto: ["parent", "GUI"], + virtualControl: ["GUI"], + fysicalControl: ["fysical"] + } + }, + sequences: { + startup: ["starting", "warmingup", "operational"], + shutdown: ["stopping", "coolingdown", "idle"], + emergencystop: ["emergencystop", "off"], + boot: ["idle", "starting", "warmingup", "operational"] + }, + calculationMode: "medium" + }; + } + + createBaseMachineGroupConfig(name) { + return { + general: { + logging: { enabled: true, logLevel: "debug" }, + name: name + }, + functionality: { + softwareType: "machineGroup", + role: "GroupController" + }, + scaling: { + current: "normalized" + }, + mode: { + current: "optimalControl" + } + }; + } + + async testSingleMachineOperation() { + console.log('\nTesting Single Machine Operation...'); + + const machineGroupConfig = this.createBaseMachineGroupConfig("TestMachineGroup"); + const machineConfig = this.createBaseMachineConfig("TestMachine1"); + + try { + const mg = new MachineGroup(machineGroupConfig); + const machine = new Machine(machineConfig); + + // Register machine with group + mg.childRegistrationUtils.registerChild(machine, "downstream"); + machine.measurements.type("pressure").variant("measured").position("downstream").value(800); + await machine.state.transitionToState("idle"); + + // Test 1: Basic initialization + this.assert( + Object.keys(mg.machines).length === 0, + 'Machine group should have exactly zero machine' + ); + + // Test 2: Calculate demand with single machine + await machine.handleInput("parent", "execSequence", "startup"); + await mg.handleFlowInput(50); + this.assert( + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() > 0, + 'Total flow should be greater than 0 for demand of 50' + ); + + // Test 3: Check machine mode handling + machine.setMode("virtualControl"); + const {single, machineNum} = mg.singleMachine(); + this.assert( + single === true, + 'Should identify as single machine when in virtual control' + ); + + // Test 4: Zero demand handling + await mg.handleFlowInput(0); + this.assert( + !mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() === 0, + 'Total flow should be 0 for zero demand' + ); + + // Test 5: Max demand handling + await mg.handleFlowInput(100); + this.assert( + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() > 0, + 'Total flow should be greater than 0 for max demand' + ); + + } catch (error) { + console.error('Test failed with error:', error); + this.failedTests++; + } + } + + async testMultipleMachineOperation() { + console.log('\nTesting Multiple Machine Operation...'); + + const machineGroupConfig = this.createBaseMachineGroupConfig("TestMachineGroup"); + + try { + const mg = new MachineGroup(machineGroupConfig); + const machine1 = new Machine(this.createBaseMachineConfig("Machine1")); + const machine2 = new Machine(this.createBaseMachineConfig("Machine2")); + + mg.childRegistrationUtils.registerChild(machine1, "downstream"); + mg.childRegistrationUtils.registerChild(machine2, "downstream"); + + machine1.measurements.type("pressure").variant("measured").position("downstream").value(800); + machine2.measurements.type("pressure").variant("measured").position("downstream").value(800); + + await machine1.state.transitionToState("idle"); + await machine2.state.transitionToState("idle"); + + await machine1.handleInput("parent", "execSequence", "startup"); + await machine2.handleInput("parent", "execSequence", "startup"); + + // Test 1: Multiple machine registration + this.assert( + Object.keys(mg.machines).length === 2, + 'Machine group should have exactly two machines' + ); + + // Test 1.1: Calculate demand with multiple machines + await mg.handleFlowInput(0); // Testing with higher demand for two machines + const machineOutputs = Object.keys(mg.machines).filter(id => + mg.machines[id].measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() > 0 + ); + + this.assert( + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() > 0 && + machineOutputs.length > 0, + 'Should distribute load between machines' + ); + + // Test 1.2: Calculate demand with multiple machines with an increment of 10 + for(let i = 0; i < 100; i+=10){ + await mg.handleFlowInput(i); // Testing with incrementing demand + const flowValue = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); + this.assert( + flowValue !== undefined && !isNaN(flowValue), + `Should handle demand of ${i} units properly` + ); + } + + // Test 2: Calculate nonsense demands with multiple machines + await mg.handleFlowInput(150); // Testing with higher demand for two machines + this.assert( + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() > 0, + 'Should handle excessive demand gracefully' + ); + + // Test 3: Force single machine mode + machine2.setMode("maintenance"); + const {single} = mg.singleMachine(); + this.assert( + single === true, + 'Should identify as single machine when one machine is in maintenance' + ); + } catch (error) { + console.error('Test failed with error:', error); + this.failedTests++; + } + } + + async testDynamicTotals() { + console.log('\nTesting Dynamic Totals...'); + + const mg = new MachineGroup(this.createBaseMachineGroupConfig("TestMachineGroup")); + const machine = new Machine(this.createBaseMachineConfig("TestMachine")); + + try { + mg.childRegistrationUtils.registerChild(machine, "downstream"); + machine.measurements.type("pressure").variant("measured").position("downstream").value(800); + await machine.state.transitionToState("idle"); + await machine.handleInput("parent", "execSequence", "startup"); + + // Test 1: Dynamic totals initialization + const maxFlow = machine.predictFlow.currentFxyYMax; + const maxPower = machine.predictPower.currentFxyYMax; + + this.assert( + mg.dynamicTotals.flow.max === maxFlow && mg.dynamicTotals.power.max === maxPower, + 'Dynamic totals should reflect machine capabilities' + ); + + // Test 2: Demand scaling + await mg.handleFlowInput(50); // 50% of max + const actualFlow = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); + this.assert( + actualFlow <= maxFlow * 0.6, // Allow some margin for interpolation + 'Scaled demand should be approximately 50% of max flow' + ); + } catch (error) { + console.error('Test failed with error:', error); + this.failedTests++; + } + } + + async testInterpolation() { + console.log('\nTesting Interpolation...'); + + const machineGroupConfig = this.createBaseMachineGroupConfig("TestMachineGroup"); + const machineConfig = this.createBaseMachineConfig("TestMachine"); + + try { + const mg = new MachineGroup(machineGroupConfig); + const machine = new Machine(machineConfig); + + // Register machine and set initial state + mg.childRegistrationUtils.registerChild(machine, "downstream"); + machine.measurements.type("pressure").variant("measured").position("downstream").value(1); + machine.state.transitionToState("idle"); + + // Test interpolation at different demand points + const testPoints = [0, 25, 50, 75, 100]; + for (const demand of testPoints) { + await mg.handleFlowInput(demand); + const flowValue = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); + const powerValue = mg.measurements.type("power").variant("predicted").position("upstream").getCurrentValue(); + + this.assert( + flowValue !== undefined && !isNaN(flowValue), + `Interpolation should produce valid flow value for demand ${demand}` + ); + this.assert( + powerValue !== undefined && !isNaN(powerValue), + `Interpolation should produce valid power value for demand ${demand}` + ); + } + + // Test interpolation between curve points + const interpolatedPoint = 45; // Should interpolate between 40 and 60 + await mg.handleFlowInput(interpolatedPoint); + this.assert( + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() > 0, + `Interpolation should handle non-exact point ${interpolatedPoint}` + ); + + } catch (error) { + console.error('Test failed with error:', error); + this.failedTests++; + } + } + + async testSingleMachineControlModes() { + console.log('\nTesting Single Machine Control Modes...'); + + const machineGroupConfig = this.createBaseMachineGroupConfig("TestMachineGroup"); + const machineConfig = this.createBaseMachineConfig("TestMachine1"); + + try { + const mg = new MachineGroup(machineGroupConfig); + const machine = new Machine(machineConfig); + + // Register machine and initialize + mg.childRegistrationUtils.registerChild(machine, "downstream"); + machine.measurements.type("pressure").variant("measured").position("downstream").value(800); + await machine.state.transitionToState("idle"); + await machine.handleInput("parent", "execSequence", "startup"); + + // Test 1: Virtual Control Mode + machine.setMode("virtualControl"); + await mg.handleFlowInput(50); + this.assert( + machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() !== undefined, + 'Should handle virtual control mode' + ); + + // Test 2: Physical Control Mode + machine.setMode("fysicalControl"); + await mg.handleFlowInput(75); + this.assert( + machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() !== undefined, + 'Should handle physical control mode' + ); + + // Test 3: Auto Mode Return + machine.setMode("auto"); + await mg.handleFlowInput(60); + this.assert( + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() > 0, + 'Should return to normal operation in auto mode' + ); + + } catch (error) { + console.error('Test failed with error:', error); + this.failedTests++; + } + } + + async testMachinesOffNormalized() { + console.log('\nTesting Machines Off with Normalized Flow...'); + + const machineGroupConfig = this.createBaseMachineGroupConfig("TestMachineGroup_OffNormalized"); + // scaling is "normalized" by default + const mg = new MachineGroup(machineGroupConfig); + const machine = new Machine(this.createBaseMachineConfig("TestMachine_OffNormalized")); + + mg.childRegistrationUtils.registerChild(machine, "downstream"); + machine.measurements.type("pressure").variant("measured").position("downstream").value(800); + await machine.state.transitionToState("idle"); + await machine.handleInput("parent", "execSequence", "startup"); + + // Turn machines off by setting demand to 0 with normalized scaling + await mg.handleFlowInput(-1); + this.assert( + !mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() === 0, + 'Total flow should be 0 when demand is < 0 in normalized scaling' + ); + } + + async testMachinesOffAbsolute() { + console.log('\nTesting Machines Off with Absolute Flow...'); + + const machineGroupConfig = this.createBaseMachineGroupConfig("TestMachineGroup_OffAbsolute"); + // Switch scaling to "absolute" + machineGroupConfig.scaling.current = "absolute"; + const mg = new MachineGroup(machineGroupConfig); + const machine = new Machine(this.createBaseMachineConfig("TestMachine_OffAbsolute")); + + mg.childRegistrationUtils.registerChild(machine, "downstream"); + machine.measurements.type("pressure").variant("measured").position("downstream").value(800); + await machine.state.transitionToState("idle"); + await machine.handleInput("parent", "execSequence", "startup"); + + // Turn machines off by setting demand to 0 with absolute scaling + await mg.handleFlowInput(0); + this.assert( + !mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || + mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() === 0, + 'Total flow should be 0 when demand is 0 in absolute scaling' + ); + } + + async testPriorityControl() { + console.log('\nTesting Priority Control...'); + + const machineGroupConfig = this.createBaseMachineGroupConfig("TestMachineGroup_Priority"); + const mg = new MachineGroup(machineGroupConfig); + + try { + // Create 3 machines with different configurations for clearer testing + const machines = []; + for(let i = 1; i <= 3; i++) { + const machineConfig = this.createBaseMachineConfig(`Machine${i}`); + const machine = new Machine(machineConfig); + machines.push(machine); + mg.childRegistrationUtils.registerChild(machine, "downstream"); + + // Set different max flows to make priority visible + machine.predictFlow = { + currentFxyYMin: 10 * i, // Different min flows + currentFxyYMax: 50 * i // Different max flows + }; + + machine.measurements.type("pressure").variant("measured").position("downstream").value(800); + await machine.state.transitionToState("idle"); + await machine.handleInput("parent", "execSequence", "startup"); + + // Mock the inputFlowCalcPower method for testing + machine.inputFlowCalcPower = (flow) => flow * 2; // Simple mock function + } + + // Test 1: Default priority (by machine ID) + // Use handleInput which routes to equalControl in prioritycontrol mode + await mg.handleInput("parent", 80); + const flowAfterDefaultPriority = Object.values(mg.machines).map(machine => + machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0 + ); + this.assert( + flowAfterDefaultPriority[0] > 0 && flowAfterDefaultPriority[1] > 0 && flowAfterDefaultPriority[2] === 0, + 'Default priority should use machines in ID order until demand is met' + ); + + // Test 2: Custom priority list + await mg.handleInput("parent", 120, Infinity, [3, 2, 1]); + await new Promise(resolve => setTimeout(resolve, 100)); + const flowAfterCustomPriority = Object.values(mg.machines).map(machine => + machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0 + ); + this.assert( + flowAfterCustomPriority[2] > 0 && flowAfterCustomPriority[1] > 0 && flowAfterCustomPriority[0] === 0, + 'Custom priority should use machines in specified order until demand is met' + ); + + // Test 3: Zero demand should shut down all machines + await mg.handleInput("parent", 0); + const noFlowCondition = Object.values(mg.machines).every(machine => + !machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || + machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() === 0 + ); + this.assert( + noFlowCondition, + 'Zero demand should result in no flow from any machine' + ); + + // Test 4: Handling excessive demand (more than total capacity) + const totalMaxFlow = machines.reduce((sum, machine) => sum + machine.predictFlow.currentFxyYMax, 0); + await mg.handleInput("parent", totalMaxFlow + 100); + const totalActualFlow = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); + this.assert( + totalActualFlow <= totalMaxFlow && totalActualFlow > 0, + 'Excessive demand should be capped to maximum possible flow' + ); + + // Test 5: Check all measurements are updated correctly + this.assert( + mg.measurements.type("power").variant("predicted").position("upstream").getCurrentValue() > 0 && + mg.measurements.type("efficiency").variant("predicted").position("downstream").getCurrentValue() > 0, + 'All measurements should be updated after priority control' + ); + + } catch (error) { + console.error('Priority control test failed with error:', error); + this.failedTests++; + } + } + + async runAllTests() { + console.log('Starting MachineGroup Tests...\n'); + + await this.testSingleMachineOperation(); + await this.testMultipleMachineOperation(); + await this.testDynamicTotals(); + await this.testInterpolation(); + await this.testSingleMachineControlModes(); + await this.testMachinesOffNormalized(); + await this.testMachinesOffAbsolute(); + await this.testPriorityControl(); // Add the new test + await testCombinationIterations(); + + console.log('\nTest Summary:'); + console.log(`Total Tests: ${this.totalTests}`); + console.log(`Passed: ${this.passedTests}`); + console.log(`Failed: ${this.failedTests}`); + + // Return exit code based on test results + process.exit(this.failedTests > 0 ? 1 : 0); + } +} + +// Add a custom logger to capture debug logs during tests +class CapturingLogger { + constructor() { + this.logs = []; + } + debug(message) { + this.logs.push({ level: "debug", message }); + console.debug(message); + } + info(message) { + this.logs.push({ level: "info", message }); + console.info(message); + } + warn(message) { + this.logs.push({ level: "warn", message }); + console.warn(message); + } + error(message) { + this.logs.push({ level: "error", message }); + console.error(message); + } + getAll() { + return this.logs; + } + clear() { + this.logs = []; + } +} + +// Modify one of the test functions to override the machineGroup logger +async function testCombinationIterations() { + console.log('\nTesting Combination Iterations Logging...'); + + const machineGroupConfig = tester.createBaseMachineGroupConfig("TestCombinationIterations"); + const mg = new MachineGroup(machineGroupConfig); + + // Override logger with a capturing logger + const customLogger = new CapturingLogger(); + mg.logger = customLogger; + + // Create one machine for simplicity (or two if you like) + const machine = new Machine(tester.createBaseMachineConfig("TestMachineForCombo")); + mg.childRegistrationUtils.registerChild(machine, "downstream"); + machine.measurements.type("pressure").variant("measured").position("downstream").value(800); + await machine.state.transitionToState("idle"); + await machine.handleInput("parent", "execSequence", "startup"); + + // For testing, force dynamic totals so that combination search is exercised + mg.dynamicTotals.flow = { min: 0, max: 200 }; // example totalling + // Call handleFlowInput with a demand that requires iterations + await mg.handleFlowInput(120); + + // After running, output captured iteration debug logs + console.log("\n-- Captured Debug Logs for Combination Search Iterations --"); + customLogger.getAll().forEach(log => { + if(log.level === "debug") { + console.log(log.message); + } + }); + + // Also output best result details if any needed for further improvement + console.log("\n-- Final Output --"); + const totalFlow = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); + console.log("Total Flow: ", totalFlow); + + // Get machine outputs by checking each machine's measurements + const machineOutputs = {}; + Object.entries(mg.machines).forEach(([id, machine]) => { + const flow = machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue(); + if (flow) machineOutputs[id] = flow; + }); + console.log("Machine Outputs: ", machineOutputs); +} + +// Run the tests +const tester = new MachineGroupTester(); +tester.runAllTests().catch(console.error); \ No newline at end of file diff --git a/dependencies/machineGroup/machineGroupConfig.json b/dependencies/machineGroup/machineGroupConfig.json new file mode 100644 index 0000000..de10a57 --- /dev/null +++ b/dependencies/machineGroup/machineGroupConfig.json @@ -0,0 +1,188 @@ +{ + "general": { + "name": { + "default": "Machine Group Configuration", + "rules": { + "type": "string", + "description": "A human-readable name or label for this machine group configuration." + } + }, + "id": { + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "A unique identifier for this configuration. If not provided, defaults to null." + } + }, + "logging": { + "logLevel": { + "default": "info", + "rules": { + "type": "enum", + "values": [ + { + "value": "debug", + "description": "Log messages are printed for debugging purposes." + }, + { + "value": "info", + "description": "Informational messages are printed." + }, + { + "value": "warn", + "description": "Warning messages are printed." + }, + { + "value": "error", + "description": "Error messages are printed." + } + ] + } + }, + "enabled": { + "default": true, + "rules": { + "type": "boolean", + "description": "Indicates whether logging is active. If true, log messages will be generated." + } + } + } + }, + "functionality": { + "softwareType": { + "default": "machineGroup", + "rules": { + "type": "string", + "description": "Logical name identifying the software type." + } + }, + "role": { + "default": "GroupController", + "rules": { + "type": "string", + "description": "Controls a group of machines within the system." + } + } + }, + "mode": { + "current": { + "default": "optimalControl", + "rules": { + "type": "enum", + "values": [ + { + "value": "optimalControl", + "description": "The group controller selects the most optimal combination of machines based on their real-time performance curves." + }, + { + "value": "priorityControl", + "description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added." + }, + { + "value": "prioritypercentagecontrol", + "description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added based on a percentage of the total demand." + }, + { + "value": "maintenance", + "description": "The group is in maintenance mode with limited actions (monitoring only)." + } + ], + "description": "The operational mode of the machine group controller." + } + }, + "allowedActions": { + "default": {}, + "rules": { + "type": "object", + "schema": { + "optimalControl": { + "default": ["statusCheck", "execOptimalCombination", "balanceLoad", "emergencyStop"], + "rules": { + "type": "set", + "itemType": "string", + "description": "Actions allowed in optimalControl mode." + } + }, + "priorityControl": { + "default": ["statusCheck", "execSequentialControl", "balanceLoad", "emergencyStop"], + "rules": { + "type": "set", + "itemType": "string", + "description": "Actions allowed in priorityControl mode." + } + }, + "prioritypercentagecontrol": { + "default": ["statusCheck", "execSequentialControl", "balanceLoad", "emergencyStop"], + "rules": { + "type": "set", + "itemType": "string", + "description": "Actions allowed in manualOverride mode." + } + }, + "maintenance": { + "default": ["statusCheck"], + "rules": { + "type": "set", + "itemType": "string", + "description": "Actions allowed in maintenance mode." + } + } + }, + "description": "Defines the actions available for each operational mode of the machine group controller." + } + }, + "allowedSources": { + "default": {}, + "rules": { + "type": "object", + "schema": { + "optimalcontrol": { + "default": ["parent", "GUI", "physical", "API"], + "rules": { + "type": "set", + "itemType": "string", + "description": "Command sources allowed in optimalControl mode." + } + }, + "prioritycontrol": { + "default": ["parent", "GUI", "physical", "API"], + "rules": { + "type": "set", + "itemType": "string", + "description": "Command sources allowed in priorityControl mode." + } + }, + "prioritypercentagecontrol": { + "default": ["parent", "GUI", "physical", "API"], + "rules": { + "type": "set", + "itemType": "string", + "description": "Command sources allowed " + } + } + }, + "description": "Specifies the valid command sources recognized by the machine group controller for each mode." + } + } + }, + "scaling": { + "current": { + "default": "normalized", + "rules": { + "type": "enum", + "values": [ + { + "value": "normalized", + "description": "Scales the demand between 0–100% of the total flow capacity, interpolating to calculate the effective demand." + }, + { + "value": "absolute", + "description": "Uses the absolute demand value directly, capped between the min and max machine flow capacities." + } + ], + "description": "The scaling mode for demand calculations." + } + } + } + } diff --git a/dependencies/test.js b/dependencies/test.js new file mode 100644 index 0000000..1f3bd2b --- /dev/null +++ b/dependencies/test.js @@ -0,0 +1,137 @@ +/** + * This file implements a pump optimization algorithm that: + * 1. Models different pumps with efficiency characteristics + * 2. Determines all possible pump combinations that can meet a demand flow + * 3. Finds the optimal combination that minimizes power consumption + * 4. Tests the algorithm with different demand levels + */ + +/** + * Pump Class + * Represents a pump with specific operating characteristics including: + * - Maximum flow capacity + * - Center of Gravity (CoG) for efficiency + * - Efficiency curve mapping flow percentages to power consumption + */ +class Pump { + constructor(name, maxFlow, cog, efficiencyCurve) { + this.name = name; + this.maxFlow = maxFlow; // Maximum flow at a given pressure + this.CoG = cog; // Efficiency center of gravity percentage + this.efficiencyCurve = efficiencyCurve; // Flow % -> Power usage mapping + } + + /** + * Returns pump flow at a given pressure + * Currently assumes constant flow regardless of pressure + */ + getFlow(pressure) { + return this.maxFlow; // Assume constant flow at a given pressure + } + + /** + * Calculates power consumption based on flow and pressure + * Uses the efficiency curve when available, otherwise uses linear approximation + */ + getPowerConsumption(flow, pressure) { + let flowPercent = flow / this.maxFlow; + return this.efficiencyCurve[flowPercent] || (1.2 * flow); // Default linear approximation + } +} + +/** + * Test pump definitions + * Three pump models with different flow capacities and efficiency characteristics + */ +const pumps = [ + new Pump("Pump A", 100, 0.6, {0.6: 50, 0.8: 70, 1.0: 100}), + new Pump("Pump B", 120, 0.7, {0.6: 55, 0.8: 75, 1.0: 110}), + new Pump("Pump C", 90, 0.5, {0.5: 40, 0.7: 60, 1.0: 90}), +]; + +const pressure = 1.0; // Assume constant pressure + +/** + * Get all valid pump combinations that meet the required demand flow (Qd) + * + * @param {Array} pumps - Available pump array + * @param {Number} Qd - Required demand flow + * @param {Number} pressure - System pressure + * @returns {Array} Array of valid pump combinations that can meet or exceed the demand + * + * This function: + * 1. Generates all possible subsets of pumps (power set) + * 2. Filters for non-empty subsets that can meet or exceed demand flow + */ +function getValidPumpCombinations(pumps, Qd, pressure) { + let subsets = [[]]; + for (let pump of pumps) { + let newSubsets = subsets.map(set => [...set, pump]); + subsets = subsets.concat(newSubsets); + } + return subsets.filter(subset => subset.length > 0 && + subset.reduce((sum, p) => sum + p.getFlow(pressure), 0) >= Qd); +} + +/** + * Find the optimal pump combination that minimizes power consumption + * + * @param {Array} pumps - Available pump array + * @param {Number} Qd - Required demand flow + * @param {Number} pressure - System pressure + * @returns {Object} Object containing the best pump combination and its power consumption + * + * This function: + * 1. Gets all valid pump combinations that meet demand + * 2. For each combination, distributes flow based on CoG proportions + * 3. Calculates total power consumption for each distribution + * 4. Returns the combination with minimum power consumption + */ +function optimizePumpSelection(pumps, Qd, pressure) { + let validCombinations = getValidPumpCombinations(pumps, Qd, pressure); + let bestCombination = null; + let minPower = Infinity; + + validCombinations.forEach(combination => { + let totalFlow = combination.reduce((sum, pump) => sum + pump.getFlow(pressure), 0); + let totalCoG = combination.reduce((sum, pump) => sum + pump.CoG, 0); + + // Distribute flow based on CoG proportions + let flowDistribution = combination.map(pump => ({ + pump, + flow: (pump.CoG / totalCoG) * Qd + })); + + let totalPower = flowDistribution.reduce((sum, { pump, flow }) => + sum + pump.getPowerConsumption(flow, pressure), 0); + + if (totalPower < minPower) { + minPower = totalPower; + bestCombination = flowDistribution; + } + }); + + return { bestCombination, minPower }; +} + +/** + * Test function that runs optimization for different demand levels + * Tests from 0% to 100% of total available flow in 10% increments + * Outputs the selected pumps, flow allocation, and power consumption for each scenario + */ +console.log("Pump Optimization Results:"); +const totalAvailableFlow = pumps.reduce((sum, pump) => sum + pump.getFlow(pressure), 0); + +for (let i = 0; i <= 10; i++) { + let Qd = (i / 10) * totalAvailableFlow; // Incremental flow demand + let { bestCombination, minPower } = optimizePumpSelection(pumps, Qd, pressure); + + console.log(`\nTotal Demand Flow: ${Qd.toFixed(2)}`); + console.log("Selected Pumps and Allocated Flow:"); + + bestCombination.forEach(({ pump, flow }) => { + console.log(` ${pump.name}: ${flow.toFixed(2)} units`); + }); + + console.log(`Total Power Consumption: ${minPower.toFixed(2)} kW`); +} diff --git a/mgc.html b/mgc.html new file mode 100644 index 0000000..40a5eca --- /dev/null +++ b/mgc.html @@ -0,0 +1,154 @@ + + + + + + diff --git a/mgc.js b/mgc.js new file mode 100644 index 0000000..a553f2c --- /dev/null +++ b/mgc.js @@ -0,0 +1,171 @@ +module.exports = function (RED) { + function machineGroupControl(config) { + //create node + RED.nodes.createNode(this, config); + + //call this => node so whenver you want to call a node function type node and the function behind it + var node = this; + + //fetch machine object from machine.js + const MachineGroup = require('./dependencies/machineGroup/machineGroup'); + const OutputUtils = require("../generalFunctions/helper/outputUtils"); + + const mgConfig = config = { + general: { + name: config.name, + id : config.id, + logging: { + enabled: config.loggingEnabled, + logLevel: config.logLevel, + } + }, + }; + + //make new class on creation to work with. + const mg = new MachineGroup(mgConfig); + + // put mg on node memory as source + node.source = mg; + + //load output utils + const output = new OutputUtils(); + + //update node status + function updateNodeStatus(mg) { + + const mode = mg.mode; + const scaling = mg.scaling; + const totalFlow = Math.round(mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() * 1) / 1; + const totalPower = Math.round(mg.measurements.type("power").variant("predicted").position("upstream").getCurrentValue() * 1) / 1; + + // Calculate total capacity based on available machines + const availableMachines = Object.values(mg.machines).filter(machine => { + const state = machine.state.getCurrentState(); + const mode = machine.currentMode; + return !(state === "off" || state === "maintenance" || mode === "maintenance"); + }); + + const totalCapacity = Math.round(mg.dynamicTotals.flow.max * 1) / 1; + + // Determine overall status based on available machines + const status = availableMachines.length > 0 + ? `${availableMachines.length} machines` + : "No machines"; + + let scalingSymbol = ''; + switch (scaling.toLowerCase()) { + case 'absolute': + scalingSymbol = 'Ⓐ'; // Clear symbol for Absolute mode + break; + case 'normalized': + scalingSymbol = 'Ⓝ'; // Clear symbol for Normalized mode + break; + default: + scalingSymbol = mode; + break; + } + + + // Generate status text in a single line + const text = ` ${mode} | ${scalingSymbol}: 💨=${totalFlow}/${totalCapacity} | ⚡=${totalPower} | ${status}`; + + return { + fill: availableMachines.length > 0 ? "green" : "red", + shape: "dot", + text + }; + } + + //never ending functions + function tick(){ + //source.tick(); + const status = updateNodeStatus(mg); + node.status(status); + + //get output + const classOutput = mg.getOutput(); + const dbOutput = output.formatMsg(classOutput, mg.config, "influxdb"); + const pOutput = output.formatMsg(classOutput, mg.config, "process"); + + //only send output on values that changed + let msgs = []; + msgs[0] = pOutput; + msgs[1] = dbOutput; + + node.send(msgs); + } + + // register child on first output this timeout is needed because of node - red stuff + setTimeout( + () => { + + /*---execute code on first start----*/ + let msgs = []; + + msgs[2] = { topic : "registerChild" , payload: node.id, positionVsParent: "upstream" }; + msgs[3] = { topic : "registerChild" , payload: node.id, positionVsParent: "downstream" }; + + //send msg + this.send(msgs); + }, + 100 + ); + + //declare refresh interval internal node + setTimeout( + () => { + /*---execute code on first start----*/ + this.interval_id = setInterval(function(){ tick() },1000) + }, + 1000 + ); + + //-------------------------------------------------------------------->>what to do on input + node.on("input", async function (msg,send,done) { + + if(msg.topic == 'registerChild'){ + const childId = msg.payload; + const childObj = RED.nodes.getNode(childId); + mg.childRegistrationUtils.registerChild(childObj.source,msg.positionVsParent); + } + + if(msg.topic == 'setMode'){ + const mode = msg.payload; + const source = "parent"; + mg.setMode(source,mode); + } + + if(msg.topic == 'setScaling'){ + const scaling = msg.payload; + mg.setScaling(scaling); + } + + if(msg.topic == 'Qd'){ + const Qd = parseFloat(msg.payload); + const source = "parent"; + + if (isNaN(Qd)) { + return mg.logger.error(`Invalid demand value: ${Qd}`); + }; + + try{ + + await mg.handleInput(source,Qd); + msg.topic = mg.config.general.name; + msg.payload = "done"; + send(msg); + }catch(e){ + console.log(e); + } + } + + // tidy up any async code here - shutdown connections and so on. + node.on('close', function() { + clearTimeout(this.interval_id); + }); + + + }); + } + RED.nodes.registerType("machineGroupControl", machineGroupControl); +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee72834 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "machineGroupControl", + "version": "0.9.0", + "description": "Control module machineGroupControl", + "main": "mgc.js", + "scripts": { + "test": "node mgc.js" + }, + "repository": { + "type": "git", + "url": "https://gitea.centraal.wbd-rd.nl/RnD/machineGroupControl.git" + }, + "keywords": [ + "machineGroupControl", + "node-red" + ], + "author": "Rene De Ren", + "license": "MIT", + "dependencies": { + "generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git", + "predict": "git+https://gitea.centraal.wbd-rd.nl/RnD/predict.git" + }, + "node-red": { + "nodes": { + "machineGroupControl": "mgc.js" + } + } +} \ No newline at end of file