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>
This commit is contained in:
271
crates/solver/tests/flooded_4port_dof.rs
Normal file
271
crates/solver/tests/flooded_4port_dof.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
//! 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}"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user