Files
Entropyk/crates/solver/tests/flooded_4port_dof.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
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>
2026-07-17 22:46:46 +02:00

272 lines
9.1 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");
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}"),
}
}