//! Integration tests for structured simulation result extraction. use entropyk::{ extract_simulation_result, SimulationOutcome, SimulationResult, SystemBuilder, }; use entropyk_components::expansion_valve::ExpansionValve; use entropyk_components::heat_exchanger::{Condenser, Evaporator}; use entropyk_components::port::{Disconnected, FluidId, Port}; use entropyk_components::{ Component, ComponentError, ConnectedPort, JacobianBuilder, MchxCondenserCoil, Polynomial2D, ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, StateSlice, }; use entropyk_core::{Enthalpy, MassFlow, Power, Pressure}; use entropyk_solver::{ConvergedState, ConvergenceStatus, SimulationMetadata}; use approx::assert_relative_eq; // ───────────────────────────────────────────────────────────────────────────── // Helpers for real components // ───────────────────────────────────────────────────────────────────────────── fn make_disconnected_port(fluid: &str, p_bar: f64, h_kj_kg: f64) -> Port { Port::new( FluidId::new(fluid), Pressure::from_bar(p_bar), Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0), ) } fn make_connected_port(fluid: &str, p_bar: f64, h_kj_kg: f64) -> ConnectedPort { let a = make_disconnected_port(fluid, p_bar, h_kj_kg); let b = make_disconnected_port(fluid, p_bar, h_kj_kg); a.connect(b).expect("port connection ok").0 } fn make_screw_curves() -> ScrewPerformanceCurves { ScrewPerformanceCurves::with_fixed_eco_fraction( Polynomial2D::bilinear(1.20, 0.003, -0.002, 0.000_01), Polynomial2D::bilinear(55_000.0, 200.0, -300.0, 0.5), 0.12, ) } /// Build a real R134a cycle with ScrewEconomizerCompressor + MchxCondenserCoil /// + ExpansionValve + Evaporator. Uses manually crafted ConvergedState from /// NIST R134a reference data (T_evap=0°C, T_cond=40°C, SH=5K, SC=3K). fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) { use entropyk_solver::CircuitId; let mut sys = entropyk_solver::System::new(); // --- Compressor (screw with economizer) --- let suc = make_connected_port("R134a", 2.93, 405.0); let dis = make_connected_port("R134a", 10.17, 440.0); let eco = make_connected_port("R134a", 5.5, 250.0); let comp = ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco) .expect("compressor"); // --- Condenser (air-cooled coil at 35°C ambient) --- let condenser = MchxCondenserCoil::for_35c_ambient(15_000.0, 0); // --- Expansion valve (fully open) --- let exv_in = make_disconnected_port("R134a", 10.17, 253.4); let exv_out = make_disconnected_port("R134a", 2.93, 253.4); let exv_disconnected = ExpansionValve::new(exv_in, exv_out, Some(1.0)).expect("exv disconnected"); let exv = exv_disconnected .connect(make_disconnected_port("R134a", 10.17, 253.4), make_disconnected_port("R134a", 2.93, 253.4)) .expect("exv connect"); // --- Evaporator (BPHE, T_sat=278.15K, SH=5K) --- let evaporator = Evaporator::with_superheat(8000.0, 278.15, 5.0); // Add to circuit 0 let n_comp = sys.add_component_to_circuit(Box::new(comp), CircuitId::ZERO).unwrap(); let n_cond = sys.add_component_to_circuit(Box::new(condenser), CircuitId::ZERO).unwrap(); let n_exv = sys.add_component_to_circuit(Box::new(exv), CircuitId::ZERO).unwrap(); let n_evap = sys.add_component_to_circuit(Box::new(evaporator), CircuitId::ZERO).unwrap(); // Register names for extract_simulation_result sys.register_component_name("compressor", n_comp); sys.register_component_name("condenser", n_cond); sys.register_component_name("expansion_valve", n_exv); sys.register_component_name("evaporator", n_evap); // Connect: comp → cond → exv → evap → comp sys.add_edge(n_comp, n_cond).unwrap(); sys.add_edge(n_cond, n_exv).unwrap(); sys.add_edge(n_exv, n_evap).unwrap(); sys.add_edge(n_evap, n_comp).unwrap(); sys.finalize().expect("system finalize"); // ConvergedState from NIST R134a reference data: // T_evap_sat = 0°C → P_sat ≈ 292800 Pa (2.928 bar) // T_cond_sat = 40°C → P_sat ≈ 1017000 Pa (10.17 bar) // h_g(0°C) ≈ 398600 J/kg, h_f(40°C) ≈ 256400 J/kg // With SH=5K and SC=3K let state = vec![ 1017000.0, 440000.0, // edge 0: comp→cond (discharge, superheated ~440 kJ/kg) 1000000.0, 250000.0, // edge 1: cond→exv (subcooled liquid ~250 kJ/kg, ~3K SC) 292800.0, 250000.0, // edge 2: exv→evap (isenthalpic expansion, same h) 285000.0, 405000.0, // edge 3: evap→comp (superheated ~5K above sat) ]; let converged = ConvergedState::new( state, 23, 5.1e-8, ConvergenceStatus::Converged, SimulationMetadata::new("r134a_chiller_nist_ref".to_string()), ); (sys, converged) } // ───────────────────────────────────────────────────────────────────────────── // Mock components for testing // ───────────────────────────────────────────────────────────────────────────── /// Mock component that reports energy transfers (simulates a compressor). struct MockCompressor; impl Component for MockCompressor { fn compute_residuals( &self, _state: &[f64], _residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { Ok(()) } fn jacobian_entries( &self, _state: &[f64], _jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn energy_transfers(&self, _state: &[f64]) -> Option<(Power, Power)> { // Compressor: no heat exchange, consumes 3000W work Some((Power::from_watts(0.0), Power::from_watts(3000.0))) } fn signature(&self) -> String { "Compressor(eff=0.7)".to_string() } } /// Mock component that absorbs heat (simulates an evaporator). struct MockEvaporator; impl Component for MockEvaporator { fn compute_residuals( &self, _state: &[f64], _residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { Ok(()) } fn jacobian_entries( &self, _state: &[f64], _jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn energy_transfers(&self, _state: &[f64]) -> Option<(Power, Power)> { // Evaporator: absorbs 10000W of heat (Q > 0 = cooling) Some((Power::from_watts(10000.0), Power::from_watts(0.0))) } fn signature(&self) -> String { "Evaporator(UA=5.0kW/K)".to_string() } } /// Mock component with no energy transfers (simulates a pipe). struct MockPipe; impl Component for MockPipe { fn compute_residuals( &self, _state: &[f64], _residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { Ok(()) } fn jacobian_entries( &self, _state: &[f64], _jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn signature(&self) -> String { "Pipe(L=10m,D=0.02m)".to_string() } } // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── /// Helper: build a realistic 4-component vapor compression cycle with mock components. fn build_realistic_cycle() -> (entropyk_solver::System, ConvergedState) { let system = SystemBuilder::new() .component("compressor", Box::new(MockCompressor)) .expect("add compressor") .component("condenser", Box::new(MockPipe)) // no energy transfer .expect("add condenser") .component("expansion_valve", Box::new(MockPipe)) .expect("add expansion valve") .component("evaporator", Box::new(MockEvaporator)) .expect("add evaporator") .edge("compressor", "condenser") .expect("edge comp->cond") .edge("condenser", "expansion_valve") .expect("edge cond->exv") .edge("expansion_valve", "evaporator") .expect("edge exv->evap") .edge("evaporator", "compressor") .expect("edge evap->comp") .build() .expect("build system"); // R410A-like state vector: 4 edges × 2 (P, h) // Realistic values: high side ~24 bar, low side ~8 bar let state = vec![ 2400000.0, 440000.0, // edge 0: compressor → condenser (discharge, high P, superheated) 2350000.0, 280000.0, // edge 1: condenser → expansion (subcooled liquid) 800000.0, 260000.0, // edge 2: expansion → evaporator (two-phase, low P) 780000.0, 400000.0, // edge 3: evaporator → compressor (superheated vapor, low P) ]; let converged = ConvergedState::new( state, 12, 2.3e-8, ConvergenceStatus::Converged, SimulationMetadata::new("r410a_chiller_35c_ambient".to_string()), ); (system, converged) } /// Helper: build a 4-component system and create a fake ConvergedState. fn build_test_system() -> (entropyk_solver::System, ConvergedState) { let system = SystemBuilder::new() .component("comp", Box::new(MockCompressor)) .expect("add comp") .component("pipe1", Box::new(MockPipe)) .expect("add pipe1") .component("evap", Box::new(MockEvaporator)) .expect("add evap") .component("pipe2", Box::new(MockPipe)) .expect("add pipe2") .edge("comp", "pipe1") .expect("edge comp->pipe1") .edge("pipe1", "evap") .expect("edge pipe1->evap") .edge("evap", "pipe2") .expect("edge evap->pipe2") .edge("pipe2", "comp") .expect("edge pipe2->comp") .build() .expect("build system"); // Create a fake converged state with 4 edges = 8 state variables // [P0, h0, P1, h1, P2, h2, P3, h3] let state = vec![ 500000.0, 450000.0, // edge 0: comp -> pipe1 (high pressure) 490000.0, 440000.0, // edge 1: pipe1 -> evap 200000.0, 250000.0, // edge 2: evap -> pipe2 (low pressure) 190000.0, 240000.0, // edge 3: pipe2 -> comp ]; let converged = ConvergedState::new( state, 15, 1e-9, ConvergenceStatus::Converged, SimulationMetadata::new("test_input_hash".to_string()), ); (system, converged) } #[test] fn test_extract_simulation_result_basic() { let (system, converged) = build_test_system(); let result = extract_simulation_result(&system, &converged); // Status and convergence assert_eq!(result.status, SimulationOutcome::Converged); assert!(result.convergence.converged); assert_eq!(result.convergence.iterations, 15); assert_relative_eq!(result.convergence.final_residual, 1e-9); // Components assert_eq!(result.components.len(), 4); // Edges assert_eq!(result.edges.len(), 4); } #[test] fn test_extract_per_component_results() { let (system, converged) = build_test_system(); let result = extract_simulation_result(&system, &converged); let comp = result .components .iter() .find(|c| c.name == "comp") .expect("comp not found"); assert_eq!(comp.component_type, "Compressor(eff=0.7)"); assert_eq!(comp.circuit, 0); assert!(comp.energy.is_some()); let energy = comp.energy.as_ref().unwrap(); assert_relative_eq!(energy.work_w, 3000.0); assert_relative_eq!(energy.heat_transfer_w, 0.0); let evap = result .components .iter() .find(|c| c.name == "evap") .expect("evap not found"); assert_eq!(evap.component_type, "Evaporator(UA=5.0kW/K)"); let evap_energy = evap.energy.as_ref().unwrap(); assert_relative_eq!(evap_energy.heat_transfer_w, 10000.0); assert_relative_eq!(evap_energy.work_w, 0.0); // Pipe has no energy transfers let pipe1 = result .components .iter() .find(|c| c.name == "pipe1") .expect("pipe1 not found"); assert!(pipe1.energy.is_none()); } #[test] fn test_extract_per_edge_results() { let (system, converged) = build_test_system(); let result = extract_simulation_result(&system, &converged); // Edge 0: comp -> pipe1 (high pressure side) let edge0 = result.edges.iter().find(|e| e.edge_id == 0).expect("edge 0"); assert_relative_eq!(edge0.pressure_pa, 500000.0); assert_relative_eq!(edge0.enthalpy_j_kg, 450000.0); assert_eq!(edge0.source.as_deref(), Some("comp")); assert_eq!(edge0.target.as_deref(), Some("pipe1")); // Edge 2: evap -> pipe2 (low pressure side) let edge2 = result.edges.iter().find(|e| e.edge_id == 2).expect("edge 2"); assert_relative_eq!(edge2.pressure_pa, 200000.0); assert_relative_eq!(edge2.enthalpy_j_kg, 250000.0); assert_eq!(edge2.source.as_deref(), Some("evap")); assert_eq!(edge2.target.as_deref(), Some("pipe2")); } #[test] fn test_system_summary() { let (system, converged) = build_test_system(); let result = extract_simulation_result(&system, &converged); // Evaporator absorbs 10000W (cooling), compressor uses 3000W assert!(result.summary.total_cooling_capacity_w.is_some()); assert_relative_eq!( result.summary.total_cooling_capacity_w.unwrap(), 10000.0 ); assert!(result.summary.total_compressor_power_w.is_some()); assert_relative_eq!( result.summary.total_compressor_power_w.unwrap(), 3000.0 ); // COP_cooling = 10000 / 3000 assert!(result.summary.cop_cooling.is_some()); assert_relative_eq!( result.summary.cop_cooling.unwrap(), 10000.0 / 3000.0, epsilon = 1e-10 ); } #[test] fn test_simulation_result_json_roundtrip() { let (system, converged) = build_test_system(); let result = extract_simulation_result(&system, &converged); let json = result.to_json().expect("to_json should succeed"); assert!(json.contains("\"status\": \"converged\"")); assert!(json.contains("\"iterations\": 15")); assert!(json.contains("Compressor")); assert!(json.contains("Evaporator")); // Round-trip (compare structurally; floats via relative_eq) let deserialized: SimulationResult = serde_json::from_str(&json).expect("deserialize should succeed"); assert_eq!(result.status, deserialized.status); assert_eq!(result.convergence.iterations, deserialized.convergence.iterations); assert_relative_eq!( result.convergence.final_residual, deserialized.convergence.final_residual, epsilon = 1e-15 ); assert_eq!(result.convergence.converged, deserialized.convergence.converged); assert_eq!(result.convergence.status, deserialized.convergence.status); assert_eq!(result.components.len(), deserialized.components.len()); assert_eq!(result.edges.len(), deserialized.edges.len()); // Verify key float fields survive round-trip for (a, b) in result.edges.iter().zip(deserialized.edges.iter()) { assert_relative_eq!(a.pressure_pa, b.pressure_pa, epsilon = 1e-5); assert_relative_eq!(a.enthalpy_j_kg, b.enthalpy_j_kg, epsilon = 1e-5); } } #[test] fn test_component_inlet_outlet_from_edges() { let (system, converged) = build_test_system(); let result = extract_simulation_result(&system, &converged); // "comp" has: incoming edge 3 (pipe2->comp), outgoing edge 0 (comp->pipe1) let comp = result .components .iter() .find(|c| c.name == "comp") .expect("comp"); // Inlet: edge 3 -> P=190000, h=240000 assert!(comp.inlet.is_some()); let inlet = comp.inlet.as_ref().unwrap(); assert_relative_eq!(inlet.pressure_pa, 190000.0); assert_relative_eq!(inlet.enthalpy_j_kg, 240000.0); // Outlet: edge 0 -> P=500000, h=450000 assert!(comp.outlet.is_some()); let outlet = comp.outlet.as_ref().unwrap(); assert_relative_eq!(outlet.pressure_pa, 500000.0); assert_relative_eq!(outlet.enthalpy_j_kg, 450000.0); } #[test] fn test_metadata_preserved() { let (system, converged) = build_test_system(); let result = extract_simulation_result(&system, &converged); assert_eq!(result.metadata.input_hash, "test_input_hash"); assert!(!result.metadata.solver_version.is_empty()); } #[test] fn test_realistic_cycle_json_output() { let (system, converged) = build_real_r134a_cycle(); let result = extract_simulation_result(&system, &converged); let json = result.to_json().expect("to_json"); println!("\n{}", json); // Basic structure checks assert_eq!(result.status, SimulationOutcome::Converged); assert!(result.convergence.converged); assert_eq!(result.components.len(), 4); assert_eq!(result.edges.len(), 4); assert!(json.contains("\"compressor\"")); assert!(json.contains("\"evaporator\"")); assert!(json.contains("\"condenser\"")); assert!(json.contains("\"expansion_valve\"")); // Compressor should have real component type (not Mock) let comp = result.components.iter().find(|c| c.name == "compressor").expect("comp"); assert!(comp.component_type.contains("Screw"), "expected ScrewEconomizer, got {}", comp.component_type); // Condenser should be MchxCondenserCoil let cond = result.components.iter().find(|c| c.name == "condenser").expect("cond"); assert!(cond.component_type.contains("Mchx"), "expected MchxCondenserCoil, got {}", cond.component_type); // Expansion valve should have real type let exv = result.components.iter().find(|c| c.name == "expansion_valve").expect("exv"); assert!(exv.component_type.contains("ExpansionValve"), "expected ExpansionValve, got {}", exv.component_type); // Evaporator let evap = result.components.iter().find(|c| c.name == "evaporator").expect("evap"); assert!(evap.component_type.contains("Evaporator"), "expected Evaporator, got {}", evap.component_type); // Check edge pressures are from NIST data let edge0 = result.edges.iter().find(|e| e.edge_id == 0).unwrap(); assert_relative_eq!(edge0.pressure_pa, 1017000.0); assert_eq!(edge0.source.as_deref(), Some("compressor")); assert_eq!(edge0.target.as_deref(), Some("condenser")); let edge2 = result.edges.iter().find(|e| e.edge_id == 2).unwrap(); assert_relative_eq!(edge2.pressure_pa, 292800.0); // P_sat at 0°C (NIST) assert_eq!(edge2.source.as_deref(), Some("expansion_valve")); assert_eq!(edge2.target.as_deref(), Some("evaporator")); println!("\n=== Component types ==="); for c in &result.components { println!(" {} (circuit {}): {}", c.name, c.circuit, c.component_type); } }