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