//! Integration test for the Newton-homotopy continuation solver. //! //! Builds the same 4-component R134a refrigeration loop used by the Newton //! integration test (`refrigeration_cycle_integration.rs`) and solves it with //! [`HomotopyConfig`] instead of `NewtonConfig`. The purpose is to prove the //! homotopy strategy integrates end-to-end with the real edge-based [`System`] //! machinery (stride-3 `(ṁ, P, h)` state, finalize, mass-flow closures) and //! returns a converged result. //! //! NOTE on the fixture: the mock components return `&[]` from `get_ports()`, so //! the `System` cannot wire edges to their ports. Their residuals are therefore //! read from the construction-time port values (set to the analytic solution) //! and are independent of the live state vector. This mirrors the existing //! Newton integration test, which for the same reason only asserts convergence //! rather than specific state values. The numerical behaviour of the homotopy //! continuation (λ-stepping, residual blending, restart-on-failure) is covered //! by the unit tests in `strategies::homotopy`. use entropyk_components::port::{Connected, FluidId, Port}; use entropyk_components::{ Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice, }; use entropyk_core::{Enthalpy, MassFlow, Pressure}; use entropyk_solver::{ solver::{Solver, SolverError}, strategies::HomotopyConfig, system::{System, DEFAULT_MASS_FLOW_SEED_KG_S}, }; type CP = Port; // r[0] = p_disc - (p_suc + 1 MPa) ; r[1] = h_disc - (h_suc + 75 kJ/kg) struct MockCompressor { port_suc: CP, port_disc: CP, } impl Component for MockCompressor { fn compute_residuals( &self, _s: &StateSlice, r: &mut ResidualVector, ) -> Result<(), ComponentError> { 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() + 75_000.0); 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), ]) } } // r[0] = p_out - p_in ; r[1] = h_out - (h_in - 225 kJ/kg) struct MockCondenser { port_in: CP, port_out: CP, } impl Component for MockCondenser { fn compute_residuals( &self, _s: &StateSlice, r: &mut ResidualVector, ) -> Result<(), ComponentError> { 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() - 225_000.0); 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), ]) } } // r[0] = p_out - (p_in - 1 MPa) ; r[1] = h_out - h_in struct MockValve { port_in: CP, port_out: CP, } impl Component for MockValve { fn compute_residuals( &self, _s: &StateSlice, r: &mut ResidualVector, ) -> Result<(), ComponentError> { r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - 1_000_000.0); 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), ]) } } // r[0] = p_out - p_in ; r[1] = h_out - (h_in + 150 kJ/kg) struct MockEvaporator { port_in: CP, port_out: CP, } impl Component for MockEvaporator { fn compute_residuals( &self, _s: &StateSlice, r: &mut ResidualVector, ) -> Result<(), ComponentError> { 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() + 150_000.0); 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 build_loop() -> System { let p_lp = 350_000.0_f64; let p_hp = 1_350_000.0_f64; let comp = Box::new(MockCompressor { port_suc: port(p_lp, 410_000.0), port_disc: port(p_hp, 485_000.0), }); let cond = Box::new(MockCondenser { port_in: port(p_hp, 485_000.0), port_out: port(p_hp, 260_000.0), }); let valv = Box::new(MockValve { port_in: port(p_hp, 260_000.0), port_out: port(p_lp, 260_000.0), }); let evap = Box::new(MockEvaporator { port_in: port(p_lp, 260_000.0), port_out: port(p_lp, 410_000.0), }); 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(); system } /// `HomotopyConfig` drives the real edge-based System machinery to a converged /// result, just like `NewtonConfig` does on the same loop. #[test] fn test_homotopy_solves_refrigeration_loop() { let mut system = build_loop(); let p_lp = 350_000.0_f64; let p_hp = 1_350_000.0_f64; let m = DEFAULT_MASS_FLOW_SEED_KG_S; // CM1.4 layout: 1 shared ṁ (single series branch) + (P, h) per edge. // state = [ṁ, P₀, h₀, P₁, h₁, P₂, h₂, P₃, h₃] (9 elements) let initial_state = vec![ m, // ṁ shared (branch 0) p_hp, 485_000.0, // edge0 comp→cond: P, h p_hp, 260_000.0, // edge1 cond→valve: P, h p_lp, 260_000.0, // edge2 valve→evap: P, h p_lp, 410_000.0, // edge3 evap→comp: P, h ]; let mut solver = HomotopyConfig { use_numerical_jacobian: true, // mock analytic Jacobian is empty initial_state: Some(initial_state), ..HomotopyConfig::default() }; let t0 = std::time::Instant::now(); let result = solver .solve(&mut system) .expect("homotopy should converge on the refrigeration loop"); let elapsed = t0.elapsed(); assert!( result.final_residual < 1e-6, "final residual too large: {:.3e}", result.final_residual ); assert!(elapsed.as_millis() < 5000, "should converge in < 5 s"); } /// A caller-supplied `initial_state` whose length does not match the system /// state vector must be rejected with `InvalidSystem` rather than silently /// substituted by an all-zeros guess (which would hide the caller's bug). #[test] fn test_homotopy_rejects_mismatched_initial_state_length() { let mut system = build_loop(); let n_state = system.full_state_vector_len(); assert!(n_state > 0, "loop should have state variables"); let mut solver = HomotopyConfig { use_numerical_jacobian: true, initial_state: Some(vec![0.0; n_state + 1]), // deliberately too long ..HomotopyConfig::default() }; match solver.solve(&mut system) { Err(SolverError::InvalidSystem { message }) => { assert!( message.contains("initial_state length"), "unexpected message: {message}" ); } other => panic!("expected InvalidSystem for length mismatch, got {other:?}"), } }