//! Standalone heat exchanger tests — Modelica-style 4-port pattern. //! //! Each test wires a SINGLE heat exchanger with Source/Sink boundary components //! on BOTH sides. No full chiller cycle, no thermal_couplings, no ThermalLoad. //! //! Pattern (Modelica `BoundaryNode.Source → HX → Sink`): //! Hot side: HotSource(P, T, ṁ) → HX:hot_inlet → HX:hot_outlet → HotSink(P_back) //! Cold side: ColdSource(P, T, ṁ) → HX:cold_inlet → HX:cold_outlet → ColdSink(P_back) //! //! Test matrix: //! 1. Air-Water HX (HeatExchanger, hot=Water, cold=Air) //! 2. Water-Water HX (HeatExchanger, hot=Water, cold=Water) //! 3. Air/Ref Evaporator (HeatExchanger, hot=Air, cold=R134a liquid→vapor) //! 4. Water/Ref Evaporator (HeatExchanger, hot=Water, cold=R134a liquid→vapor) //! 5. Air/Ref Condenser (HeatExchanger, hot=R134a vapor→liquid, cold=Air) //! 6. Water/Ref Condenser (HeatExchanger, hot=R134a vapor→liquid, cold=Water) use entropyk_cli::run::{run_simulation, SimulationStatus}; use tempfile::tempdir; fn run_config(json: &str) -> SimulationResult { let dir = tempdir().unwrap(); let path = dir.path().join("hx_test.json"); std::fs::write(&path, json).unwrap(); run_simulation(&path, None, false).unwrap() } use entropyk_cli::run::SimulationResult; fn assert_converged(result: &SimulationResult, label: &str) { assert!( matches!(result.status, SimulationStatus::Converged), "{label} did not converge: {:?} ({:?})", result.status, result.error ); } // ── 1. Air-Water Heat Exchanger ────────────────────────────────────────────── // Hot water (60°C) heats cold air (20°C). Water cools down, air warms up. #[test] fn test_hx_air_water_4port() { let json = r#" { "fluid": "Water", "fluid_backend": "CoolProp", "circuits": [{ "id": 0, "components": [ { "type": "BrineSource", "name": "hot_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 60.0, "m_flow_kg_s": 0.5 }, { "type": "HeatExchanger", "name": "hx", "ua": 3000.0, "hot_fluid_id": "Water", "cold_fluid_id": "Air", "cold_humidity_ratio": 0.010 }, { "type": "BrineSink", "name": "hot_out", "fluid": "Water", "p_back_bar": 2.0 }, { "type": "AirSource", "name": "cold_in", "p_set_bar": 1.01325, "t_dry_c": 20.0, "rh": 50.0, "m_flow_kg_s": 1.0 }, { "type": "Fan", "name": "supply_fan", "fluid": "Air", "speed_ratio": 1.0, "air_density_kg_per_m3": 1.204, "design_flow_m3_s": 0.83, "curve_p0": 250.0, "curve_p1": 0.0, "curve_p2": -20.0, "eff_e0": 0.65, "eff_e1": 0.0, "eff_e2": 0.0 }, { "type": "AirSink", "name": "cold_out", "p_back_bar": 1.01325 } ], "edges": [ { "from": "hot_in:outlet", "to": "hx:hot_inlet" }, { "from": "hx:hot_outlet", "to": "hot_out:inlet" }, { "from": "cold_in:outlet", "to": "supply_fan:inlet" }, { "from": "supply_fan:outlet", "to": "hx:cold_inlet" }, { "from": "hx:cold_outlet", "to": "cold_out:inlet" } ] }], "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 } } "#; let result = run_config(json); assert_converged(&result, "Air-Water HX"); let state = result.state.expect("state"); assert!(state.len() == 5, "5 edges expected, got {}", state.len()); // Hot water cools down. let h_hot_in = state[0].enthalpy_kj_kg; let h_hot_out = state[1].enthalpy_kj_kg; assert!( h_hot_in > h_hot_out, "hot water must cool down: {h_hot_in} -> {h_hot_out} kJ/kg" ); // Cold air warms up. let h_cold_in = state[3].enthalpy_kj_kg; let h_cold_out = state[4].enthalpy_kj_kg; assert!( h_cold_out > h_cold_in, "cold air must warm up: {h_cold_in} -> {h_cold_out} kJ/kg" ); // Energy conservation: Q_hot = Q_cold (within 2%). // m_hot and m_cold are imposed by the sources. let m_hot = 0.5_f64; // kg/s let m_cold = 1.0_f64; // kg/s let q_hot = m_hot * (h_hot_in - h_hot_out); // kW let q_cold = m_cold * (h_cold_out - h_cold_in); // kW assert!( (q_hot - q_cold).abs() < 0.02 * q_hot.abs().max(0.001), "First Law: Q_hot={q_hot:.4} kW vs Q_cold={q_cold:.4} kW" ); assert!(q_hot > 0.0, "heat must flow from hot to cold: Q={q_hot} kW"); } // ── 2. Water-Water Heat Exchanger ──────────────────────────────────────────── // Hot water (80°C) heats cold water (20°C). #[test] fn test_hx_water_water_4port() { let json = r#" { "fluid": "Water", "fluid_backend": "CoolProp", "circuits": [{ "id": 0, "components": [ { "type": "BrineSource", "name": "hot_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 80.0, "m_flow_kg_s": 0.3 }, { "type": "HeatExchanger", "name": "hx", "ua": 5000.0, "hot_fluid_id": "Water", "cold_fluid_id": "Water" }, { "type": "BrineSink", "name": "hot_out", "fluid": "Water", "p_back_bar": 2.0 }, { "type": "BrineSource", "name": "cold_in", "fluid": "Water", "p_set_bar": 1.5, "t_set_c": 20.0, "m_flow_kg_s": 0.5 }, { "type": "BrineSink", "name": "cold_out", "fluid": "Water", "p_back_bar": 1.5 } ], "edges": [ { "from": "hot_in:outlet", "to": "hx:hot_inlet" }, { "from": "hx:hot_outlet", "to": "hot_out:inlet" }, { "from": "cold_in:outlet", "to": "hx:cold_inlet" }, { "from": "hx:cold_outlet", "to": "cold_out:inlet" } ] }], "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 } } "#; let result = run_config(json); assert_converged(&result, "Water-Water HX"); let state = result.state.expect("state"); assert!(state.len() == 4, "4 edges expected"); let h_hot_in = state[0].enthalpy_kj_kg; let h_hot_out = state[1].enthalpy_kj_kg; assert!( h_hot_in > h_hot_out, "hot water must cool down: {h_hot_in} -> {h_hot_out}" ); let h_cold_in = state[2].enthalpy_kj_kg; let h_cold_out = state[3].enthalpy_kj_kg; assert!( h_cold_out > h_cold_in, "cold water must warm up: {h_cold_in} -> {h_cold_out}" ); let m_hot = 0.3_f64; let m_cold = 0.5_f64; let q_hot = m_hot * (h_hot_in - h_hot_out); let q_cold = m_cold * (h_cold_out - h_cold_in); assert!( (q_hot - q_cold).abs() < 0.02 * q_hot.abs().max(0.001), "First Law: Q_hot={q_hot:.4} vs Q_cold={q_cold:.4}" ); assert!(q_hot > 0.0, "heat must flow from hot to cold"); } // ── 3. Air/Refrigerant Evaporator ──────────────────────────────────────────── // Warm air (25°C) heats cold refrigerant R134a (liquid at 5°C → vapor). // Hot side = Air, Cold side = R134a. #[test] fn test_hx_air_ref_evaporator_4port() { let json = r#" { "fluid": "R134a", "fluid_backend": "CoolProp", "circuits": [{ "id": 0, "components": [ { "type": "AirSource", "name": "hot_in", "p_set_bar": 1.01325, "t_dry_c": 25.0, "rh": 50.0, "m_flow_kg_s": 0.8 }, { "type": "Evaporator", "name": "hx", "ua": 2000.0, "fluid": "R134a", "skip_pressure_eq": true, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010 }, { "type": "AirSink", "name": "hot_out", "p_back_bar": 1.01325 }, { "type": "RefrigerantSource", "name": "cold_in", "fluid": "R134a", "p_set_bar": 3.5, "quality": 0.0, "m_flow_kg_s": 0.05 }, { "type": "RefrigerantSink", "name": "cold_out", "fluid": "R134a", "p_back_bar": 3.5 } ], "edges": [ { "from": "cold_in:outlet", "to": "hx:inlet" }, { "from": "hx:outlet", "to": "cold_out:inlet" }, { "from": "hot_in:outlet", "to": "hx:secondary_inlet" }, { "from": "hx:secondary_outlet", "to": "hot_out:inlet" } ] }], "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 } } "#; let result = run_config(json); assert_converged(&result, "Air/Ref Evaporator"); let state = result.state.expect("state"); // Edge order: 0=ref_in, 1=ref_out, 2=air_in, 3=air_out let h_ref_in = state[0].enthalpy_kj_kg; let h_ref_out = state[1].enthalpy_kj_kg; assert!( h_ref_out > h_ref_in, "refrigerant must absorb heat (evaporate): {h_ref_in} -> {h_ref_out}" ); let h_air_in = state[2].enthalpy_kj_kg; let h_air_out = state[3].enthalpy_kj_kg; assert!( h_air_in > h_air_out, "air must cool down: {h_air_in} -> {h_air_out}" ); let m_air = 0.8_f64; let m_ref = 0.05_f64; let q_hot = m_air * (h_air_in - h_air_out); let q_cold = m_ref * (h_ref_out - h_ref_in); assert!( (q_hot - q_cold).abs() < 0.05 * q_hot.abs().max(0.001), "First Law: Q_air={q_hot:.4} vs Q_ref={q_cold:.4}" ); assert!(q_hot > 0.0, "heat must flow from air to refrigerant"); } // ── 4. Water/Refrigerant Evaporator ────────────────────────────────────────── // Chilled water (12°C) heats cold refrigerant R134a (liquid at 5°C → vapor). // Hot side = Water (secondary), Cold side = R134a (refrigerant). #[test] fn test_hx_water_ref_evaporator_4port() { let json = r#" { "fluid": "R134a", "fluid_backend": "CoolProp", "circuits": [{ "id": 0, "components": [ { "type": "BrineSource", "name": "hot_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5 }, { "type": "Evaporator", "name": "hx", "ua": 2000.0, "fluid": "R134a", "skip_pressure_eq": true, "secondary_fluid": "Water" }, { "type": "BrineSink", "name": "hot_out", "fluid": "Water", "p_back_bar": 2.0 }, { "type": "RefrigerantSource", "name": "cold_in", "fluid": "R134a", "p_set_bar": 3.5, "quality": 0.0, "m_flow_kg_s": 0.05 }, { "type": "RefrigerantSink", "name": "cold_out", "fluid": "R134a", "p_back_bar": 3.5 } ], "edges": [ { "from": "cold_in:outlet", "to": "hx:inlet" }, { "from": "hx:outlet", "to": "cold_out:inlet" }, { "from": "hot_in:outlet", "to": "hx:secondary_inlet" }, { "from": "hx:secondary_outlet", "to": "hot_out:inlet" } ] }], "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 } } "#; let result = run_config(json); assert_converged(&result, "Water/Ref Evaporator"); let state = result.state.expect("state"); // Edge order: 0=ref_in, 1=ref_out, 2=water_in, 3=water_out let h_ref_in = state[0].enthalpy_kj_kg; let h_ref_out = state[1].enthalpy_kj_kg; assert!( h_ref_out > h_ref_in, "refrigerant must absorb heat: {h_ref_in} -> {h_ref_out}" ); let h_water_in = state[2].enthalpy_kj_kg; let h_water_out = state[3].enthalpy_kj_kg; assert!( h_water_in > h_water_out, "water must cool down: {h_water_in} -> {h_water_out}" ); let m_water = 0.5_f64; let m_ref = 0.05_f64; let q_hot = m_water * (h_water_in - h_water_out); let q_cold = m_ref * (h_ref_out - h_ref_in); assert!( (q_hot - q_cold).abs() < 0.05 * q_hot.abs().max(0.001), "First Law: Q_water={q_hot:.4} vs Q_ref={q_cold:.4}" ); assert!(q_hot > 0.0, "heat must flow from water to refrigerant"); } // ── 5. Air/Refrigerant Condenser ───────────────────────────────────────────── // Hot refrigerant R134a (vapor at 50°C) heats cold air (35°C). // Hot side = R134a (refrigerant), Cold side = Air (secondary). #[test] fn test_hx_air_ref_condenser_4port() { let json = r#" { "fluid": "R134a", "fluid_backend": "CoolProp", "circuits": [{ "id": 0, "components": [ { "type": "RefrigerantSource", "name": "hot_in", "fluid": "R134a", "p_set_bar": 12.0, "quality": 1.0, "m_flow_kg_s": 0.05 }, { "type": "Condenser", "name": "hx", "ua": 2500.0, "fluid": "R134a", "skip_pressure_eq": true, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010 }, { "type": "RefrigerantSink", "name": "hot_out", "fluid": "R134a", "p_back_bar": 12.0 }, { "type": "AirSource", "name": "cold_in", "p_set_bar": 1.01325, "t_dry_c": 35.0, "rh": 40.0, "m_flow_kg_s": 1.0 }, { "type": "AirSink", "name": "cold_out", "p_back_bar": 1.01325 } ], "edges": [ { "from": "hot_in:outlet", "to": "hx:inlet" }, { "from": "hx:outlet", "to": "hot_out:inlet" }, { "from": "cold_in:outlet", "to": "hx:secondary_inlet" }, { "from": "hx:secondary_outlet", "to": "cold_out:inlet" } ] }], "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 } } "#; let result = run_config(json); assert_converged(&result, "Air/Ref Condenser"); let state = result.state.expect("state"); // Edge order: 0=ref_in, 1=ref_out, 2=air_in, 3=air_out let h_ref_in = state[0].enthalpy_kj_kg; let h_ref_out = state[1].enthalpy_kj_kg; assert!( h_ref_in > h_ref_out, "refrigerant must reject heat: {h_ref_in} -> {h_ref_out}" ); let h_air_in = state[2].enthalpy_kj_kg; let h_air_out = state[3].enthalpy_kj_kg; assert!( h_air_out > h_air_in, "air must warm up: {h_air_in} -> {h_air_out}" ); let m_ref = 0.05_f64; let m_air = 1.0_f64; let q_hot = m_ref * (h_ref_in - h_ref_out); let q_cold = m_air * (h_air_out - h_air_in); assert!( (q_hot - q_cold).abs() < 0.05 * q_hot.abs().max(0.001), "First Law: Q_ref={q_hot:.4} vs Q_air={q_cold:.4}" ); assert!(q_hot > 0.0, "heat must flow from refrigerant to air"); } // ── 6. Water/Refrigerant Condenser ─────────────────────────────────────────── // Hot refrigerant R134a (vapor at 50°C) heats cold water (30°C). // Hot side = R134a (refrigerant), Cold side = Water (secondary). #[test] fn test_hx_water_ref_condenser_4port() { let json = r#" { "fluid": "R134a", "fluid_backend": "CoolProp", "circuits": [{ "id": 0, "components": [ { "type": "RefrigerantSource", "name": "hot_in", "fluid": "R134a", "p_set_bar": 12.0, "quality": 1.0, "m_flow_kg_s": 0.05 }, { "type": "Condenser", "name": "hx", "ua": 2500.0, "fluid": "R134a", "skip_pressure_eq": true, "secondary_fluid": "Water" }, { "type": "RefrigerantSink", "name": "hot_out", "fluid": "R134a", "p_back_bar": 12.0 }, { "type": "BrineSource", "name": "cold_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.4 }, { "type": "BrineSink", "name": "cold_out", "fluid": "Water", "p_back_bar": 2.0 } ], "edges": [ { "from": "hot_in:outlet", "to": "hx:inlet" }, { "from": "hx:outlet", "to": "hot_out:inlet" }, { "from": "cold_in:outlet", "to": "hx:secondary_inlet" }, { "from": "hx:secondary_outlet", "to": "cold_out:inlet" } ] }], "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 } } "#; let result = run_config(json); assert_converged(&result, "Water/Ref Condenser"); let state = result.state.expect("state"); // Edge order: 0=ref_in, 1=ref_out, 2=water_in, 3=water_out let h_ref_in = state[0].enthalpy_kj_kg; let h_ref_out = state[1].enthalpy_kj_kg; assert!( h_ref_in > h_ref_out, "refrigerant must reject heat: {h_ref_in} -> {h_ref_out}" ); let h_water_in = state[2].enthalpy_kj_kg; let h_water_out = state[3].enthalpy_kj_kg; assert!( h_water_out > h_water_in, "water must warm up: {h_water_in} -> {h_water_out}" ); let m_ref = 0.05_f64; let m_water = 0.4_f64; let q_hot = m_ref * (h_ref_in - h_ref_out); let q_cold = m_water * (h_water_out - h_water_in); assert!( (q_hot - q_cold).abs() < 0.05 * q_hot.abs().max(0.001), "First Law: Q_ref={q_hot:.4} vs Q_water={q_cold:.4}" ); assert!(q_hot > 0.0, "heat must flow from refrigerant to water"); }