//! DoF balance for a water-cooled chiller with FloodedEvaporator (4-port live secondary). //! //! This test is intentionally **topology + ledger only** (no Newton solve): //! - builds the honest machine graph with named multi-port edges; //! - finalizes and asserts `validate_system_dof()` (square system); //! - does **not** require CoolProp (uses `TestBackend` for boundary enthalpy only). //! //! Budget target (CM1.4): //! unknowns = 3 ṁ-branches + 2×8 edges = 19 //! equations = Comp2 + Cond4 + EXV1 + Flooded4 + 2×(Src3+Sink1) = 19 //! //! Run: //! cargo test -p entropyk-solver --test flooded_4port_dof use std::sync::Arc; use entropyk_components::brine_boundary::{BrineSink, BrineSource}; use entropyk_components::isentropic_compressor::VolumetricEfficiency; use entropyk_components::port::{FluidId as PortFluidId, Port}; use entropyk_components::{ Component, Condenser, FloodedEvaporator, IsenthalpicExpansionValve, IsentropicCompressor, }; use entropyk_core::{Concentration, Pressure, Temperature}; use entropyk_fluids::{FluidBackend, TestBackend}; use entropyk_solver::system::System; use entropyk_solver::{EquationRole, SystemDofBalance, SystemDofError}; fn dummy_port(fluid: &str) -> entropyk_components::ConnectedPort { let a = Port::new( PortFluidId::new(fluid), Pressure::from_bar(2.0), entropyk_core::Enthalpy::from_joules_per_kg(50_000.0), ); let b = Port::new( PortFluidId::new(fluid), Pressure::from_bar(2.0), entropyk_core::Enthalpy::from_joules_per_kg(50_000.0), ); a.connect(b).expect("dummy port pair").0 } /// Assemble the flooded water-cooled topology and return a finalized system. fn build_flooded_watercooled() -> System { let backend: Arc = Arc::new(TestBackend::new()); let ref_fluid = "R134a"; let water = "Water"; let comp = Box::new( IsentropicCompressor::new(0.70, 313.15, 278.15, 5.0) .with_refrigerant(ref_fluid) .with_fluid_backend(backend.clone()) .with_displacement(5.0e-5, 50.0, VolumetricEfficiency::Constant(0.92)), ); let cond = Box::new( Condenser::new(2200.0) .with_refrigerant(ref_fluid) .with_secondary_fluid(water) .with_fluid_backend(backend.clone()) .with_emergent_pressure(5.0), ); let exv = Box::new( IsenthalpicExpansionValve::new(278.15) .with_refrigerant(ref_fluid) .with_fluid_backend(backend.clone()) .with_emergent_pressure(), ); // quality_control=false → saturated-vapor suction closure (default). let evap = Box::new( FloodedEvaporator::new(9000.0) .with_refrigerant(ref_fluid) .with_secondary_fluid(water) .with_fluid_backend(backend.clone()) .with_quality_control(false), ); // TestBackend Water P-T is only valid near 1 atm liquid — keep p ≤ 1.05 bar. let p_water = Pressure::from_bar(1.0); let cond_src = Box::new( BrineSource::new( water, p_water, Temperature::from_celsius(30.0), Concentration::from_percent(0.0), backend.clone(), dummy_port(water), ) .expect("cond BrineSource") .with_imposed_mass_flow(0.45) .expect("cond m_flow"), ); let cond_sink = Box::new( BrineSink::new( water, p_water, None, None, backend.clone(), dummy_port(water), ) .expect("cond BrineSink"), ); let evap_src = Box::new( BrineSource::new( water, p_water, Temperature::from_celsius(12.0), Concentration::from_percent(0.0), backend.clone(), dummy_port(water), ) .expect("evap BrineSource") .with_imposed_mass_flow(0.55) .expect("evap m_flow"), ); let evap_sink = Box::new( BrineSink::new(water, p_water, None, None, backend, dummy_port(water)) .expect("evap BrineSink"), ); 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); let n_cwi = system.add_component(cond_src); let n_cwo = system.add_component(cond_sink); let n_ewi = system.add_component(evap_src); let n_ewo = system.add_component(evap_sink); system.register_component_name("comp", n_comp); system.register_component_name("cond", n_cond); system.register_component_name("exv", n_exv); system.register_component_name("evap", n_evap); system.register_component_name("cond_water_in", n_cwi); system.register_component_name("cond_water_out", n_cwo); system.register_component_name("evap_water_in", n_ewi); system.register_component_name("evap_water_out", n_ewo); // Refrigerant loop: outlet(1) → inlet(0). get_ports() empty → indices kept as-is. system .add_edge_with_ports(n_comp, 1, n_cond, 0) .expect("comp→cond"); system .add_edge_with_ports(n_cond, 1, n_exv, 0) .expect("cond→exv"); system .add_edge_with_ports(n_exv, 1, n_evap, 0) .expect("exv→evap"); system .add_edge_with_ports(n_evap, 1, n_comp, 0) .expect("evap→comp"); // Condenser water: source outlet(0) → cond secondary_in(2); cond secondary_out(3) → sink(0) system .add_edge_with_ports(n_cwi, 0, n_cond, 2) .expect("cw in"); system .add_edge_with_ports(n_cond, 3, n_cwo, 0) .expect("cw out"); // Evaporator water system .add_edge_with_ports(n_ewi, 0, n_evap, 2) .expect("chw in"); system .add_edge_with_ports(n_evap, 3, n_ewo, 0) .expect("chw out"); system.finalize().expect("finalize flooded water-cooled graph"); system } #[test] fn flooded_watercooled_4port_is_dof_balanced() { let system = build_flooded_watercooled(); let report = system.dof_report(); assert_eq!( report.n_unknowns, 19, "unknowns: 3 branches + 2×8 edges = 19\n{}", report.summary() ); assert_eq!( report.n_equations, 19, "equations must match unknowns\n{}", report.summary() ); assert_eq!( report.balance, SystemDofBalance::Balanced, "square system required\n{}", report.summary() ); assert!( system.validate_system_dof().is_ok(), "hard DoF gate must pass\n{}", report.summary() ); // Flooded block must declare saturated-vapor closure (not quality) by default. let evap = report .components .iter() .find(|c| c.component_name == "evap") .expect("evap in ledger"); assert_eq!(evap.n_equations, 4, "ΔP + energy + sat-vapor + secondary energy"); assert!( evap.roles.iter().any(|r| matches!( r, EquationRole::OutletClosure { kind: "saturated_vapor" } )), "expected saturated_vapor outlet closure, got {:?}", evap.roles ); } #[test] fn quality_control_without_extra_free_still_same_equation_count() { // quality_control replaces sat-vapor residual — n_equations must stay constant // (no silent DoF jump). This guards against re-introducing +1 without free. let backend: Arc = Arc::new(TestBackend::new()); let mut with_q = FloodedEvaporator::new(9000.0) .with_refrigerant("R134a") .with_secondary_fluid("Water") .with_fluid_backend(backend.clone()) .with_quality_control(true); let mut without_q = FloodedEvaporator::new(9000.0) .with_refrigerant("R134a") .with_secondary_fluid("Water") .with_fluid_backend(backend) .with_quality_control(false); // Wire same 4-port context so n_secondary matches. let ports = [ Some((0, 1, 2)), Some((0, 3, 4)), Some((5, 6, 7)), Some((5, 8, 9)), ]; with_q.set_port_context(&ports); without_q.set_port_context(&ports); assert_eq!( with_q.n_equations(), without_q.n_equations(), "quality_control must replace sat-vapor closure, not add a residual" ); assert_eq!(with_q.n_equations(), 4); } #[test] fn overconstrained_extra_quality_anchor_is_rejected_by_finalize_gate() { // If someone stacked an extra outlet closure without freeing an unknown, // finalize with enforce_dof_gate must refuse (over-constrained). // Here we only assert the public gate API rejects imbalance when equations > unknowns // using the already-balanced system as baseline — flip by adding a free residual mock // is covered in dof_balance.rs. This test documents the expected production policy. let system = build_flooded_watercooled(); match system.validate_system_dof() { Ok(()) => {} Err(SystemDofError::Imbalance { .. }) => { panic!("balanced flooded machine must not report Imbalance") } Err(e) => panic!("unexpected DoF error: {e}"), } }