Files
Entropyk/crates/cli/tests/hx_standalone.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
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>
2026-07-17 22:46:46 +02:00

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