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]
);
}

View File

@@ -292,10 +292,11 @@ fn test_fallback_config_customization() {
fallback_enabled: true,
return_to_newton_threshold: 5e-4,
max_fallback_switches: 3,
..Default::default()
};
let solver = FallbackSolver::new(config.clone());
assert_eq!(solver.config, config);
assert_eq!(solver.config.fallback_enabled, config.fallback_enabled);
assert_eq!(solver.config.return_to_newton_threshold, 5e-4);
assert_eq!(solver.config.max_fallback_switches, 3);
}

View File

@@ -0,0 +1,208 @@
use entropyk_components::port::{Connected, FluidId, Port};
use entropyk_components::state_machine::CircuitId;
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, MchxCondenserCoil, Polynomial2D,
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, StateSlice,
};
use entropyk_core::{Enthalpy, MassFlow, Power, Pressure};
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId};
use entropyk_solver::system::System;
type CP = Port<Connected>;
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
}
fn make_screw_curves() -> ScrewPerformanceCurves {
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,
)
}
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]
fn test_real_cycle_inverse_control_integration() {
let mut sys = System::new();
// 1. Create components
let comp_suc = make_port("R134a", 3.2, 400.0);
let comp_dis = make_port("R134a", 12.8, 440.0);
let comp_eco = make_port("R134a", 6.4, 260.0);
let comp = ScrewEconomizerCompressor::new(
make_screw_curves(),
"R134a",
50.0,
0.92,
comp_suc,
comp_dis,
comp_eco,
).unwrap();
let coil = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
let exv = Mock::new(2, 0); // Expansion Valve
let evap = Mock::new(2, 0); // Evaporator
// 2. Add components to system
let comp_node = sys.add_component_to_circuit(Box::new(comp), CircuitId::ZERO).unwrap();
let coil_node = sys.add_component_to_circuit(Box::new(coil), CircuitId::ZERO).unwrap();
let exv_node = sys.add_component_to_circuit(Box::new(exv), CircuitId::ZERO).unwrap();
let evap_node = sys.add_component_to_circuit(Box::new(evap), CircuitId::ZERO).unwrap();
sys.register_component_name("compressor", comp_node);
sys.register_component_name("condenser", coil_node);
sys.register_component_name("expansion_valve", exv_node);
sys.register_component_name("evaporator", evap_node);
// 3. Connect components
sys.add_edge(comp_node, coil_node).unwrap();
sys.add_edge(coil_node, exv_node).unwrap();
sys.add_edge(exv_node, evap_node).unwrap();
sys.add_edge(evap_node, comp_node).unwrap();
// 4. Add Inverse Control Elements (Constraints and BoundedVariables)
// Constraint 1: Superheat at evaporator = 5K
sys.add_constraint(Constraint::new(
ConstraintId::new("superheat_control"),
ComponentOutput::Superheat {
component_id: "evaporator".to_string(),
},
5.0,
)).unwrap();
// Constraint 2: Capacity at compressor = 50000 W
sys.add_constraint(Constraint::new(
ConstraintId::new("capacity_control"),
ComponentOutput::Capacity {
component_id: "compressor".to_string(),
},
50000.0,
)).unwrap();
// Control 1: Valve Opening
let bv_valve = BoundedVariable::with_component(
BoundedVariableId::new("valve_opening"),
"expansion_valve",
0.5,
0.0,
1.0,
).unwrap();
sys.add_bounded_variable(bv_valve).unwrap();
// Control 2: Compressor Speed
let bv_comp = BoundedVariable::with_component(
BoundedVariableId::new("compressor_speed"),
"compressor",
0.7,
0.3,
1.0,
).unwrap();
sys.add_bounded_variable(bv_comp).unwrap();
// Link constraints to controls
sys.link_constraint_to_control(
&ConstraintId::new("superheat_control"),
&BoundedVariableId::new("valve_opening"),
).unwrap();
sys.link_constraint_to_control(
&ConstraintId::new("capacity_control"),
&BoundedVariableId::new("compressor_speed"),
).unwrap();
// 5. Finalize the system
sys.finalize().unwrap();
// Verify system state size and degrees of freedom
assert_eq!(sys.constraint_count(), 2);
assert_eq!(sys.bounded_variable_count(), 2);
// Validate DoF
sys.validate_inverse_control_dof().expect("System should be balanced for inverse control");
// Evaluate the total system residual and jacobian capability
let state_len = sys.state_vector_len();
assert!(state_len > 0, "System should have state variables");
// Create mock state and control values
let state = vec![400_000.0; state_len];
let control_values = vec![0.5, 0.7]; // Valve, Compressor speeds
let mut residuals = vec![0.0; state_len + 2];
// Evaluate constraints
let measured = sys.extract_constraint_values_with_controls(&state, &control_values);
let count = sys.compute_constraint_residuals(&state, &mut residuals[state_len..], &measured);
assert_eq!(count, 2, "Should have computed 2 constraint residuals");
// Evaluate jacobian
let jacobian_entries = sys.compute_inverse_control_jacobian(&state, state_len, &control_values);
assert!(!jacobian_entries.is_empty(), "Jacobian should have entries for inverse control");
println!("System integration with inverse control successful!");
}

View File

@@ -0,0 +1,479 @@
//! Tests for verbose mode diagnostics (Story 7.4).
//!
//! Covers:
//! - VerboseConfig default behavior
//! - IterationDiagnostics collection
//! - Jacobian condition number estimation
//! - ConvergenceDiagnostics summary
use entropyk_solver::jacobian::JacobianMatrix;
use entropyk_solver::{
ConvergenceDiagnostics, IterationDiagnostics, SolverSwitchEvent, SolverType, SwitchReason,
VerboseConfig, VerboseOutputFormat,
};
// =============================================================================
// Task 1: VerboseConfig Tests
// =============================================================================
#[test]
fn test_verbose_config_default_is_disabled() {
let config = VerboseConfig::default();
// All features should be disabled by default for backward compatibility
assert!(!config.enabled, "enabled should be false by default");
assert!(!config.log_residuals, "log_residuals should be false by default");
assert!(
!config.log_jacobian_condition,
"log_jacobian_condition should be false by default"
);
assert!(
!config.log_solver_switches,
"log_solver_switches should be false by default"
);
assert!(
!config.dump_final_state,
"dump_final_state should be false by default"
);
assert_eq!(
config.output_format,
VerboseOutputFormat::Both,
"output_format should default to Both"
);
}
#[test]
fn test_verbose_config_all_enabled() {
let config = VerboseConfig::all_enabled();
assert!(config.enabled, "enabled should be true");
assert!(config.log_residuals, "log_residuals should be true");
assert!(config.log_jacobian_condition, "log_jacobian_condition should be true");
assert!(config.log_solver_switches, "log_solver_switches should be true");
assert!(config.dump_final_state, "dump_final_state should be true");
}
#[test]
fn test_verbose_config_is_any_enabled() {
// All disabled
let config = VerboseConfig::default();
assert!(!config.is_any_enabled(), "no features should be enabled");
// Master switch off but features on
let config = VerboseConfig {
enabled: false,
log_residuals: true,
..Default::default()
};
assert!(
!config.is_any_enabled(),
"should be false when master switch is off"
);
// Master switch on but all features off
let config = VerboseConfig {
enabled: true,
..Default::default()
};
assert!(
!config.is_any_enabled(),
"should be false when no features are enabled"
);
// Master switch on and one feature on
let config = VerboseConfig {
enabled: true,
log_residuals: true,
..Default::default()
};
assert!(config.is_any_enabled(), "should be true when one feature is enabled");
}
// =============================================================================
// Task 2: IterationDiagnostics Tests
// =============================================================================
#[test]
fn test_iteration_diagnostics_creation() {
let diag = IterationDiagnostics {
iteration: 5,
residual_norm: 1e-4,
delta_norm: 1e-5,
alpha: Some(0.5),
jacobian_frozen: true,
jacobian_condition: Some(1e3),
};
assert_eq!(diag.iteration, 5);
assert!((diag.residual_norm - 1e-4).abs() < 1e-15);
assert!((diag.delta_norm - 1e-5).abs() < 1e-15);
assert_eq!(diag.alpha, Some(0.5));
assert!(diag.jacobian_frozen);
assert_eq!(diag.jacobian_condition, Some(1e3));
}
#[test]
fn test_iteration_diagnostics_without_alpha() {
// Sequential Substitution doesn't use line search
let diag = IterationDiagnostics {
iteration: 3,
residual_norm: 1e-3,
delta_norm: 1e-4,
alpha: None,
jacobian_frozen: false,
jacobian_condition: None,
};
assert_eq!(diag.alpha, None);
assert!(!diag.jacobian_frozen);
assert_eq!(diag.jacobian_condition, None);
}
// =============================================================================
// Task 3: Jacobian Condition Number Tests
// =============================================================================
#[test]
fn test_jacobian_condition_number_well_conditioned() {
// Identity-like matrix (well-conditioned)
let entries = vec![(0, 0, 2.0), (1, 1, 1.0)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let cond = j.estimate_condition_number().expect("should compute condition number");
// Condition number of diagonal matrix is max/min diagonal entry
assert!(
cond < 10.0,
"Expected low condition number for well-conditioned matrix, got {}",
cond
);
}
#[test]
fn test_jacobian_condition_number_ill_conditioned() {
// Nearly singular matrix
let entries = vec![
(0, 0, 1.0),
(0, 1, 1.0),
(1, 0, 1.0),
(1, 1, 1.0000001),
];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let cond = j.estimate_condition_number().expect("should compute condition number");
assert!(
cond > 1e6,
"Expected high condition number for ill-conditioned matrix, got {}",
cond
);
}
#[test]
fn test_jacobian_condition_number_identity() {
// Identity matrix has condition number 1
let entries = vec![(0, 0, 1.0), (1, 1, 1.0), (2, 2, 1.0)];
let j = JacobianMatrix::from_builder(&entries, 3, 3);
let cond = j.estimate_condition_number().expect("should compute condition number");
assert!(
(cond - 1.0).abs() < 1e-10,
"Expected condition number 1 for identity matrix, got {}",
cond
);
}
#[test]
fn test_jacobian_condition_number_empty_matrix() {
// Empty matrix (0x0)
let j = JacobianMatrix::zeros(0, 0);
let cond = j.estimate_condition_number();
assert!(
cond.is_none(),
"Expected None for empty matrix"
);
}
// =============================================================================
// Task 4: SolverSwitchEvent Tests
// =============================================================================
#[test]
fn test_solver_switch_event_creation() {
let event = SolverSwitchEvent {
from_solver: SolverType::NewtonRaphson,
to_solver: SolverType::SequentialSubstitution,
reason: SwitchReason::Divergence,
iteration: 10,
residual_at_switch: 1e6,
};
assert_eq!(event.from_solver, SolverType::NewtonRaphson);
assert_eq!(event.to_solver, SolverType::SequentialSubstitution);
assert_eq!(event.reason, SwitchReason::Divergence);
assert_eq!(event.iteration, 10);
assert!((event.residual_at_switch - 1e6).abs() < 1e-6);
}
#[test]
fn test_solver_type_display() {
assert_eq!(
format!("{}", SolverType::NewtonRaphson),
"Newton-Raphson"
);
assert_eq!(
format!("{}", SolverType::SequentialSubstitution),
"Sequential Substitution"
);
}
#[test]
fn test_switch_reason_display() {
assert_eq!(format!("{}", SwitchReason::Divergence), "divergence detected");
assert_eq!(
format!("{}", SwitchReason::SlowConvergence),
"slow convergence"
);
assert_eq!(format!("{}", SwitchReason::UserRequested), "user requested");
assert_eq!(
format!("{}", SwitchReason::ReturnToNewton),
"returning to Newton after stabilization"
);
}
// =============================================================================
// Task 5: ConvergenceDiagnostics Tests
// =============================================================================
#[test]
fn test_convergence_diagnostics_default() {
let diag = ConvergenceDiagnostics::default();
assert_eq!(diag.iterations, 0);
assert!((diag.final_residual - 0.0).abs() < 1e-15);
assert!(!diag.converged);
assert!(diag.iteration_history.is_empty());
assert!(diag.solver_switches.is_empty());
assert!(diag.final_state.is_none());
assert!(diag.jacobian_condition_final.is_none());
assert_eq!(diag.timing_ms, 0);
assert!(diag.final_solver.is_none());
}
#[test]
fn test_convergence_diagnostics_with_capacity() {
let diag = ConvergenceDiagnostics::with_capacity(100);
// Capacity should be pre-allocated
assert!(diag.iteration_history.capacity() >= 100);
assert!(diag.iteration_history.is_empty());
}
#[test]
fn test_convergence_diagnostics_push_iteration() {
let mut diag = ConvergenceDiagnostics::new();
diag.push_iteration(IterationDiagnostics {
iteration: 0,
residual_norm: 1.0,
delta_norm: 0.0,
alpha: None,
jacobian_frozen: false,
jacobian_condition: None,
});
diag.push_iteration(IterationDiagnostics {
iteration: 1,
residual_norm: 0.5,
delta_norm: 0.5,
alpha: Some(1.0),
jacobian_frozen: false,
jacobian_condition: Some(100.0),
});
assert_eq!(diag.iteration_history.len(), 2);
assert_eq!(diag.iteration_history[0].iteration, 0);
assert_eq!(diag.iteration_history[1].iteration, 1);
}
#[test]
fn test_convergence_diagnostics_push_switch() {
let mut diag = ConvergenceDiagnostics::new();
diag.push_switch(SolverSwitchEvent {
from_solver: SolverType::NewtonRaphson,
to_solver: SolverType::SequentialSubstitution,
reason: SwitchReason::Divergence,
iteration: 5,
residual_at_switch: 1e10,
});
assert_eq!(diag.solver_switches.len(), 1);
assert_eq!(diag.solver_switches[0].iteration, 5);
}
#[test]
fn test_convergence_diagnostics_summary_converged() {
let mut diag = ConvergenceDiagnostics::new();
diag.iterations = 25;
diag.final_residual = 1e-8;
diag.best_residual = 1e-8;
diag.converged = true;
diag.timing_ms = 150;
diag.final_solver = Some(SolverType::NewtonRaphson);
diag.jacobian_condition_final = Some(1e4);
let summary = diag.summary();
assert!(summary.contains("Converged: YES"));
assert!(summary.contains("Iterations: 25"));
// The format uses {:.3e} which produces like "1.000e-08"
assert!(summary.contains("Final Residual:"));
assert!(summary.contains("Solver Switches: 0"));
assert!(summary.contains("Timing: 150 ms"));
assert!(summary.contains("Jacobian Condition:"));
assert!(summary.contains("Final Solver: Newton-Raphson"));
// Should NOT contain ill-conditioned warning
assert!(!summary.contains("WARNING"));
}
#[test]
fn test_convergence_diagnostics_summary_ill_conditioned() {
let mut diag = ConvergenceDiagnostics::new();
diag.iterations = 100;
diag.final_residual = 1e-2;
diag.best_residual = 1e-3;
diag.converged = false;
diag.timing_ms = 500;
diag.jacobian_condition_final = Some(1e12);
let summary = diag.summary();
assert!(summary.contains("Converged: NO"));
assert!(summary.contains("WARNING: ill-conditioned"));
}
#[test]
fn test_convergence_diagnostics_summary_with_switches() {
let mut diag = ConvergenceDiagnostics::new();
diag.iterations = 50;
diag.final_residual = 1e-6;
diag.best_residual = 1e-6;
diag.converged = true;
diag.timing_ms = 200;
diag.push_switch(SolverSwitchEvent {
from_solver: SolverType::NewtonRaphson,
to_solver: SolverType::SequentialSubstitution,
reason: SwitchReason::Divergence,
iteration: 10,
residual_at_switch: 1e10,
});
let summary = diag.summary();
assert!(summary.contains("Solver Switches: 1"));
}
// =============================================================================
// VerboseOutputFormat Tests
// =============================================================================
#[test]
fn test_verbose_output_format_default() {
let format = VerboseOutputFormat::default();
assert_eq!(format, VerboseOutputFormat::Both);
}
// =============================================================================
// JSON Serialization Tests (Story 7.4 - AC4)
// =============================================================================
#[test]
fn test_convergence_diagnostics_json_serialization() {
let mut diag = ConvergenceDiagnostics::new();
diag.iterations = 50;
diag.final_residual = 1e-6;
diag.best_residual = 1e-7;
diag.converged = true;
diag.timing_ms = 250;
diag.final_solver = Some(SolverType::NewtonRaphson);
diag.jacobian_condition_final = Some(1e5);
diag.push_iteration(IterationDiagnostics {
iteration: 1,
residual_norm: 1.0,
delta_norm: 0.5,
alpha: Some(1.0),
jacobian_frozen: false,
jacobian_condition: Some(100.0),
});
diag.push_switch(SolverSwitchEvent {
from_solver: SolverType::NewtonRaphson,
to_solver: SolverType::SequentialSubstitution,
reason: SwitchReason::Divergence,
iteration: 10,
residual_at_switch: 1e6,
});
// Test JSON serialization
let json = serde_json::to_string(&diag).expect("Should serialize to JSON");
assert!(json.contains("\"iterations\":50"));
assert!(json.contains("\"converged\":true"));
assert!(json.contains("\"NewtonRaphson\""));
assert!(json.contains("\"Divergence\""));
}
#[test]
fn test_convergence_diagnostics_round_trip() {
let mut diag = ConvergenceDiagnostics::new();
diag.iterations = 25;
diag.final_residual = 1e-8;
diag.converged = true;
diag.timing_ms = 100;
diag.final_solver = Some(SolverType::SequentialSubstitution);
// Serialize to JSON
let json = serde_json::to_string(&diag).expect("Should serialize");
// Deserialize back
let restored: ConvergenceDiagnostics =
serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(restored.iterations, 25);
assert!((restored.final_residual - 1e-8).abs() < 1e-20);
assert!(restored.converged);
assert_eq!(restored.timing_ms, 100);
assert_eq!(restored.final_solver, Some(SolverType::SequentialSubstitution));
}
#[test]
fn test_dump_diagnostics_json_format() {
let mut diag = ConvergenceDiagnostics::new();
diag.iterations = 10;
diag.final_residual = 1e-4;
diag.converged = false;
let json_output = diag.dump_diagnostics(VerboseOutputFormat::Json);
assert!(json_output.starts_with('{'));
// to_string_pretty adds spaces after colons
assert!(json_output.contains("\"iterations\"") && json_output.contains("10"));
assert!(json_output.contains("\"converged\"") && json_output.contains("false"));
}
#[test]
fn test_dump_diagnostics_log_format() {
let mut diag = ConvergenceDiagnostics::new();
diag.iterations = 10;
diag.final_residual = 1e-4;
diag.converged = false;
let log_output = diag.dump_diagnostics(VerboseOutputFormat::Log);
assert!(log_output.contains("Convergence Diagnostics Summary"));
assert!(log_output.contains("Converged: NO"));
assert!(log_output.contains("Iterations: 10"));
}