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>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -88,6 +88,9 @@ fn test_simulation_result_statuses() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -98,6 +101,9 @@ fn test_simulation_result_statuses() {
state: None,
performance: None,
error: Some("Error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
SimulationResult {
@@ -108,6 +114,9 @@ fn test_simulation_result_statuses() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 1000,
},
];
@@ -137,26 +146,38 @@ fn test_batch_aggregator_csv_output() {
input: "scenario1.json".to_string(),
status: SimulationStatus::Converged,
convergence: Some(entropyk_cli::run::ConvergenceInfo {
final_residual: 1e-8,
tolerance: 1e-6,
}),
final_residual: 1e-8,
tolerance: 1e-6,
iterations: None,
strategy: None,
iteration_history: vec![],
}),
iterations: Some(25),
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 150,
},
SimulationResult {
input: "scenario2.json".to_string(),
status: SimulationStatus::Converged,
convergence: Some(entropyk_cli::run::ConvergenceInfo {
final_residual: 5e-7,
tolerance: 1e-6,
}),
final_residual: 5e-7,
tolerance: 1e-6,
iterations: None,
strategy: None,
iteration_history: vec![],
}),
iterations: Some(30),
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 200,
},
SimulationResult {
@@ -167,6 +188,9 @@ fn test_batch_aggregator_csv_output() {
state: None,
performance: None,
error: Some("Solver failed".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];
@@ -195,6 +219,9 @@ fn test_batch_aggregator_json_summary() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -205,6 +232,9 @@ fn test_batch_aggregator_json_summary() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 75,
},
SimulationResult {
@@ -215,6 +245,9 @@ fn test_batch_aggregator_json_summary() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 5000,
},
];
@@ -268,11 +301,17 @@ fn test_batch_summary_csv_with_convergence() {
convergence: Some(entropyk_cli::run::ConvergenceInfo {
final_residual: 1e-9,
tolerance: 1e-6,
iterations: None,
strategy: None,
iteration_history: vec![],
}),
iterations: Some(42),
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 300,
}];

View File

@@ -11,7 +11,67 @@ fn test_parse_minimal_config() {
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.fluid, "R134a");
assert!(config.circuits.is_empty());
assert_eq!(config.solver.strategy, "fallback");
assert_eq!(config.solver.strategy, "newton");
assert!(config.controls.is_empty());
}
#[test]
fn test_parse_saturated_control_loop() {
let json = r#"
{
"fluid": "R134a",
"controls": [
{
"type": "SaturatedController",
"id": "lwt_capacity",
"measure": { "component": "evap_0", "output": "capacity" },
"actuator": {
"component": "screw_0", "factor": "f_m",
"initial": 1.0, "min": 0.5, "max": 1.5
},
"target": 350000.0,
"gain": 0.01,
"band": 1.0,
"smooth_eps": 0.001
}
]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.controls.len(), 1);
let c = &config.controls[0];
assert_eq!(c.control_type, "SaturatedController");
assert_eq!(c.id, "lwt_capacity");
assert_eq!(c.measure.component, "evap_0");
assert_eq!(c.measure.output, "capacity");
assert_eq!(c.actuator.factor, "f_m");
assert_eq!(c.actuator.min, 0.5);
assert_eq!(c.actuator.max, 1.5);
assert_eq!(c.target, 350000.0);
assert_eq!(c.gain, 0.01);
assert_eq!(c.smooth_eps, Some(0.001));
}
#[test]
fn test_control_defaults_apply() {
let json = r#"
{
"fluid": "R134a",
"controls": [
{
"id": "cap",
"measure": { "component": "evap", "output": "capacity" },
"actuator": { "component": "comp", "factor": "f_m", "min": 0.5, "max": 1.5 },
"target": 1000.0
}
]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
let c = &config.controls[0];
assert_eq!(c.control_type, "SaturatedController");
assert_eq!(c.gain, 1.0);
assert_eq!(c.band, 1.0);
assert_eq!(c.actuator.initial, 1.0);
assert_eq!(c.smooth_eps, None);
}
#[test]
@@ -110,7 +170,7 @@ fn test_load_config_file_not_found() {
#[test]
fn test_solver_config_defaults() {
let config = SolverConfig::default();
assert_eq!(config.strategy, "fallback");
assert_eq!(config.strategy, "newton");
assert_eq!(config.max_iterations, 100);
assert_eq!(config.tolerance, 1e-6);
assert_eq!(config.timeout_ms, 0);

View File

@@ -0,0 +1,397 @@
//! 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");
}

File diff suppressed because it is too large Load Diff