use entropyk_components::port::{Connected, FluidId, Port}; /// Integration test: calibrated refrigeration cycle vs synthetic test data. /// /// Validates that Calib factors correctly scale component outputs and that /// the solver converges on a calibrated cycle matching expected targets /// within configurable tolerances (capacity ±2%, power ±3%). /// /// The mock components form a self-consistent cycle for any Calib values: /// Compressor : dp = +1 MPa, dh = +75kJ × f_m × f_power /// Condenser : dp = -20kPa×f_dp, dh = -(75kJ×f_m×f_power + 150kJ×f_ua) /// Valve : dp = -(1MPa - 20kPa×f_dp), dh = 0 (isenthalpic) /// Evaporator : dp = 0, dh = +150kJ × f_ua /// /// Energy balance: compressor_work + evaporator_absorption = condenser_rejection ✓ /// Pressure balance: closes for any f_dp ✓ use entropyk_components::{ Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice, }; use entropyk_core::{Calib, MassFlow}; use entropyk_core::{Enthalpy, Pressure}; use entropyk_solver::{ solver::{NewtonConfig, Solver}, system::System, system::DEFAULT_MASS_FLOW_SEED_KG_S, }; type CP = Port; // ─── Calibrated mock components ──────────────────────────────────────────────── struct CalibCompressor { port_suc: CP, port_disc: CP, calib: Calib, } impl Component for CalibCompressor { fn compute_residuals( &self, _s: &StateSlice, r: &mut ResidualVector, ) -> Result<(), ComponentError> { let dh_eff = 75_000.0 * self.calib.z_flow * self.calib.z_power; r[0] = self.port_disc.pressure().to_pascals() - (self.port_suc.pressure().to_pascals() + 1_000_000.0); r[1] = self.port_disc.enthalpy().to_joules_per_kg() - (self.port_suc.enthalpy().to_joules_per_kg() + dh_eff); Ok(()) } fn jacobian_entries( &self, _s: &StateSlice, _j: &mut JacobianBuilder, ) -> Result<(), ComponentError> { Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn port_mass_flows(&self, _: &StateSlice) -> Result, ComponentError> { Ok(vec![ MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05), ]) } } struct CalibCondenser { port_in: CP, port_out: CP, calib: Calib, } impl Component for CalibCondenser { fn compute_residuals( &self, _s: &StateSlice, r: &mut ResidualVector, ) -> Result<(), ComponentError> { let dp_eff = 20_000.0 * self.calib.z_dp; // Condenser rejects compressor work + evaporator load (energy balance) let dh_reject = 75_000.0 * self.calib.z_flow * self.calib.z_power + 150_000.0 * self.calib.z_ua; r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - dp_eff); r[1] = self.port_out.enthalpy().to_joules_per_kg() - (self.port_in.enthalpy().to_joules_per_kg() - dh_reject); Ok(()) } fn jacobian_entries( &self, _s: &StateSlice, _j: &mut JacobianBuilder, ) -> Result<(), ComponentError> { Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn port_mass_flows(&self, _: &StateSlice) -> Result, ComponentError> { Ok(vec![ MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05), ]) } } struct CalibValve { port_in: CP, port_out: CP, calib: Calib, } impl Component for CalibValve { fn compute_residuals( &self, _s: &StateSlice, r: &mut ResidualVector, ) -> Result<(), ComponentError> { let dp_eff = 1_000_000.0 - 20_000.0 * self.calib.z_dp; r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - dp_eff); r[1] = self.port_out.enthalpy().to_joules_per_kg() - self.port_in.enthalpy().to_joules_per_kg(); Ok(()) } fn jacobian_entries( &self, _s: &StateSlice, _j: &mut JacobianBuilder, ) -> Result<(), ComponentError> { Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn port_mass_flows(&self, _: &StateSlice) -> Result, ComponentError> { Ok(vec![ MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05), ]) } } struct CalibEvaporator { port_in: CP, port_out: CP, calib: Calib, } impl Component for CalibEvaporator { fn compute_residuals( &self, _s: &StateSlice, r: &mut ResidualVector, ) -> Result<(), ComponentError> { let dh_eff = 150_000.0 * self.calib.z_ua; r[0] = self.port_out.pressure().to_pascals() - self.port_in.pressure().to_pascals(); r[1] = self.port_out.enthalpy().to_joules_per_kg() - (self.port_in.enthalpy().to_joules_per_kg() + dh_eff); Ok(()) } fn jacobian_entries( &self, _s: &StateSlice, _j: &mut JacobianBuilder, ) -> Result<(), ComponentError> { Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn port_mass_flows(&self, _: &StateSlice) -> Result, ComponentError> { Ok(vec![ MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05), ]) } } fn port(p_pa: f64, h_j_kg: f64) -> CP { let (connected, _) = Port::new( FluidId::new("R134a"), Pressure::from_pascals(p_pa), Enthalpy::from_joules_per_kg(h_j_kg), ) .connect(Port::new( FluidId::new("R134a"), Pressure::from_pascals(p_pa), Enthalpy::from_joules_per_kg(h_j_kg), )) .unwrap(); connected } fn make_calib() -> Calib { Calib { z_flow: 1.0, z_flow_eco: 1.0, z_dp: 1.0, z_ua: 1.0, z_power: 1.0, z_etav: 1.0, calibration_source: None, } } /// Compute the analytical solution for the calibrated cycle. fn analytical_solution(calib: &Calib) -> [f64; 8] { let p3 = 350_000.0; let h3 = 410_000.0; let p0 = p3 + 1_000_000.0; let h0 = h3 + 75_000.0 * calib.z_flow * calib.z_power; let p1 = p0 - 20_000.0 * calib.z_dp; let h1 = h0 - 75_000.0 * calib.z_flow * calib.z_power - 150_000.0 * calib.z_ua; let p2 = p3; let h2 = h1; [p0, h0, p1, h1, p2, h2, p3, h3] } fn solve_calibrated_cycle(calib: &Calib) -> Vec { let sol = analytical_solution(calib); let comp = Box::new(CalibCompressor { port_suc: port(sol[6], sol[7]), port_disc: port(sol[0], sol[1]), calib: calib.clone(), }); let cond = Box::new(CalibCondenser { port_in: port(sol[0], sol[1]), port_out: port(sol[2], sol[3]), calib: calib.clone(), }); let valv = Box::new(CalibValve { port_in: port(sol[2], sol[3]), port_out: port(sol[4], sol[5]), calib: calib.clone(), }); let evap = Box::new(CalibEvaporator { port_in: port(sol[4], sol[5]), port_out: port(sol[6], sol[7]), calib: calib.clone(), }); let mut system = System::new(); let n_comp = system.add_component(comp); let n_cond = system.add_component(cond); let n_valv = system.add_component(valv); let n_evap = system.add_component(evap); system.add_edge(n_comp, n_cond).unwrap(); system.add_edge(n_cond, n_valv).unwrap(); system.add_edge(n_valv, n_evap).unwrap(); system.add_edge(n_evap, n_comp).unwrap(); system.finalize().unwrap(); // CM1.2: state layout is now (ṁ, P, h) per edge (stride 3). Map the analytical // (P, h) pairs onto the correct slots and seed each edge's mass flow. let mut initial_state = vec![0.0; system.full_state_vector_len()]; for (i, edge_idx) in system.edge_indices().enumerate() { let (m_idx, p_idx, h_idx) = system.edge_state_indices_full(edge_idx); initial_state[m_idx] = DEFAULT_MASS_FLOW_SEED_KG_S; initial_state[p_idx] = sol[2 * i]; initial_state[h_idx] = sol[2 * i + 1]; } let mut config = NewtonConfig { max_iterations: 100, tolerance: 1e-8, line_search: false, use_numerical_jacobian: true, initial_state: Some(initial_state), ..NewtonConfig::default() }; let result = config.solve(&mut system).unwrap().state; // CM1.2: re-extract the (P, h) pairs per edge so downstream assertions keep // the historical [p0, h0, p1, h1, ...] layout independent of the ṁ slots. let mut ph = vec![0.0; 2 * system.edge_count()]; for (i, edge_idx) in system.edge_indices().enumerate() { let (p_idx, h_idx) = system.edge_state_indices(edge_idx); ph[2 * i] = result[p_idx]; ph[2 * i + 1] = result[h_idx]; } ph } /// Baseline: all Calib = 1.0 → results match nominal analytical solution. #[test] fn test_calibrated_cycle_nominal_baseline() { let calib = make_calib(); let sv = solve_calibrated_cycle(&calib); let expected = analytical_solution(&calib); for i in 0..8 { let diff = (sv[i] - expected[i]).abs(); assert!( diff < 10.0, "sv[{}]: got {}, expected {}, diff {}", i, sv[i], expected[i], diff ); } // Energy balance check let dh_comp = sv[1] - sv[7]; let dh_cond = sv[3] - sv[1]; let dh_valve = sv[5] - sv[3]; let dh_evap = sv[7] - sv[5]; let imbalance = dh_comp + dh_cond + dh_valve + dh_evap; assert!(imbalance.abs() < 10.0, "Energy imbalance: {imbalance}"); } /// f_ua = 1.1 on evaporator → capacity increases by 10% (±2% tolerance). #[test] fn test_calibrated_cycle_fua_increases_capacity() { let nom = make_calib(); let cal = Calib { z_ua: 1.1, calibration_source: Some("synthetic-fua".into()), ..make_calib() }; let sv_nom = solve_calibrated_cycle(&nom); let sv_cal = solve_calibrated_cycle(&cal); let dh_evap_nom = sv_nom[7] - sv_nom[5]; let dh_evap_cal = sv_cal[7] - sv_cal[5]; let capacity_ratio = dh_evap_cal / dh_evap_nom; assert!( (capacity_ratio - 1.10).abs() < 0.02, "Capacity ratio: {capacity_ratio:.4}, expected ~1.10 ±2%" ); } /// f_m * f_power on compressor → compressor work scales accordingly (±3% tolerance). #[test] fn test_calibrated_cycle_fm_fpower_scales_compressor_work() { let nom = make_calib(); let cal = Calib { z_flow: 1.05, z_power: 1.03, calibration_source: Some("test-bench-2024-A".into()), ..make_calib() }; let sv_nom = solve_calibrated_cycle(&nom); let sv_cal = solve_calibrated_cycle(&cal); let dh_comp_nom = sv_nom[1] - sv_nom[7]; let dh_comp_cal = sv_cal[1] - sv_cal[7]; let power_ratio = dh_comp_cal / dh_comp_nom; let expected = 1.05 * 1.03; assert!( (power_ratio - expected).abs() < 0.03, "Power ratio: {power_ratio:.4}, expected ~{expected:.4} ±3%" ); } /// f_dp on condenser → pressure drop scales by f_dp factor. #[test] fn test_calibrated_cycle_fdp_scales_pressure_drop() { let nom = make_calib(); let cal = Calib { z_dp: 1.5, calibration_source: Some("dp-test-synthetic".into()), ..make_calib() }; let sv_nom = solve_calibrated_cycle(&nom); let sv_cal = solve_calibrated_cycle(&cal); let dp_nom = sv_nom[2] - sv_nom[0]; // negative (pressure drop) let dp_cal = sv_cal[2] - sv_cal[0]; let dp_ratio = dp_cal / dp_nom; assert!( (dp_ratio - 1.5).abs() < 0.05, "Pressure drop ratio: {dp_ratio:.4}, expected ~1.50 ±5%" ); } /// Calib with calibration_source roundtrips through JSON and still produces correct results. #[test] fn test_calibrated_cycle_with_calibration_source_metadata() { let calib_json = r#"{ "f_m": 1.0, "f_dp": 1.0, "f_ua": 1.1, "f_power": 1.0, "f_etav": 1.0, "calibration_source": "manufacturer-test-report-2024-TR-001" }"#; let calib: Calib = serde_json::from_str(calib_json).unwrap(); assert_eq!( calib.calibration_source.as_deref(), Some("manufacturer-test-report-2024-TR-001") ); assert_eq!(calib.z_ua, 1.1); let sv = solve_calibrated_cycle(&calib); // f_ua=1.1 → evaporator Δh = 150kJ × 1.1 = 165 kJ/kg let dh_evap = sv[7] - sv[5]; assert!( (dh_evap - 165_000.0).abs() < 1_000.0, "Evaporator Δh with f_ua=1.1: {dh_evap:.0}, expected ~165000" ); }