//! End-to-end saturated PI control integration test. //! //! The loop is co-solved with the emergent-pressure refrigeration cycle: the //! saturated controller contributes `(u, x)` unknowns, wires compressor `f_m` //! through `CalibIndices`, and measures real evaporator capacity from component //! thermodynamics. #![cfg(feature = "coolprop")] use std::sync::Arc; use entropyk_components::isentropic_compressor::VolumetricEfficiency; use entropyk_components::{Condenser, Evaporator, IsenthalpicExpansionValve, IsentropicCompressor}; use entropyk_fluids::{CoolPropBackend, FluidBackend}; use entropyk_solver::inverse::{ BoundedVariable, BoundedVariableId, ComponentOutput, ConstraintId, SaturatedController, Saturation, }; use entropyk_solver::solver::Solver; use entropyk_solver::system::System; use entropyk_solver::{FallbackSolver, NewtonConfig}; const N_BASE: usize = 9; fn build_system(controller: Option) -> System { let backend: Arc = Arc::new(CoolPropBackend::new()); let fluid = "R134a"; let comp = Box::new( IsentropicCompressor::new(0.70, 318.15, 278.15, 5.0) .with_refrigerant(fluid) .with_fluid_backend(backend.clone()) .with_displacement(6.5e-5, 50.0, VolumetricEfficiency::Constant(0.92)), ); let cond = Box::new( Condenser::new(766.0) .with_refrigerant(fluid) .with_fluid_backend(backend.clone()) .with_secondary_stream(303.15, 1500.0) .with_emergent_pressure(5.0), ); let exv = Box::new( IsenthalpicExpansionValve::new(278.15) .with_refrigerant(fluid) .with_fluid_backend(backend.clone()) .with_emergent_pressure(), ); let evap = Box::new( Evaporator::new(1468.0) .with_refrigerant(fluid) .with_fluid_backend(backend.clone()) .with_secondary_stream(285.15, 2000.0) .with_emergent_pressure(), ); let mut system = System::new(); let n_comp = system.add_component(comp); let n_cond = system.add_component(cond); let n_exv = system.add_component(exv); let n_evap = system.add_component(evap); system.register_component_name("compressor", n_comp); system.register_component_name("evaporator", n_evap); system.add_edge(n_comp, n_cond).unwrap(); system.add_edge(n_cond, n_exv).unwrap(); system.add_edge(n_exv, n_evap).unwrap(); system.add_edge(n_evap, n_comp).unwrap(); if let Some(ctrl) = controller { let bv = BoundedVariable::with_component( BoundedVariableId::new("compressor_f_m"), "compressor", 1.0, ctrl.u_min(), ctrl.u_max(), ) .unwrap(); system.add_bounded_variable(bv).unwrap(); system.add_saturated_controller(ctrl); } system.finalize().unwrap(); system } fn seed_state(system: &System) -> Vec { let mut initial_state = vec![ 0.05, 11.6e5, 445e3, 11.6e5, 262e3, 3.50e5, 262e3, 3.50e5, 405e3, ]; debug_assert_eq!(initial_state.len(), N_BASE); while initial_state.len() < system.full_state_vector_len() { initial_state.push(if initial_state.len() == N_BASE { 1.0 } else { 0.0 }); } initial_state } fn solve_capacity(controller: Option) -> (f64, f64, f64, f64) { let mut system = build_system(controller); let initial_state = seed_state(&system); let config = NewtonConfig { max_iterations: 300, tolerance: 1e-6, line_search: true, use_numerical_jacobian: false, initial_state: Some(initial_state.clone()), ..NewtonConfig::default() }; let mut solver = FallbackSolver::default_solver() .with_newton_config(config) .with_initial_state(initial_state); let converged = solver .solve(&mut system) .unwrap_or_else(|e| panic!("saturated capacity solve must converge: {e:?}")); let state = &converged.state; let q_evap = state[0] * (state[8] - state[6]); let u = if system.saturated_controller_count() > 0 { state[N_BASE] } else { 1.0 }; let x = if system.saturated_controller_count() > 0 { state[N_BASE + 1] } else { 0.0 }; (state[0], q_evap, u, x) } fn capacity_controller(setpoint: f64, u_min: f64, u_max: f64) -> SaturatedController { SaturatedController::new( ConstraintId::new("capacity_sat_loop"), ComponentOutput::Capacity { component_id: "evaporator".to_string(), }, BoundedVariableId::new("compressor_f_m"), setpoint, u_min, u_max, ) .unwrap() .with_gain(1.0e-2) .unwrap() .with_band(1.0) .unwrap() .with_saturation(Saturation::Hard) } #[test] fn saturated_lwt_control_tracks_when_unsaturated() { let (_m_nom, q_nom, _, _) = solve_capacity(None); assert!(q_nom > 0.0); let (_m, q, u, x) = solve_capacity(Some(capacity_controller(q_nom, 0.5, 1.5))); assert!( (q - q_nom).abs() < 0.03 * q_nom, "wide saturated loop should track nominal capacity: got {q:.1} W, target {q_nom:.1} W" ); assert!( (0.5..=1.5).contains(&u) && x.abs() < 0.25, "controller should remain unsaturated: u={u:.4}, x={x:.4}" ); } #[test] fn saturated_lwt_control_pins_actuator_when_saturated() { let (_m_nom, q_nom, _, _) = solve_capacity(None); let target = 1.30 * q_nom; let (_m, q, u, x) = solve_capacity(Some(capacity_controller(target, 0.75, 1.0))); assert!( (u - 1.0).abs() < 2.0e-3, "tight loop should pin compressor f_m at upper bound: u={u:.6}" ); assert!( x > 1.0, "anti-windup state should move beyond the saturation band: x={x:.4}" ); assert!( (q - target).abs() > 0.10 * q_nom, "tracking error should be released at saturation: q={q:.1} W, target={target:.1} W" ); }