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