chore: remove deprecated flow_boundary and update docs to match new architecture

This commit is contained in:
Sepehr
2026-03-01 20:00:09 +01:00
parent 20700afce8
commit d88914a44f
105 changed files with 11222 additions and 2994 deletions

View File

@@ -0,0 +1,625 @@
//! Integration test: Air-Cooled Chiller with Screw Economizer Compressor
//!
//! Simulates a 2-circuit air-cooled chiller with:
//! - 2 × ScrewEconomizerCompressor (R134a, VFD controlled 2560 Hz)
//! - 4 × MchxCondenserCoil + fan banks (35°C ambient air)
//! - 2 × FloodedEvaporator + Drum (water-glycol MEG 35%, 12°C → 7°C)
//! - Economizer (flash-gas injection)
//! - Superheat control via Constraint
//! - Fan speed control (anti-override) via BoundedVariable
//!
//! ## Topology per circuit (× 2 circuits)
//!
//! ```text
//! BrineSource(MEG35%, 12°C)
//! ↓
//! FloodedEvaporator ←── Drum ←── Economizer(flash)
//! ↓ ↑
//! ScrewEconomizerCompressor(eco port) ──┘
//! ↓
//! FlowSplitter (1 → 2 coils)
//! ↓ ↓
//! MchxCoil_A+Fan_A MchxCoil_B+Fan_B
//! ↓ ↓
//! FlowMerger (2 → 1)
//! ↓
//! ExpansionValve
//! ↓
//! BrineSink(MEG35%, 7°C)
//! ```
//!
//! This test validates topology construction, finalization, and that all
//! components can compute residuals without errors at a reasonable initial state.
use entropyk_components::port::{Connected, FluidId, Port};
use entropyk_components::state_machine::{CircuitId, OperationalState};
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, MchxCondenserCoil, Polynomial2D,
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, StateManageable, StateSlice,
};
use entropyk_core::{Enthalpy, MassFlow, Power, Pressure};
use entropyk_solver::{system::System, TopologyError};
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
type CP = Port<Connected>;
/// Creates a connected port pair — returns the first (connected) port.
fn make_port(fluid: &str, p_bar: f64, h_kj_kg: f64) -> ConnectedPort {
let a = Port::new(
FluidId::new(fluid),
Pressure::from_bar(p_bar),
Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
);
let b = Port::new(
FluidId::new(fluid),
Pressure::from_bar(p_bar),
Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
);
a.connect(b).expect("port connection ok").0
}
/// Creates screw compressor performance curves representing a ~200 kW screw
/// refrigerating unit at 50 Hz (R134a).
///
/// SST reference: +3°C = 276.15 K
/// SDT reference: +50°C = 323.15 K
fn make_screw_curves() -> ScrewPerformanceCurves {
// Bilinear approximation:
// ṁ_suc [kg/s] = 1.20 + 0.003×(SST-276) - 0.002×(SDT-323) + 1e-5×(SST-276)×(SDT-323)
// W_shaft [W] = 55000 + 200×(SST-276) - 300×(SDT-323) + 0.5×…
ScrewPerformanceCurves::with_fixed_eco_fraction(
Polynomial2D::bilinear(1.20, 0.003, -0.002, 0.000_01),
Polynomial2D::bilinear(55_000.0, 200.0, -300.0, 0.5),
0.12, // 12% economizer fraction
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock components used for sections not yet wired with real residuals
// (FloodedEvaporator, Drum, Economizer, ExpansionValve, BrineSource/Sink,
// FlowSplitter/Merger — these already exist as real components, but for this
// topology test we use mocks to isolate the new components under test)
// ─────────────────────────────────────────────────────────────────────────────
/// Generic mock component: all residuals = 0, n_equations configurable.
struct Mock {
n: usize,
circuit_id: CircuitId,
}
impl Mock {
fn new(n: usize, circuit: u16) -> Self {
Self {
n,
circuit_id: CircuitId(circuit),
}
}
}
impl Component for Mock {
fn compute_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for r in residuals.iter_mut().take(self.n) {
*r = 0.0;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
self.n
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_mass_flows(&self, _state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
Ok(vec![MassFlow::from_kg_per_s(1.0)])
}
fn energy_transfers(&self, _state: &StateSlice) -> Option<(Power, Power)> {
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 1: ScrewEconomizerCompressor topology
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_screw_compressor_creation_and_residuals() {
let suc = make_port("R134a", 3.2, 400.0);
let dis = make_port("R134a", 12.8, 440.0);
let eco = make_port("R134a", 6.4, 260.0);
let comp =
ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
.expect("compressor creation ok");
assert_eq!(comp.n_equations(), 5);
// Compute residuals at a plausible operating state
let state = vec![
1.2, // ṁ_suc [kg/s]
0.144, // ṁ_eco [kg/s] = 12% × 1.2
400_000.0, // h_suc [J/kg]
440_000.0, // h_dis [J/kg]
55_000.0, // W_shaft [W]
];
let mut residuals = vec![0.0; 5];
comp.compute_residuals(&state, &mut residuals)
.expect("residuals computed");
// All residuals must be finite
for (i, r) in residuals.iter().enumerate() {
assert!(r.is_finite(), "residual[{}] = {} not finite", i, r);
}
// Residual[4] (shaft power balance): W_calc - W_state
// Polynomial at SST~276K, SDT~323K gives ~55000 W → residual ≈ 0
println!("Screw residuals: {:?}", residuals);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 2: VFD frequency scaling
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_screw_vfd_scaling() {
let suc = make_port("R134a", 3.2, 400.0);
let dis = make_port("R134a", 12.8, 440.0);
let eco = make_port("R134a", 6.4, 260.0);
let mut comp =
ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
.unwrap();
// At full speed (50 Hz): compute mass flow residual
let state_full = vec![1.2, 0.144, 400_000.0, 440_000.0, 55_000.0];
let mut r_full = vec![0.0; 5];
comp.compute_residuals(&state_full, &mut r_full).unwrap();
let m_error_full = r_full[0].abs();
// At 40 Hz (80%): mass flow should be ~80% of full speed
comp.set_frequency_hz(40.0).unwrap();
assert!((comp.frequency_ratio() - 0.8).abs() < 1e-10);
let state_reduced = vec![0.96, 0.115, 400_000.0, 440_000.0, 44_000.0];
let mut r_reduced = vec![0.0; 5];
comp.compute_residuals(&state_reduced, &mut r_reduced)
.unwrap();
let m_error_reduced = r_reduced[0].abs();
println!(
"VFD test: r[0] at 50Hz = {:.4}, at 40Hz = {:.4}",
m_error_full, m_error_reduced
);
// Both should be finite
assert!(m_error_full.is_finite());
assert!(m_error_reduced.is_finite());
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 3: MCHX condenser coil UA correction
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_mchx_ua_correction_with_fan_speed() {
// Coil bank: 4 coils, 15 kW/K each at design point (35°C, fan=100%)
let ua_per_coil = 15_000.0; // W/K
let mut coils: Vec<MchxCondenserCoil> = (0..4)
.map(|i| MchxCondenserCoil::for_35c_ambient(ua_per_coil, i))
.collect();
// Total UA at full speed
let ua_total_full: f64 = coils.iter().map(|c| c.ua_effective()).sum();
assert!(
(ua_total_full - 4.0 * ua_per_coil).abs() < 2000.0,
"Total UA at full speed should be ≈ 60 kW/K, got {:.0}",
ua_total_full
);
// Reduce fan 1 to 70% (anti-override scenario)
coils[0].set_fan_speed_ratio(0.70);
let ua_coil0_reduced = coils[0].ua_effective();
let ua_coil0_full = coils[1].ua_effective(); // coil[1] still at 100%
// UA at 70% speed = UA_nominal × 0.7^0.5 ≈ UA_nominal × 0.837
let expected_ratio = 0.70_f64.sqrt();
let actual_ratio = ua_coil0_reduced / ua_coil0_full;
let tol = 0.02; // 2% tolerance
assert!(
(actual_ratio - expected_ratio).abs() < tol,
"UA ratio expected {:.3}, got {:.3}",
expected_ratio,
actual_ratio
);
println!(
"MCHX UA: full={:.0} W/K, at 70% fan={:.0} W/K (ratio={:.3})",
ua_coil0_full, ua_coil0_reduced, actual_ratio
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 4: MCHX UA decreases at high ambient temperature
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_mchx_ua_ambient_temperature_effect() {
let mut coil_35 = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
let mut coil_45 = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
coil_45.set_air_temperature_celsius(45.0);
let ua_35 = coil_35.ua_effective();
let ua_45 = coil_45.ua_effective();
println!("UA at 35°C: {:.0} W/K, UA at 45°C: {:.0} W/K", ua_35, ua_45);
// Higher ambient → lower air density → lower UA
assert!(
ua_45 < ua_35,
"UA should decrease with higher ambient temperature"
);
// The reduction should be ~3% (density ratio: 1.12/1.09 ≈ 0.973)
let density_35 = 1.12_f64;
let density_45 = 101_325.0 / (287.058 * 318.15); // ≈ 1.109
let expected_ratio = density_45 / density_35;
let actual_ratio = ua_45 / ua_35;
assert!(
(actual_ratio - expected_ratio).abs() < 0.02,
"Density ratio expected {:.4}, got {:.4}",
expected_ratio,
actual_ratio
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 5: 2-circuit system topology construction
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_two_circuit_chiller_topology() {
let mut sys = System::new();
// ── Circuit 0 (compressor + condenser side) ───────────────────────────────
// Simplified topology using Mock components to validate graph construction:
//
// Screw comp → FlowSplitter → [CoilA, CoilB] → FlowMerger
// → EXV → FloodedEvap
// ← Drum ← Economizer ←────────────────────────────┘
// Screw compressor circuit 0
let comp0_suc = make_port("R134a", 3.2, 400.0);
let comp0_dis = make_port("R134a", 12.8, 440.0);
let comp0_eco = make_port("R134a", 6.4, 260.0);
let comp0 = ScrewEconomizerCompressor::new(
make_screw_curves(),
"R134a",
50.0,
0.92,
comp0_suc,
comp0_dis,
comp0_eco,
)
.unwrap();
let comp0_node = sys
.add_component_to_circuit(Box::new(comp0), CircuitId::ZERO)
.expect("add comp0");
// 4 MCHX coils for circuit 0 (2 coils per circuit in this test)
for i in 0..2 {
let coil = MchxCondenserCoil::for_35c_ambient(15_000.0, i);
let coil_node = sys
.add_component_to_circuit(Box::new(coil), CircuitId::ZERO)
.expect("add coil");
sys.add_edge(comp0_node, coil_node).expect("comp→coil edge");
}
// FlowMerger (mock), EXV, FloodedEvap, Drum, Eco — all mock
let merger = sys
.add_component_to_circuit(Box::new(Mock::new(2, 0)), CircuitId::ZERO)
.unwrap();
let exv = sys
.add_component_to_circuit(Box::new(Mock::new(2, 0)), CircuitId::ZERO)
.unwrap();
let evap = sys
.add_component_to_circuit(Box::new(Mock::new(3, 0)), CircuitId::ZERO)
.unwrap();
let drum = sys
.add_component_to_circuit(Box::new(Mock::new(5, 0)), CircuitId::ZERO)
.unwrap();
let eco = sys
.add_component_to_circuit(Box::new(Mock::new(3, 0)), CircuitId::ZERO)
.unwrap();
// Connect: merger → exv → evap → drum → eco → comp0 (suction)
sys.add_edge(merger, exv).unwrap();
sys.add_edge(exv, evap).unwrap();
sys.add_edge(evap, drum).unwrap();
sys.add_edge(drum, eco).unwrap();
sys.add_edge(eco, comp0_node).unwrap();
sys.add_edge(comp0_node, merger).unwrap(); // closes loop via compressor
// ── Circuit 1 (second independent compressor circuit) ─────────────────────
let comp1_suc = make_port("R134a", 3.2, 400.0);
let comp1_dis = make_port("R134a", 12.8, 440.0);
let comp1_eco = make_port("R134a", 6.4, 260.0);
let comp1 = ScrewEconomizerCompressor::new(
make_screw_curves(),
"R134a",
50.0,
0.92,
comp1_suc,
comp1_dis,
comp1_eco,
)
.unwrap();
let comp1_node = sys
.add_component_to_circuit(Box::new(comp1), CircuitId(1))
.expect("add comp1");
// 2 coils for circuit 1
for i in 2..4 {
let coil = MchxCondenserCoil::for_35c_ambient(15_000.0, i);
let coil_node = sys
.add_component_to_circuit(Box::new(coil), CircuitId(1))
.expect("add coil");
sys.add_edge(comp1_node, coil_node)
.expect("comp1→coil edge");
}
let merger1 = sys
.add_component_to_circuit(Box::new(Mock::new(2, 1)), CircuitId(1))
.unwrap();
let exv1 = sys
.add_component_to_circuit(Box::new(Mock::new(2, 1)), CircuitId(1))
.unwrap();
let evap1 = sys
.add_component_to_circuit(Box::new(Mock::new(3, 1)), CircuitId(1))
.unwrap();
sys.add_edge(merger1, exv1).unwrap();
sys.add_edge(exv1, evap1).unwrap();
sys.add_edge(evap1, comp1_node).unwrap();
sys.add_edge(comp1_node, merger1).unwrap();
// ── Assert topology ───────────────────────────────────────────────────────
assert_eq!(sys.circuit_count(), 2, "should have exactly 2 circuits");
// Circuit 0: comp + 2 coils + merger + exv + evap + drum + eco = 9 nodes
assert!(
sys.circuit_nodes(CircuitId::ZERO).count() >= 8,
"circuit 0 should have ≥8 nodes"
);
// Circuit 1: comp + 2 coils + merger + exv + evap = 6 nodes
assert!(
sys.circuit_nodes(CircuitId(1)).count() >= 5,
"circuit 1 should have ≥5 nodes"
);
// Finalize should succeed
let result = sys.finalize();
assert!(
result.is_ok(),
"System finalize should succeed: {:?}",
result.err()
);
println!(
"2-circuit chiller topology: {} nodes in circuit 0, {} in circuit 1",
sys.circuit_nodes(CircuitId::ZERO).count(),
sys.circuit_nodes(CircuitId(1)).count()
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 6: Fan anti-override control logic
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_fan_anti_override_speed_reduction() {
// Simulate anti-override: when condensing pressure > limit,
// reduce fan speed gradually until pressure stabilises.
//
// This test validates the MCHX UA response to fan speed changes,
// which is the physical mechanism behind anti-override control.
let ua_nominal = 15_000.0; // W/K per coil
let mut coil = MchxCondenserCoil::for_35c_ambient(ua_nominal, 0);
// Start at 100% fan speed
assert!((coil.fan_speed_ratio() - 1.0).abs() < 1e-10);
let ua_100 = coil.ua_effective();
// Reduce to 80% (typical anti-override step)
coil.set_fan_speed_ratio(0.80);
let ua_80 = coil.ua_effective();
// Reduce to 60%
coil.set_fan_speed_ratio(0.60);
let ua_60 = coil.ua_effective();
// UA should decrease monotonically with fan speed
assert!(ua_100 > ua_80, "UA should decrease from 100% to 80%");
assert!(ua_80 > ua_60, "UA should decrease from 80% to 60%");
// Reduction should follow power law: UA ∝ speed^0.5
let ratio_80 = ua_80 / ua_100;
let ratio_60 = ua_60 / ua_100;
assert!(
(ratio_80 - 0.80_f64.sqrt()).abs() < 0.03,
"80% speed ratio: expected {:.3}, got {:.3}",
0.80_f64.sqrt(),
ratio_80
);
assert!(
(ratio_60 - 0.60_f64.sqrt()).abs() < 0.03,
"60% speed ratio: expected {:.3}, got {:.3}",
0.60_f64.sqrt(),
ratio_60
);
println!(
"Anti-override UA: 100%={:.0}, 80%={:.0}, 60%={:.0} W/K",
ua_100, ua_80, ua_60
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 7: Screw compressor off state — zero mass flow
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_screw_compressor_off_state_zero_flow() {
let suc = make_port("R134a", 3.2, 400.0);
let dis = make_port("R134a", 12.8, 440.0);
let eco = make_port("R134a", 6.4, 260.0);
let mut comp =
ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
.unwrap();
comp.set_state(OperationalState::Off).unwrap();
let state = vec![0.0; 5];
let mut residuals = vec![0.0; 5];
comp.compute_residuals(&state, &mut residuals).unwrap();
// In Off state: r[0]=ṁ_suc=0, r[1]=ṁ_eco=0, r[4]=W=0
assert!(
residuals[0].abs() < 1e-12,
"Off: ṁ_suc residual should be 0"
);
assert!(
residuals[1].abs() < 1e-12,
"Off: ṁ_eco residual should be 0"
);
assert!(residuals[4].abs() < 1e-12, "Off: W residual should be 0");
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 8: 4-coil bank total capacity estimate
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_four_coil_bank_total_ua() {
// Design: 4 coils, total UA = 60 kW/K, T_air=35°C
// Expected: total condensing capacity ≈ 60 kW/K × (T_cond - T_air) ≈ 60 × 15 = 900 kW
// (for T_cond = 50°C, ΔT_lm ≈ 15 K — rough estimate)
let coils: Vec<MchxCondenserCoil> = (0..4)
.map(|i| MchxCondenserCoil::for_35c_ambient(15_000.0, i))
.collect();
let total_ua: f64 = coils.iter().map(|c| c.ua_effective()).sum();
println!(
"4-coil bank total UA: {:.0} W/K = {:.1} kW/K",
total_ua,
total_ua / 1000.0
);
// Should be close to 60 kW/K (4 × 15 kW/K, with density ≈ 1 at design point)
assert!(
(total_ua - 60_000.0).abs() < 3_000.0,
"Total UA should be ≈ 60 kW/K, got {:.1} kW/K",
total_ua / 1000.0
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 9: Cross-circuit connection rejected
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_cross_circuit_connection_rejected() {
let mut sys = System::new();
let n0 = sys
.add_component_to_circuit(Box::new(Mock::new(2, 0)), CircuitId::ZERO)
.unwrap();
let n1 = sys
.add_component_to_circuit(Box::new(Mock::new(2, 1)), CircuitId(1))
.unwrap();
let result = sys.add_edge(n0, n1);
assert!(
matches!(result, Err(TopologyError::CrossCircuitConnection { .. })),
"Cross-circuit edge should be rejected"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test 10: Screw compressor energy balance sanity check
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_screw_energy_balance() {
let suc = make_port("R134a", 3.2, 400.0);
let dis = make_port("R134a", 12.8, 440.0);
let eco = make_port("R134a", 6.4, 260.0);
let comp =
ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
.unwrap();
// At this operating point:
// h_suc=400 kJ/kg, h_dis=440 kJ/kg, h_eco=260 kJ/kg
// ṁ_suc=1.2 kg/s, ṁ_eco=0.144 kg/s, ṁ_total=1.344 kg/s
// Energy in = 1.2×400000 + 0.144×260000 + W/0.92
// Energy out = 1.344×440000
// W = (1.344×440000 - 1.2×400000 - 0.144×260000) × 0.92
let m_suc = 1.2_f64;
let m_eco = 0.144_f64;
let m_total = m_suc + m_eco;
let h_suc = 400_000.0_f64;
let h_dis = 440_000.0_f64;
let h_eco = 260_000.0_f64;
let eta_mech = 0.92_f64;
let w_expected = (m_total * h_dis - m_suc * h_suc - m_eco * h_eco) * eta_mech;
println!(
"Expected shaft power: {:.0} W = {:.1} kW",
w_expected,
w_expected / 1000.0
);
// Verify that this W closes the energy balance (residual[2] ≈ 0)
let state = vec![m_suc, m_eco, h_suc, h_dis, w_expected];
let mut residuals = vec![0.0; 5];
comp.compute_residuals(&state, &mut residuals).unwrap();
// residual[2] = energy_in - energy_out
// = (ṁ_suc×h_suc + ṁ_eco×h_eco + W/η) - ṁ_total×h_dis
// Should be exactly 0 if W was computed correctly
println!("Energy balance residual: {:.4} J/s", residuals[2]);
assert!(
residuals[2].abs() < 1.0,
"Energy balance residual should be < 1 W, got {:.4}",
residuals[2]
);
}