diff --git a/dependencies/heatExchanger/heatExchanger.js b/dependencies/heatExchanger/heatExchanger.js new file mode 100644 index 0000000..934b99c --- /dev/null +++ b/dependencies/heatExchanger/heatExchanger.js @@ -0,0 +1,318 @@ +/** + * @file heatExchanger.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. + * + * Author: + * - Rene De Ren + * Email: + * - rene@thegoldenbasket.nl + * + * Future Improvements: + * - Time-based stability checks + * - Warmup handling + * - Dynamic outlier detection thresholds + * - Dynamic smoothing window and methods + * - Alarm and threshold handling + * - Maintenance mode + * - Historical data and trend analysis + */ + +const EventEmitter = require('events'); +const Logger = require('../../../generalFunctions/helper/logger'); +const defaultConfig = require('./heatExchangerConfig.json'); +const ConfigUtils = require('../../../generalFunctions/helper/configUtils'); + +class heatExchanger { + constructor(config={}) { + + this.emitter = new EventEmitter(); // Own EventEmitter + this.configUtils = new ConfigUtils(defaultConfig); + this.config = this.configUtils.initConfig(config); + + // Init after config is set + this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name); + this.measurements = {}; + + // defining all parameters of a heat exchanger + this.maxFlow = this.config.asset.maxFlowRate; // expressed in m3/h + this.volume = 0.36; // expressed in liters + this.tempRange = {min: -195 , max : 225}; // expressed in °C + this.pressureRange = {min: 0, max: 20}; // expressed in bar + this.maxPower = this.config.asset.maxPower ; // expressed in kW + this.type = this.config.asset.subType ; // type of heat exchanger + + //keep track of last known values + this.mFlowRateHot = 0; // kg/s + this.mFlowRateCold = 0; // kg/s + this.tempInHot = 0; // °C + this.tempOutHot = 0; // °C + this.tempInCold = 0; // °C + this.tempOutCold = 0; // °C + this.Q_act = 0; // kW + + //medium properties + this.inputMedium = this.config.medium.input; // type of medium + this.outputMedium = this.config.medium.output; // type of medium + //build lookup table for medium properties (to do later) + this.CpIn = 4.18; // specific heat capacity of water in kJ/kg°C + this.CpOut = 4.18; // specific heat capacity of water in kJ/kg°C + + this.flowConfig = this.config.flowConfiguration; // Type of flow arrangement: "counterflow", "parallel", "crossflow", etc. + + this.U = this.config.asset.U; // average for stainaless steel plate heat exchangers water to water - 2000-5000 W/m2K + this.A = this.config.asset.A; // heat exchanger area in m2 (for example 0.24m2) + this.UA = this.calcUA(this.U,this.A) / 1000; // heat exchanger UA value in kW/K + + this.logger.debug(`heatExchanger id: ${this.config.general.id}, initialized successfully.`); + + } + + //later add conversions for different units + setHotFlowRate(flowRate) { + this.mFlowRateHot = this.m3hToKgs(flowRate); + this.calcOutputTemps(); + return this.mFlowRateHot; + } + + setColdFlowRate(flowRate) { + if (flowRate === undefined || flowRate === null || isNaN(parseFloat(flowRate))){ + this.logger.error(`Invalid flow rate value: ${flowRate}`); + this.mFlowRateCold = 0; + return this.mFlowRateCold; + } + this.mFlowRateCold = this.m3hToKgs(flowRate); + this.calcOutputTemps(); + return this.mFlowRateCold; + } + + setHotInput(temp) { + this.tempInHot = temp; + this.calcOutputTemps(); + return this.tempInHot; + } + + setColdInput(temp) { + this.tempInCold = temp; + this.calcOutputTemps(); + return this.tempInCold; + } + + calcUA(U, A) { + // UA = U * A + const UA = U * A; + return UA; + } + + getHeatTransferRate(mFlowRate, Cp, deltaT) { + // Q heat transfer rate kW = m mass flow rate kg/s * Cp specific heat capacity kJ/kg°C * ΔT temperature difference °C + const Q = mFlowRate * Cp * deltaT; + return Q; + } + + calcOutputTemps() { + // Calculate outlet temperatures based on last known input temperatures and heat transfer rate + const result = this.calcOutletTempsEffectivenessNTU(this.mFlowRateHot, this.tempInHot, this.mFlowRateCold, this.tempInCold); + this.tempOutHot = result.tempOutHot; + this.tempOutCold = result.tempOutCold; + this.Q_act = result.Q; + this.logger.debug(`Hot Outlet Temperature: ${this.tempOutHot.toFixed(2)} °C, Cold Outlet Temperature: ${this.tempOutCold.toFixed(2)} °C, Heat Transfer Rate: ${this.Q_act.toFixed(2)} kW`); + return result; + } + + calcOutletTempsEffectivenessNTU(mFlowRateHot, tempInHot, mFlowRateCold, tempInCold) { + // Calculate heat capacity rates + const C_hot = mFlowRateHot * this.CpIn; + const C_cold = mFlowRateCold * this.CpOut; + const UA = this.UA; + const flowConfig = this.flowConfig; + + // Find C_min and C_max + const C_min = Math.min(C_hot, C_cold); + const C_max = Math.max(C_hot, C_cold); + const Cr = C_min / C_max; + + // Calculate NTU (Number of Transfer Units) + const NTU = (C_min > 0) ? UA / C_min : 0; + + // Calculate effectiveness based on exchanger type + let epsilon; + switch (flowConfig) { + case "counterflow": + // Special case for Cr = 1 (balanced flow) + if (Math.abs(Cr - 1) < 0.00001) { + epsilon = NTU / (1 + NTU); + } else { + epsilon = (1 - Math.exp(-NTU * (1 - Cr))) / (1 - Cr * Math.exp(-NTU * (1 - Cr))); + } + break; + case "parallelflow": + epsilon = (1 - Math.exp(-NTU * (1 + Cr))) / (1 + Cr); + break; + default: + throw new Error(`Unsupported flow configuration ${flowConfig}`); + + } + // Calculate maximum heat transfer in + const Q_max = C_min * (tempInHot - tempInCold); + + // Calculate actual heat transfer + const Q = Math.min ( epsilon * Q_max, this.maxPower) ; + + // Calculate outlet temperatures + const tempOutHot = C_hot > 0 ? ( tempInHot - Q / C_hot) : tempInHot; + const tempOutCold = C_cold > 0 ? ( tempInCold + Q / C_cold ) : tempInCold; + + return { tempOutHot, tempOutCold, Q }; + } + + // mFlowRate in kg/s, Cp in kJ/kg°C, tempIn in °C without effectiveness + calcOutletTemps(mFlowRateHot, CpHot, tempInHot, mFlowRateCold, CpCold, tempInCold, Q) { + // Calculate hot outlet temperature (heat is lost) + const tempOutHot = tempInHot - (Q / (mFlowRateHot * CpHot)); + + // Calculate cold outlet temperature (heat is gained) + const tempOutCold = tempInCold + (Q / (mFlowRateCold * CpCold)); + + return { tempOutHot, tempOutCold }; + } + + // Use Q to get the output temperature of the same stream temp in °C, mFlowRate in kg/s, Cp in kJ/kg°C + getTempOut(mFlowRate, Cp, tempIn, Q) { + // T_out = T_in + Q / (m * Cp) + const tempOut = tempIn + Q / (mFlowRate * Cp); + return tempOut; + } + + m3hToKgs(m3h) { + // 1 m3/h = 0.000277778 m3/s + const m3s = m3h * 0.000277778; + // 1 m3 = 1000 kg + const kgs = m3s * 1000; + return kgs; + } + + // use Q to get the input temperature of the same stream + getTempIn(mFlowRate, Cp, tempOut, Q) { + // T_in = T_out - Q / (m * Cp) + const tempIn = tempOut - Q / (mFlowRate * Cp); + return tempIn; + } + + getDeltaT(tempIn, tempOut) { + // ΔT = T_out - T_in + const deltaT = tempOut - tempIn; + return deltaT; + } + + getOutput() { + + // Improved output object generation + const output = {}; + //build the output object + if(Object.keys(this.measurements).length > 0) { + 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; + } + }); + }); + } + output["flowRateHot"] = this.mFlowRateHot; + output["flowRateCold"] = this.mFlowRateCold; + output["tempOutHot"] = this.tempOutHot; + output["tempOutCold"] = this.tempOutCold; + output["Q"] = this.Q_act; + + return output; + } + +} + +module.exports = heatExchanger; + +/* +// Testing the class +const configuration = { + general: { + name: "he1", + logging: { + enabled: true, + logLevel: "debug", + }, + } +}; + + +const he = new heatExchanger(configuration); + +he.logger.info(`heatExchanger created with config : ${JSON.stringify(he.config)}`); + +he.logger.setLogLevel("debug"); + +// hot side (water) +const mFlowHot = he.m3hToKgs(1); // kg/s +const CpHot = 4.18; // kJ/kg.K (Water) +const Th_in = 100; // °C + +// cold side (water) +const mFlowCold = he.m3hToKgs(1); // kg/s +const CpCold = 4.18; // kJ/kg.K (Water) +const Tc_in = 10; // °C + +console.log(`UA: ${he.UA} W/K`); + +const results = he.calcOutletTempsEffectivenessNTU(mFlowHot, CpHot, Th_in, mFlowCold, CpCold, Tc_in); +//loop over different temperatures and calculate the heat transfer rate +for(let i = 0; i < 10; i++) { + const tempOutHot = results.tempOutHot - i; + const tempOutCold = results.tempOutCold + i; + const Q = he.getHeatTransferRate(mFlowHot, CpHot, Th_in - tempOutHot); + const deltaT = he.getDeltaT(tempOutHot, tempOutCold); + console.log(`Hot Outlet Temperature: ${tempOutHot.toFixed(2)} °C, Cold Outlet Temperature: ${tempOutCold.toFixed(2)} °C, Heat Transfer Rate: ${Q.toFixed(2)} kW, ΔT: ${deltaT.toFixed(2)} °C`); +} + +console.log(`---------------------------------`); +console.log(`Hot Inlet Temperature: ${Th_in} °C`); +console.log(`Hot Outlet Temperature: ${results.tempOutHot.toFixed(2)} °C`); +console.log(`flow rate hot: ${mFlowHot} kg/s`); +console.log(`---------------------------------`); +console.log(`Cold Inlet Temperature: ${Tc_in} °C`); +console.log(`Cold Outlet Temperature: ${results.tempOutCold.toFixed(2)} °C`); +console.log(`flow rate cold: ${mFlowCold} kg/s`); +console.log(`---------------------------------`); +console.log(`Heat Transfer Rate: ${results.Q.toFixed(2)} kW`); + +// */ \ No newline at end of file diff --git a/dependencies/heatExchanger/heatExchanger.test.js b/dependencies/heatExchanger/heatExchanger.test.js new file mode 100644 index 0000000..02dbe97 --- /dev/null +++ b/dependencies/heatExchanger/heatExchanger.test.js @@ -0,0 +1,471 @@ +const HeatExchanger = require('./heatExchanger'); + +// Suppress console output from the actual class during testing +const silentLogger = { + error: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + setLogLevel: () => {} +}; + +// Base configuration for testing +const testConfig = { + general: { + name: "test_heat_exchanger", + id: "HX-001", + logging: { + enabled: false, + logLevel: "error" + } + }, + asset: { + maxFlowRate: 10, // m3/h + maxPower: 100, // kW + subType: "plate", + U: 3000, // W/m²K - Overall heat transfer coefficient + A: 0.24 // m² - Heat transfer area + }, + medium: { + input: "water", + output: "water" + }, + flowConfiguration: "counterflow" // Default to counterflow +}; + +class HeatExchangerTester { + constructor() { + this.totalTests = 0; + this.passedTests = 0; + this.failedTests = 0; + this.heatExchanger = new HeatExchanger(testConfig); + + // Override logger to prevent console output during tests + this.heatExchanger.logger = silentLogger; + } + + assert(condition, message) { + this.totalTests++; + if (condition) { + console.log(`✓ PASS: ${message}`); + this.passedTests++; + } else { + console.log(`✗ FAIL: ${message}`); + this.failedTests++; + } + } + + assertApproxEqual(actual, expected, tolerance = 0.01, message) { + const diff = Math.abs(actual - expected); + const relativeError = expected !== 0 ? diff / Math.abs(expected) : diff; + + this.assert( + relativeError <= tolerance, + `${message} - Expected: ${expected.toFixed(3)}, Got: ${actual.toFixed(3)}, Relative Error: ${(relativeError * 100).toFixed(2)}%` + ); + } + + testM3hToKgsConversion() { + console.log("\nTesting m3/h to kg/s Conversion..."); + + // Standard water conversion + const m3h = 3.6; // 3.6 m³/h + const expectedKgs = 1.0; // 1.0 kg/s + + const convertedValue = this.heatExchanger.m3hToKgs(m3h); + this.assertApproxEqual(convertedValue, expectedKgs, 0.001, "3.6 m³/h should convert to 1.0 kg/s"); + + // Zero case + this.assertApproxEqual(this.heatExchanger.m3hToKgs(0), 0, 0.001, "0 m³/h should convert to 0 kg/s"); + + // Large value + this.assertApproxEqual(this.heatExchanger.m3hToKgs(36), 10, 0.001, "36 m³/h should convert to 10 kg/s"); + } + + testUACalculation() { + console.log("\nTesting UA Calculation..."); + + // Simple multiplication + const U = 3; // kW/m²K + const A = 0.24; // m² + const expectedUA = 0.720; // kW/K + + const calculatedUA = this.heatExchanger.calcUA(U, A); + this.assertApproxEqual(calculatedUA, expectedUA, 0.001, "UA should be U × A"); + + // Ensure class initialization sets UA correctly + this.assertApproxEqual(this.heatExchanger.UA, expectedUA, 0.001, "UA should be initialized correctly"); + } + + testHeatTransferRate() { + console.log("\nTesting Heat Transfer Rate Calculation..."); + + // Basic heat transfer equation: Q = ṁ × Cp × ΔT + const mFlowRate = 1.0; // kg/s + const Cp = 4.18; // kJ/kgK (water) + const deltaT = 10; // 10°C temperature difference + const expectedQ = 41.8; // kW + + const calculatedQ = this.heatExchanger.getHeatTransferRate(mFlowRate, Cp, deltaT); + this.assertApproxEqual(calculatedQ, expectedQ, 0.001, "Heat transfer rate should match Q = ṁ × Cp × ΔT"); + + // Zero case + this.assertApproxEqual(this.heatExchanger.getHeatTransferRate(0, Cp, deltaT), 0, 0.001, "No flow should result in zero heat transfer"); + this.assertApproxEqual(this.heatExchanger.getHeatTransferRate(mFlowRate, Cp, 0), 0, 0.001, "No temperature difference should result in zero heat transfer"); + } + + testDeltaTCalculation() { + console.log("\nTesting Delta T Calculation..."); + + const tempIn = 20; // °C + const tempOut = 30; // °C + const expectedDeltaT = 10; // °C + + const calculatedDeltaT = this.heatExchanger.getDeltaT(tempIn, tempOut); + this.assertApproxEqual(calculatedDeltaT, expectedDeltaT, 0.001, "Delta T calculation should be correct"); + + // Negative delta (cooling) + this.assertApproxEqual( + this.heatExchanger.getDeltaT(50, 20), + -30, + 0.001, + "Delta T should handle cooling correctly" + ); + } + + testCalcOutletTempsEffectivenessNTU_Counterflow() { + console.log("\nTesting Outlet Temperature Calculation (Counterflow)..."); + + // Create a specific instance for this test with counterflow + const config = JSON.parse(JSON.stringify(testConfig)); + config.flowConfiguration = "counterflow"; + const counterflowHE = new HeatExchanger(config); + counterflowHE.logger = silentLogger; + + // Test case: balanced flow (C_hot = C_cold) + const mFlowRateHot = 1.0; // kg/s + const tempInHot = 100; // °C + const mFlowRateCold = 1.0; // kg/s + const tempInCold = 20; // °C + + // UA = 720 W/K = 0.72 kW/K + // C_min = m * Cp = 1 * 4.18 = 4.18 kW/K + // NTU = UA/C_min = 0.72 / 4.18 = 0.172 + // Cr = 1 (balanced flow) + // Effectiveness (counterflow) for Cr=1: ε = NTU/(1+NTU) = 0.172/(1+0.172) = 0.147 + // Q_max = C_min * (Th_in - Tc_in) = 4.18 * (100 - 20) = 334.4 kW + // Q = ε * Q_max = 0.147 * 334.4 = 49.2 kW (but limited by maxPower = 100 kW) + // Th_out = Th_in - Q/C_hot = 100 - 49.2/4.18 = 88.2°C + // Tc_out = Tc_in + Q/C_cold = 20 + 49.2/4.18 = 31.8°C + + const result = counterflowHE.calcOutletTempsEffectivenessNTU( + mFlowRateHot, + tempInHot, + mFlowRateCold, + tempInCold + ); + + // Using calculated effectiveness value to check outputs + this.assertApproxEqual(result.tempOutHot, 88.2, 0.1, "Hot outlet temperature should match for counterflow"); + this.assertApproxEqual(result.tempOutCold, 31.8, 0.1, "Cold outlet temperature should match for counterflow"); + this.assertApproxEqual(result.Q, 49.2, 0.3, "Heat transfer rate should match for counterflow"); + + // Check energy balance + const QHot = mFlowRateHot * counterflowHE.CpIn * (tempInHot - result.tempOutHot); + const QCold = mFlowRateCold * counterflowHE.CpOut * (result.tempOutCold - tempInCold); + this.assertApproxEqual(QHot, QCold, 0.01, "Energy balance should be maintained (Q_hot = Q_cold)"); + } + + testCalcOutletTempsEffectivenessNTU_Parallel() { + console.log("\nTesting Outlet Temperature Calculation (Parallel Flow)..."); + + // Create a specific instance for this test with parallel flow + const config = JSON.parse(JSON.stringify(testConfig)); + config.flowConfiguration = "parallelflow"; + const parallelHE = new HeatExchanger(config); + parallelHE.logger = silentLogger; + + // Same inputs as the counterflow test for comparison + const mFlowRateHot = 1.0; // kg/s + const tempInHot = 100; // °C + const mFlowRateCold = 1.0; // kg/s + const tempInCold = 20; // °C + + // Calculate expected values with the correct formula + // NTU = 0.172, Cr = 1 + // Effectiveness (parallel flow): ε = (1 - exp(-NTU * (1 + Cr))) / (1 + Cr) + // ε = (1 - exp(-0.172 * 2)) / 2 ≈ 0.146 + // Q = ε * Q_max = 0.146 * 334.4 ≈ 48.8 kW + // Th_out = Th_in - Q/C_hot = 100 - 48.8/4.18 ≈ 88.3°C + // Tc_out = Tc_in + Q/C_cold = 20 + 48.8/4.18 ≈ 31.7°C + + const result = parallelHE.calcOutletTempsEffectivenessNTU( + mFlowRateHot, + tempInHot, + mFlowRateCold, + tempInCold + ); + + // Using corrected calculated effectiveness value to check outputs + this.assertApproxEqual(result.tempOutHot, 88.3, 0.1, "Hot outlet temperature should match for parallel flow"); + this.assertApproxEqual(result.tempOutCold, 31.7, 0.1, "Cold outlet temperature should match for parallel flow"); + this.assertApproxEqual(result.Q, 48.8, 0.3, "Heat transfer rate should match for parallel flow"); + + // Check energy balance + const QHot = mFlowRateHot * parallelHE.CpIn * (tempInHot - result.tempOutHot); + const QCold = mFlowRateCold * parallelHE.CpOut * (result.tempOutCold - tempInCold); + this.assertApproxEqual(QHot, QCold, 0.01, "Energy balance should be maintained (Q_hot = Q_cold)"); + } + + testMaxPowerLimit() { + console.log("\nTesting Maximum Power Limit..."); + + // Create a specific instance with low maximum power + const config = JSON.parse(JSON.stringify(testConfig)); + config.asset.maxPower = 20; // kW - intentionally low + const limitedHE = new HeatExchanger(config); + limitedHE.logger = silentLogger; + + // Set up a scenario that would exceed the maximum power + const mFlowRateHot = 2.0; // kg/s + const tempInHot = 100; // °C + const mFlowRateCold = 2.0; // kg/s + const tempInCold = 10; // °C + + // With these parameters, the theoretical heat transfer would be higher, + // but should be limited to 20 kW by the maxPower setting + + const result = limitedHE.calcOutletTempsEffectivenessNTU( + mFlowRateHot, + tempInHot, + mFlowRateCold, + tempInCold + ); + + this.assertApproxEqual(result.Q, 20, 0.01, "Heat transfer rate should be limited by maxPower"); + + // Calculate what the outlet temperatures should be with the limited power + // Th_out = Th_in - Q/C_hot = 100 - 20/(2*4.18) = 97.6°C + // Tc_out = Tc_in + Q/C_cold = 10 + 20/(2*4.18) = 12.4°C + + this.assertApproxEqual(result.tempOutHot, 97.6, 0.1, "Hot outlet temperature should reflect power limit"); + this.assertApproxEqual(result.tempOutCold, 12.4, 0.1, "Cold outlet temperature should reflect power limit"); + } + + testImbalancedFlowRates() { + console.log("\nTesting Imbalanced Flow Rates..."); + + // Create a specific instance for this test + const imbalancedHE = new HeatExchanger(testConfig); + imbalancedHE.logger = silentLogger; + + // Test case: C_hot < C_cold + const mFlowRateHot = 0.5; // kg/s + const tempInHot = 80; // °C + const mFlowRateCold = 2.0; // kg/s + const tempInCold = 15; // °C + + // C_hot = 0.5 * 4.18 = 2.09 kW/K + // C_cold = 2.0 * 4.18 = 8.36 kW/K + // C_min = 2.09, C_max = 8.36 + // Cr = 2.09/8.36 = 0.25 + // NTU = UA/C_min = 0.72/2.09 = 0.344 + // Effectiveness calculation for counterflow with Cr=0.25: + // ε = (1 - exp(-NTU * (1 - Cr))) / (1 - Cr * exp(-NTU * (1 - Cr))) + + const result = imbalancedHE.calcOutletTempsEffectivenessNTU( + mFlowRateHot, + tempInHot, + mFlowRateCold, + tempInCold + ); + + // Verify reasonable outputs + this.assert(result.tempOutHot < tempInHot, "Hot outlet temperature should be less than inlet"); + this.assert(result.tempOutCold > tempInCold, "Cold outlet temperature should be greater than inlet"); + + // Check energy balance + const QHot = mFlowRateHot * imbalancedHE.CpIn * (tempInHot - result.tempOutHot); + const QCold = mFlowRateCold * imbalancedHE.CpOut * (result.tempOutCold - tempInCold); + this.assertApproxEqual(QHot, QCold, 0.01, "Energy balance should be maintained with imbalanced flow rates"); + this.assertApproxEqual(QHot, result.Q, 0.01, "Calculated Q should match energy extracted from hot stream"); + } + + testZeroFlowRate() { + console.log("\nTesting Zero Flow Rate Handling..."); + + // Create a specific instance for this test + const zeroFlowHE = new HeatExchanger(testConfig); + zeroFlowHE.logger = silentLogger; + + // Test with zero hot flow rate + const resultZeroHot = zeroFlowHE.calcOutletTempsEffectivenessNTU( + 0, // kg/s - zero flow rate + 80, // °C + 1.0, // kg/s + 15 // °C + ); + + this.assertApproxEqual(resultZeroHot.Q, 0, 0.001, "Zero hot flow rate should result in zero heat transfer"); + this.assertApproxEqual(resultZeroHot.tempOutHot, 80, 0.001, "Zero hot flow should result in unchanged hot outlet temperature"); + this.assertApproxEqual(resultZeroHot.tempOutCold, 15, 0.001, "Zero hot flow should result in unchanged cold outlet temperature"); + + // Test with zero cold flow rate + const resultZeroCold = zeroFlowHE.calcOutletTempsEffectivenessNTU( + 1.0, // kg/s + 80, // °C + 0, // kg/s - zero flow rate + 15 // °C + ); + + this.assertApproxEqual(resultZeroCold.Q, 0, 0.001, "Zero cold flow rate should result in zero heat transfer"); + this.assertApproxEqual(resultZeroCold.tempOutHot, 80, 0.001, "Zero cold flow should result in unchanged hot outlet temperature"); + this.assertApproxEqual(resultZeroCold.tempOutCold, 15, 0.001, "Zero cold flow should result in unchanged cold outlet temperature"); + } + + testSetter_HotFlowRate() { + console.log("\nTesting Hot Flow Rate Setter..."); + + const setterHE = new HeatExchanger(testConfig); + setterHE.logger = silentLogger; + + // Set temperatures first to have a baseline + setterHE.setHotInput(80); + setterHE.setColdInput(20); + setterHE.setColdFlowRate(2); + + // Initial calculation happens + const originalOutletHot = setterHE.tempOutHot; + + // Now change hot flow rate + const m3h = 5; + const expectedKgs = 5 * 0.000277778 * 1000; // convert to kg/s + + const result = setterHE.setHotFlowRate(m3h); + + this.assertApproxEqual(result, expectedKgs, 0.001, "setHotFlowRate should return converted flow rate"); + this.assertApproxEqual(setterHE.mFlowRateHot, expectedKgs, 0.001, "mFlowRateHot should be updated correctly"); + + // Outlet temperature should change with different flow rate + this.assert(setterHE.tempOutHot !== originalOutletHot, "Hot outlet temperature should change after flow rate update"); + } + + testSetter_HotInput() { + console.log("\nTesting Hot Input Temperature Setter..."); + + const setterHE = new HeatExchanger(testConfig); + setterHE.logger = silentLogger; + + // Set other parameters first + setterHE.setColdInput(20); + setterHE.setHotFlowRate(3); + setterHE.setColdFlowRate(3); + + const originalOutletCold = setterHE.tempOutCold; + + // Now set hot input temperature + const newTemp = 90; + const result = setterHE.setHotInput(newTemp); + + this.assertApproxEqual(result, newTemp, 0.001, "setHotInput should return the set temperature"); + this.assertApproxEqual(setterHE.tempInHot, newTemp, 0.001, "tempInHot should be updated correctly"); + + // Cold outlet temperature should change with different hot input + this.assert(setterHE.tempOutCold !== originalOutletCold, + "Cold outlet temperature should change after hot input temperature update"); + } + + testPhysicalLimits() { + console.log("\nTesting Physical Limits and Constraints..."); + + // Test that extreme temperature differences don't break the model + const extremeHE = new HeatExchanger(testConfig); + extremeHE.logger = silentLogger; + + // Extreme temperature difference + const result = extremeHE.calcOutletTempsEffectivenessNTU( + 1.0, // kg/s + 1000, // °C - extremely high temperature + 1.0, // kg/s + 0, // °C - very low temperature + ); + + // Check energy balance is still maintained + const QHot = 1.0 * extremeHE.CpIn * (1000 - result.tempOutHot); + const QCold = 1.0 * extremeHE.CpOut * (result.tempOutCold - 0); + this.assertApproxEqual(QHot, QCold, 0.01, "Energy balance should be maintained even with extreme temperatures"); + + // Make sure temps are capped at maxPower + this.assert(result.Q <= extremeHE.maxPower, "Heat transfer rate should not exceed maxPower"); + } + + testOutputGeneration() { + console.log("\nTesting Output Generation..."); + + const outputHE = new HeatExchanger(testConfig); + outputHE.logger = silentLogger; + + // Set some values + outputHE.setHotInput(80); + outputHE.setColdInput(20); + outputHE.setHotFlowRate(3); + outputHE.setColdFlowRate(2); + + // Get output object + const output = outputHE.getOutput(); + + // Check that the output contains the right keys + this.assert(output.hasOwnProperty('flowRateHot'), "Output should contain flowRateHot"); + this.assert(output.hasOwnProperty('flowRateCold'), "Output should contain flowRateCold"); + this.assert(output.hasOwnProperty('tempOutHot'), "Output should contain tempOutHot"); + this.assert(output.hasOwnProperty('tempOutCold'), "Output should contain tempOutCold"); + this.assert(output.hasOwnProperty('Q'), "Output should contain Q"); + + // Check values match the internal state + this.assertApproxEqual(output.flowRateHot, outputHE.mFlowRateHot, 0.001, "Output flowRateHot should match internal state"); + this.assertApproxEqual(output.tempOutHot, outputHE.tempOutHot, 0.001, "Output tempOutHot should match internal state"); + this.assertApproxEqual(output.Q, outputHE.Q_act, 0.001, "Output Q should match internal state"); + } + + async runAllTests() { + console.log("\nStarting Heat Exchanger Tests...\n"); + + // Basic functionality tests + this.testM3hToKgsConversion(); + this.testUACalculation(); + this.testHeatTransferRate(); + this.testDeltaTCalculation(); + + // Heat exchanger physics tests + this.testCalcOutletTempsEffectivenessNTU_Counterflow(); + this.testCalcOutletTempsEffectivenessNTU_Parallel(); + this.testMaxPowerLimit(); + this.testImbalancedFlowRates(); + this.testZeroFlowRate(); + + // API tests + this.testSetter_HotFlowRate(); + this.testSetter_HotInput(); + this.testPhysicalLimits(); + this.testOutputGeneration(); + + console.log("\nTest Summary:"); + console.log(`Total Tests: ${this.totalTests}`); + console.log(`Passed: ${this.passedTests}`); + console.log(`Failed: ${this.failedTests}`); + console.log(`Success Rate: ${((this.passedTests / this.totalTests) * 100).toFixed(1)}%`); + + return this.failedTests === 0; + } +} + +// Run all tests +const tester = new HeatExchangerTester(); +tester.runAllTests() + .then(success => { + process.exit(success ? 0 : 1); + }) + .catch(error => { + console.error("Error running tests:", error); + process.exit(1); + }); diff --git a/dependencies/heatExchanger/heatExchangerConfig.json b/dependencies/heatExchanger/heatExchangerConfig.json new file mode 100644 index 0000000..5340854 --- /dev/null +++ b/dependencies/heatExchanger/heatExchangerConfig.json @@ -0,0 +1,258 @@ +{ + "general": { + "name": { + "default": "heatExchanger Configuration", + "rules": { + "type": "string", + "description": "A human-readable name or label for this heatExchanger configuration." + } + }, + "id": { + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "A unique identifier for this configuration. If not provided, defaults to null." + } + }, + "unit": { + "default": "unitless", + "rules": { + "type": "string", + "description": "The unit of heatExchanger for this configuration (e.g., 'meters', 'seconds', 'unitless')." + } + }, + "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": "heatExchanger", + "rules": { + "type": "string", + "description": "Specified software type for this configuration." + } + }, + "role": { + "default": "Sensor", + "rules": { + "type": "string", + "description": "Indicates the role this configuration plays (e.g., sensor, controller, etc.)." + } + } + }, + "asset": { + "uuid": { + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "Asset tag number which is a universally unique identifier for this asset. May be null if not assigned." + } + }, + "tagCode":{ + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "Asset tag code which is a unique identifier for this asset. May be null if not assigned." + } + }, + "geoLocation": { + "default": { + "x": 0, + "y": 0, + "z": 0 + }, + "rules": { + "type": "object", + "description": "An object representing the asset's physical coordinates or location.", + "schema": { + "x": { + "default": 0, + "rules": { + "type": "number", + "description": "X coordinate of the asset's location." + } + }, + "y": { + "default": 0, + "rules": { + "type": "number", + "description": "Y coordinate of the asset's location." + } + }, + "z": { + "default": 0, + "rules": { + "type": "number", + "description": "Z coordinate of the asset's location." + } + } + } + } + }, + "supplier": { + "default": "Unknown", + "rules": { + "type": "string", + "description": "The supplier or manufacturer of the asset." + } + }, + "type": { + "default": "heatExchanger", + "rules": { + "type": "enum", + "values": [ + { + "value": "heatExchanger", + "description": "A device used to transfer heat between two or more fluids." + } + ] + } + }, + "subType": { + "default": "plate", + "rules": { + "type": "string", + "description": "A user-defined or manufacturer-defined subtype for the asset." + } + }, + "model": { + "default": "Unknown", + "rules": { + "type": "string", + "description": "A user-defined or manufacturer-defined model identifier for the asset." + } + }, + "U": { + "default": 3000, + "rules": { + "type": "number", + "description": "The overall heat transfer coefficient for the heat exchanger in W/m2K." + } + }, + "A": { + "default": 0.24, + "rules": { + "type": "number", + "description": "The heat transfer area for the heat exchanger in m2." + } + }, + "maxPower": { + "default": 44, + "rules": { + "type": "number", + "description": "The maximum power that the heat exchanger can handle in kW." + } + }, + "maxFlowRate": { + "default": 4, + "rules": { + "type": "number", + "description": "The maximum flow rate that the heat exchanger can handle in m3/h." + } + } + }, + "medium": { + "input":{ + "default": "water", + "rules": { + "type": "string", + "description": "The fluid that is allowed to flow through the heat exchanger." + } + }, + "output":{ + "default": "water", + "rules": { + "type": "string", + "description": "The fluid that is allowed to flow through the heat exchanger." + } + }, + "allowed": { + "default": [ + "water", + "glycol", + "oil", + "steam" + ], + "rules": { + "type": "set", + "description": "An array of allowed mediums for the heat exchanger." + } + } + }, + "flowConfiguration": { + "default": "counterflow", + "rules": { + "type": "enum", + "values":[ + { + "value": "counterflow", + "description": "The direction of the flow of the two fluids is opposite to each other." + }, + { + "value": "parallelflow", + "description": "The direction of the flow of the two fluids is the same." + }, + { + "value": "crossflow", + "description": "The direction of the flow of the two fluids is perpendicular to each other." + } + ], + "description": "The direction of the flow of the two fluids." + } + }, + "type": { + "default": "plate", + "rules": { + "type": "enum", + "values":[ + { + "value": "shellAndTube", + "description": "A type of heat exchanger that consists of a shell (a large pressure vessel) with a bundle of tubes inside it." + }, + { + "value": "plate", + "description": "A type of heat exchanger that uses metal plates to transfer heat between two fluids." + }, + { + "value": "regenerative", + "description": "A type of heat exchanger that uses a rotating wheel to transfer heat between two fluids." + } + ], + "description": "The type of heat exchanger." + } + } +} \ No newline at end of file diff --git a/heatExchanger.html b/heatExchanger.html new file mode 100644 index 0000000..7de1e13 --- /dev/null +++ b/heatExchanger.html @@ -0,0 +1,328 @@ + + + + + + + + diff --git a/heatExchanger.js b/heatExchanger.js new file mode 100644 index 0000000..20675a2 --- /dev/null +++ b/heatExchanger.js @@ -0,0 +1,147 @@ +module.exports = function (RED) { + function heatExchanger(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; + + try{ + + //fetch heatExchanger object from source.js + const heatExchanger = require("./dependencies/heatExchanger/heatExchanger"); + const OutputUtils = require("../generalFunctions/helper/outputUtils"); + + //load user defined config in the node-red UI + const heConfig={ + general: { + name: config.name, + id: node.id, + unit: config.unit, + logging:{ + logLevel: config.logLevel, + enabled: config.enableLog, + }, + }, + asset: { + tagCode: config.assetTagCode, + supplier: config.supplier, + subType: config.subType, + model: config.model, + } + } + + //make new heatExchanger on creation to work with. + const he = new heatExchanger(heConfig); + + // put m on node memory as source + node.source = he; + + //load output utils + const output = new OutputUtils(); + + function updateNodeStatus(val) { + //display status + node.status({ fill: "green", shape: "dot", text: val + " " + heConfig.general.unit }); + } + + function tick() { + try { + //const status = updateNodeStatus(); + //node.status(status); + + //get output + const classOutput = he.getOutput(); + const dbOutput = output.formatMsg(classOutput, he.config, "influxdb"); + const pOutput = output.formatMsg(classOutput, he.config, "process"); + + //only send output on values that changed + let msgs = []; + msgs[0] = pOutput; + msgs[1] = dbOutput; + + node.send(msgs); + + } catch (error) { + node.error("Error in tick function: " + error); + node.status({ fill: "red", shape: "ring", text: "Tick Error" }); + } + } + + // 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.intervalId = setInterval(function(){ tick() },1000) + }, + 1000 + ); + + //-------------------------------------------------------------------->>what to do on input + node.on("input", function (msg,send,done) { + + // augment this later with child connections + if(msg.topic == "TempHotInput") + { + if(msg.payload.mAbs){ + he.setHotInput(msg.payload.mAbs); + console.log("HotInput: " + msg.payload); + } + } + + if(msg.topic == "TempColdInput") + { + if(msg.payload.mAbs){ + he.setColdInput(msg.payload.mAbs); + console.log("ColdInput: " + msg.payload); + } + + } + + if(msg.topic == "FlowRateHot"){ + if(msg.payload.downstream_predicted_flow){ + he.setHotFlowRate(msg.payload.downstream_predicted_flow); + console.log("FlowRateHot: " + msg.payload.downstream_predicted_flow); + } + + } + + if(msg.topic == "FlowRateCold"){ + if(msg.payload.downstream_predicted_flow){ + he.setColdFlowRate(msg.payload.downstream_predicted_flow); + console.log("FlowRateCold: " + msg.payload.downstream_predicted_flow); + } + } + + done(); + + }); + + // tidy up any async code here - shutdown connections and so on. + node.on('close', function(done) { + if (node.intervalId) clearTimeout(node.intervalId); + if (node.tickInterval) clearInterval(node.tickInterval); + if (done) done(); + }); + + }catch(e){ + console.log(e); + } + } + RED.nodes.registerType("heatExchanger", heatExchanger); +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..2ba9646 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "heatExchanger", + "version": "0.9.0", + "description": "Control module heatExchanger", + "main": "heatExchanger.js", + "scripts": { + "test": "heatExchanger.js" + }, + "repository": { + "type": "git", + "url": "https://gitea.centraal.wbd-rd.nl/RnD/heatExchanger.git" + }, + "keywords": [ + "heat exchanger", + "node-red" + ], + "author": "Rene De Ren", + "license": "SEE LICENSE", + "dependencies": { + "generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git", + "convert": "git+https://gitea.centraal.wbd-rd.nl/RnD/convert.git", + "predict": "git+https://gitea.centraal.wbd-rd.nl/RnD/predict.git" + }, + "node-red": { + "nodes": { + "heatExchanger": "heatExchanger.js" + } + } +} \ No newline at end of file