This commit is contained in:
znetsixe
2025-05-26 17:24:42 +02:00
parent 88945add81
commit 09f1c37125
6 changed files with 1551 additions and 0 deletions

View File

@@ -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`);
// */

View File

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

View File

@@ -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."
}
}
}

328
heatExchanger.html Normal file
View File

@@ -0,0 +1,328 @@
<script type="module">
import * as menuUtils from "/generalFunctions/helper/menuUtils.js";
RED.nodes.registerType("heatExchanger", {
category: "digital twin",
color: "#e4a363",
defaults: {
// Define default properties
name: { value: "", required: true },
enableLog: { value: false },
logLevel: { value: "error" },
// Define specific properties
scaling: { value: false },
i_min: { value: 0, required: true },
i_max: { value: 0, required: true },
i_offset: { value: 0 },
o_min: { value: 0, required: true },
o_max: { value: 1, required: true },
simulator: { value: false },
unit: { value: "unit", required: true },
smooth_method: { value: "" },
count: { value: "10", required: true },
//define asset properties
supplier: { value: "" },
subType: { value: "" },
model: { value: "" },
unit: { value: "" },
},
inputs: 1,
outputs: 4,
inputLabels: ["heatExchanger Input"],
outputLabels: ["process", "dbase", "upstreamParent", "downstreamParent"],
icon: "font-awesome/fa-tachometer",
label: function () {
return this.name || "heatExchanger";
},
oneditprepare: function() {
const node = this;
// Define UI html elements
const elements = {
// Basic fields
name: document.getElementById("node-input-name"),
// specific fields
scalingCheckbox: document.getElementById("node-input-scaling"),
rowInputMin: document.getElementById("row-input-i_min"),
rowInputMax: document.getElementById("row-input-i_max"),
smoothMethod: document.getElementById("node-input-smooth_method"),
count: document.getElementById("node-input-count"),
iOffset: document.getElementById("node-input-i_offset"),
oMin: document.getElementById("node-input-o_min"),
oMax: document.getElementById("node-input-o_max"),
// Logging fields
logCheckbox: document.getElementById("node-input-enableLog"),
logLevelSelect: document.getElementById("node-input-logLevel"),
rowLogLevel: document.getElementById("row-logLevel"),
// Asset fields
supplier: document.getElementById("node-input-supplier"),
subType: document.getElementById("node-input-subType"),
model: document.getElementById("node-input-model"),
unit: document.getElementById("node-input-unit"),
};
//this needs to live somewhere and for now we add it to every node file for simplicity
const projecSettingstURL = "http://localhost:1880/generalFunctions/settings/projectSettings.json";
try{
// Fetch project settings
menuUtils.fetchProjectData(projecSettingstURL)
.then((projectSettings) => {
//assign to node vars
node.configUrls = projectSettings.configUrls;
const { cloudConfigURL, localConfigURL } = menuUtils.getSpecificConfigUrl("heatExchanger",node.configUrls.cloud.taggcodeAPI);
node.configUrls.cloud.config = cloudConfigURL; // first call
node.configUrls.local.config = localConfigURL; // backup call
node.locationId = projectSettings.locationId;
node.uuid = projectSettings.uuid;
// Gets the ID of the active workspace (Flow)
const activeFlowId = RED.workspaces.active(); //fetches active flow id
node.processId = 1;//activeFlowId;
// UI elements specific for node
menuUtils.initMeasurementToggles(elements);
menuUtils.populateSmoothingMethods(node.configUrls, elements, node);
// UI elements across all nodes
menuUtils.fetchAndPopulateDropdowns(node.configUrls, elements, node); // function for all assets
menuUtils.initBasicToggles(elements);
})
}catch(e){
console.log("Error fetching project settings", e);
}
if(node.d){
//this means node is disabled
console.log("Current status of node is disabled");
}
},
oneditsave: function () {
const node = this;
console.log(`------------ Saving changes to node ------------`);
console.log(`${node.uuid}`);
// Save basic properties
["name", "supplier", "subType", "model", "unit", "smooth_method"].forEach(
(field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
);
// Save numeric and boolean properties
["scaling", "enableLog", "simulator"].forEach(
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
);
["i_min", "i_max", "i_offset", "o_min", "o_max", "count"].forEach(
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
);
node.logLevel = document.getElementById("node-input-logLevel").value || "info";
// Validation checks
if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
RED.notify("Scaling enabled, but input range is incomplete!", "error");
}
if (!node.unit) {
RED.notify("Unit selection is required.", "error");
}
if (node.subType && !node.unit) {
RED.notify("Unit must be set when specifying a subtype.", "error");
}
console.log("stored node modelData", node.modelMetadata);
console.log("------------ Changes saved to heatExchanger node preparing to save to API ------------");
try{
// Fetch project settings
menuUtils.apiCall(node,node.configUrls)
.then((response) => {
//save response to node information
node.assetTagCode = response.asset_tag_number;
})
.catch((error) => {
console.log("Error during API call", error);
});
}catch(e){
console.log("Error saving assetID and tagnumber", e);
}
},
});
</script>
<!-- Main UI -->
<script type="text/html" data-template-name="heatExchanger">
<!-- Node Name -->
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input
type="text"
id="node-input-name"
placeholder="heatExchanger Name"
style="width:70%;"
/>
</div>
<!-- Scaling Checkbox -->
<div class="form-row">
<label for="node-input-scaling"
><i class="fa fa-compress"></i> Scaling</label
>
<input
type="checkbox"
id="node-input-scaling"
style="width:20px; vertical-align:baseline;"
/>
<span>Enable input scaling?</span>
</div>
<!-- Source Min/Max (only if scaling is true) -->
<div class="form-row" id="row-input-i_min">
<label for="node-input-i_min"
><i class="fa fa-arrow-down"></i> Source Min</label>
<input type="number" id="node-input-i_min" placeholder="0" />
</div>
<div class="form-row" id="row-input-i_max">
<label for="node-input-i_max"
><i class="fa fa-arrow-up"></i> Source Max</label>
<input type="number" id="node-input-i_max" placeholder="3000" />
</div>
<!-- Offset -->
<div class="form-row">
<label for="node-input-i_offset"
><i class="fa fa-adjust"></i> Input Offset</label>
<input type="number" id="node-input-i_offset" placeholder="0" />
</div>
<!-- Output / Process Min/Max -->
<div class="form-row">
<label for="node-input-o_min"><i class="fa fa-tag"></i> Process Min</label>
<input type="number" id="node-input-o_min" placeholder="0" />
</div>
<div class="form-row">
<label for="node-input-o_max"><i class="fa fa-tag"></i> Process Max</label>
<input type="number" id="node-input-o_max" placeholder="1" />
</div>
<!-- Simulator Checkbox -->
<div class="form-row">
<label for="node-input-simulator"
><i class="fa fa-cog"></i> Simulator</label>
<input
type="checkbox"
id="node-input-simulator"
style="width:20px; vertical-align:baseline;"
/>
<span>Activate internal simulation?</span>
</div>
<!-- Smoothing Method -->
<div class="form-row">
<label for="node-input-smooth_method"
><i class="fa fa-line-chart"></i> Smoothing</label>
<select id="node-input-smooth_method" style="width:60%;">
<!-- Filled dynamically from heatExchangerConfig.json or fallback -->
</select>
</div>
<!-- Smoothing Window -->
<div class="form-row">
<label for="node-input-count">Window</label>
<input
type="number"
id="node-input-count"
placeholder="10"
style="width:60px;"
/>
<div class="form-tips">Number of samples for smoothing</div>
</div>
<!-- Optional Extended Fields: supplier, type, subType, model -->
<hr />
<div class="form-row">
<label for="node-input-supplier"
><i class="fa fa-industry"></i> Supplier</label>
<select id="node-input-supplier" style="width:60%;">
<option value="">(optional)</option>
</select>
</div>
<div class="form-row">
<label for="node-input-subType"
><i class="fa fa-puzzle-piece"></i> SubType</label>
<select id="node-input-subType" style="width:60%;">
<option value="">(optional)</option>
</select>
</div>
<div class="form-row">
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
<select id="node-input-model" style="width:60%;">
<option value="">(optional)</option>
</select>
</div>
<div class="form-row">
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
<select id="node-input-unit" style="width:60%;"></select>
</div>
<hr />
<!-- loglevel checkbox -->
<div class="form-row">
<label for="node-input-enableLog"
><i class="fa fa-cog"></i> Enable Log</label>
<input
type="checkbox"
id="node-input-enableLog"
style="width:20px; vertical-align:baseline;"
/>
<span>Enable logging</span>
</div>
<div class="form-row" id="row-logLevel">
<label for="node-input-logLevel"><i class="fa fa-cog"></i> Log Level</label>
<select id="node-input-logLevel" style="width:60%;">
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
</script>
<script type="text/html" data-help-name="heatExchanger">
<p><b>heatExchanger Node</b>: Scales, smooths, and simulates heatExchanger data.</p>
<p>Use this node to scale, smooth, and simulate heatExchanger data. The node can be configured to scale input data to a specified range, smooth the data using a variety of methods, and simulate data for testing purposes.</p>
</script>

147
heatExchanger.js Normal file
View File

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

29
package.json Normal file
View File

@@ -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"
}
}
}