//! End-to-end **closed-loop capacity control** integration test. //! //! This exercises the design/control vertical slice built on top of the //! emergent-pressure cycle: //! //! * The evaporator cooling capacity is measured with REAL thermodynamics //! (`Component::measure_output(Capacity, …)` → `energy_transfers`), NOT the //! legacy placeholder formula. //! * The compressor exposes a genuine actuator: the inverse-control variable //! `f_m` scales the swept mass flow in its residual `r0 = ṁ − f_m·ṁ_calc` //! and emits the matching Jacobian column `∂r0/∂f_m = −ṁ_calc`. //! //! A `Capacity` constraint on the evaporator is linked to an `f_m` //! `BoundedVariable` on the compressor. The solver must therefore find the //! compressor loading that makes the emergent cooling capacity meet the target — //! this is the core "design a machine to a duty" loop, with no bricolage. //! //! Requires the `coolprop` feature (entropy + saturation properties): //! cargo test -p entropyk-solver --features coolprop --test capacity_control_integration #![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, Constraint, ConstraintId, }; use entropyk_solver::solver::Solver; use entropyk_solver::system::System; use entropyk_solver::{FallbackSolver, NewtonConfig}; /// Base emergent-cycle state layout (9 unknowns, same-branch series loop): /// `[ṁ, P0,h0, P1,h1, P2,h2, P3,h3]`. const N_BASE: usize = 9; /// Assembles the emergent cycle. When `capacity_target` is `Some(w)`, a /// `Capacity` constraint on the evaporator is linked to an `f_m` actuator on the /// compressor (closed-loop capacity control). Returns `(ṁ, q_evap, f_m)`. fn solve(capacity_target: Option) -> (f64, f64, f64) { 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(); // E0 comp→cond system.add_edge(n_cond, n_exv).unwrap(); // E1 cond→exv system.add_edge(n_exv, n_evap).unwrap(); // E2 exv→evap system.add_edge(n_evap, n_comp).unwrap(); // E3 evap→comp if let Some(target_w) = capacity_target { // Constraint: evaporator cooling capacity = target (real ε-NTU duty). system .add_constraint(Constraint::new( ConstraintId::new("capacity_control"), ComponentOutput::Capacity { component_id: "evaporator".to_string(), }, target_w, )) .unwrap(); // Actuator: compressor mass-flow multiplier f_m ∈ [0.5, 2.0]. The `f_m` // suffix wires it into the compressor's CalibIndices during finalize(). let bv = BoundedVariable::with_component( BoundedVariableId::new("compressor_f_m"), "compressor", 1.0, 0.5, 2.0, ) .unwrap(); system.add_bounded_variable(bv).unwrap(); system .link_constraint_to_control( &ConstraintId::new("capacity_control"), &BoundedVariableId::new("compressor_f_m"), ) .unwrap(); } system.finalize().unwrap(); // Physically-consistent seed near the expected operating point. let mut initial_state = vec![ 0.05, // ṁ [kg/s] 11.6e5, 445e3, // E0 comp→cond : P_cond, h_dis 11.6e5, 262e3, // E1 cond→exv : P_cond, h_liq 3.50e5, 262e3, // E2 exv→evap : P_evap, h (isenthalpic) 3.50e5, 405e3, // E3 evap→comp : P_evap, h_suction (superheated) ]; debug_assert_eq!(initial_state.len(), N_BASE); // Append control / coupling slots (f_m seeded at its nominal 1.0, rest 0). let n_full = system.full_state_vector_len(); while initial_state.len() < n_full { initial_state.push(if initial_state.len() == N_BASE { 1.0 } else { 0.0 }); } 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!("solve(target={:?}) must converge: {:?}", capacity_target, e)); let sv = &converged.state; let m_dot = sv[0]; let h_evap_in = sv[6]; let h_evap_out = sv[8]; let q_evap = m_dot * (h_evap_out - h_evap_in); // f_m lives at total_state_len + 0 (first/only linked control) when present. let f_m = if capacity_target.is_some() { sv[N_BASE] } else { 1.0 }; (m_dot, q_evap, f_m) } /// The compressor `f_m` actuator must genuinely drive the emergent cooling /// capacity to a commanded target: a higher capacity target must be met by a /// higher solved mass flow AND a higher compressor loading `f_m`. #[test] fn test_capacity_target_drives_compressor_loading() { // 1. Nominal (uncontrolled, f_m = 1) capacity of this machine. let (m_nom, q_nom, _) = solve(None); assert!(q_nom > 0.0, "nominal capacity must be positive: {}", q_nom); assert!(m_nom > 0.0, "nominal mass flow must be positive: {}", m_nom); // 2. Two achievable targets bracketing the nominal point (within f_m range). let target_low = 0.85 * q_nom; let target_high = 1.15 * q_nom; let (m_low, q_low, fm_low) = solve(Some(target_low)); let (m_high, q_high, fm_high) = solve(Some(target_high)); // The closed loop meets each commanded capacity (5 % tolerance). assert!( (q_low - target_low).abs() < 0.05 * target_low, "low target not met: got {:.0} W, wanted {:.0} W", q_low, target_low ); assert!( (q_high - target_high).abs() < 0.05 * target_high, "high target not met: got {:.0} W, wanted {:.0} W", q_high, target_high ); // Higher duty ⇒ more mass flow AND more compressor loading — the actuator // is doing real physical work, not being ignored. assert!( m_high > m_low, "higher capacity must raise solved ṁ: {:.4} → {:.4} kg/s", m_low, m_high ); assert!( fm_high > fm_low, "higher capacity must raise compressor loading z_flow: {:.3} → {:.3}", fm_low, fm_high ); // f_m must stay within its declared bounds. assert!( (0.5..=2.0).contains(&fm_low) && (0.5..=2.0).contains(&fm_high), "f_m out of bounds: {:.3}, {:.3}", fm_low, fm_high ); }