Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
398 lines
16 KiB
Rust
398 lines
16 KiB
Rust
//! 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");
|
|
}
|