Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
272 lines
9.1 KiB
Rust
272 lines
9.1 KiB
Rust
//! 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<dyn FluidBackend> = 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<dyn FluidBackend> = 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}"),
|
||
}
|
||
}
|