/// 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_solver::{ solver::{NewtonConfig, Solver}, system::System, }; use entropyk_components::port::{Connected, FluidId, Port}; use entropyk_core::{Enthalpy, Pressure}; 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.f_m * self.calib.f_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.f_dp; // Condenser rejects compressor work + evaporator load (energy balance) let dh_reject = 75_000.0 * self.calib.f_m * self.calib.f_power + 150_000.0 * self.calib.f_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.f_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.f_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 { f_m: 1.0, f_dp: 1.0, f_ua: 1.0, f_power: 1.0, f_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.f_m * calib.f_power; let p1 = p0 - 20_000.0 * calib.f_dp; let h1 = h0 - 75_000.0 * calib.f_m * calib.f_power - 150_000.0 * calib.f_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(); let mut config = NewtonConfig { max_iterations: 100, tolerance: 1e-8, line_search: false, use_numerical_jacobian: true, initial_state: Some(sol.to_vec()), ..NewtonConfig::default() }; config.solve(&mut system).unwrap().state } /// 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 { f_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 { f_m: 1.05, f_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 { f_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.f_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" ); }