chore: remove deprecated flow_boundary and update docs to match new architecture
This commit is contained in:
625
crates/solver/tests/chiller_air_glycol_integration.rs
Normal file
625
crates/solver/tests/chiller_air_glycol_integration.rs
Normal 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 25–60 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]
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
208
crates/solver/tests/real_cycle_inverse_integration.rs
Normal file
208
crates/solver/tests/real_cycle_inverse_integration.rs
Normal 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!");
|
||||
}
|
||||
479
crates/solver/tests/verbose_mode.rs
Normal file
479
crates/solver/tests/verbose_mode.rs
Normal 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"));
|
||||
}
|
||||
Reference in New Issue
Block a user