Files
Entropyk/crates/solver/tests/flooded_4port_dof.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:35:31 +02:00

282 lines
9.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
// Secondary isobaric P closure was added for the Modelica MassFlowSource_T
// (Free P) pattern: the HX propagates the sink pressure to the source edge.
assert_eq!(
evap.n_equations, 5,
"ΔP + energy + sat-vapor + secondary P + 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"
);
// 3 refrigerant rows (ΔP + energy + closure) + 2 secondary (P + energy, same branch).
assert_eq!(with_q.n_equations(), 5);
}
#[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}"),
}
}