519 lines
20 KiB
Rust
519 lines
20 KiB
Rust
//! Integration tests for structured simulation result extraction.
|
||
|
||
use entropyk::{
|
||
extract_simulation_result, SimulationOutcome, SimulationResult, SystemBuilder,
|
||
};
|
||
use entropyk_components::expansion_valve::ExpansionValve;
|
||
use entropyk_components::heat_exchanger::{Condenser, Evaporator};
|
||
use entropyk_components::port::{Disconnected, FluidId, Port};
|
||
use entropyk_components::{
|
||
Component, ComponentError, ConnectedPort, JacobianBuilder, MchxCondenserCoil, Polynomial2D,
|
||
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, StateSlice,
|
||
};
|
||
use entropyk_core::{Enthalpy, MassFlow, Power, Pressure};
|
||
use entropyk_solver::{ConvergedState, ConvergenceStatus, SimulationMetadata};
|
||
|
||
use approx::assert_relative_eq;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Helpers for real components
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
fn make_disconnected_port(fluid: &str, p_bar: f64, h_kj_kg: f64) -> Port<Disconnected> {
|
||
Port::new(
|
||
FluidId::new(fluid),
|
||
Pressure::from_bar(p_bar),
|
||
Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
|
||
)
|
||
}
|
||
|
||
fn make_connected_port(fluid: &str, p_bar: f64, h_kj_kg: f64) -> ConnectedPort {
|
||
let a = make_disconnected_port(fluid, p_bar, h_kj_kg);
|
||
let b = make_disconnected_port(fluid, p_bar, h_kj_kg);
|
||
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,
|
||
)
|
||
}
|
||
|
||
/// Build a real R134a cycle with ScrewEconomizerCompressor + MchxCondenserCoil
|
||
/// + ExpansionValve + Evaporator. Uses manually crafted ConvergedState from
|
||
/// NIST R134a reference data (T_evap=0°C, T_cond=40°C, SH=5K, SC=3K).
|
||
fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
|
||
use entropyk_solver::CircuitId;
|
||
|
||
let mut sys = entropyk_solver::System::new();
|
||
|
||
// --- Compressor (screw with economizer) ---
|
||
let suc = make_connected_port("R134a", 2.93, 405.0);
|
||
let dis = make_connected_port("R134a", 10.17, 440.0);
|
||
let eco = make_connected_port("R134a", 5.5, 250.0);
|
||
let comp = ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
|
||
.expect("compressor");
|
||
|
||
// --- Condenser (air-cooled coil at 35°C ambient) ---
|
||
let condenser = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||
|
||
// --- Expansion valve (fully open) ---
|
||
let exv_in = make_disconnected_port("R134a", 10.17, 253.4);
|
||
let exv_out = make_disconnected_port("R134a", 2.93, 253.4);
|
||
let exv_disconnected = ExpansionValve::new(exv_in, exv_out, Some(1.0)).expect("exv disconnected");
|
||
let exv = exv_disconnected
|
||
.connect(make_disconnected_port("R134a", 10.17, 253.4), make_disconnected_port("R134a", 2.93, 253.4))
|
||
.expect("exv connect");
|
||
|
||
// --- Evaporator (BPHE, T_sat=278.15K, SH=5K) ---
|
||
let evaporator = Evaporator::with_superheat(8000.0, 278.15, 5.0);
|
||
|
||
// Add to circuit 0
|
||
let n_comp = sys.add_component_to_circuit(Box::new(comp), CircuitId::ZERO).unwrap();
|
||
let n_cond = sys.add_component_to_circuit(Box::new(condenser), CircuitId::ZERO).unwrap();
|
||
let n_exv = sys.add_component_to_circuit(Box::new(exv), CircuitId::ZERO).unwrap();
|
||
let n_evap = sys.add_component_to_circuit(Box::new(evaporator), CircuitId::ZERO).unwrap();
|
||
|
||
// Register names for extract_simulation_result
|
||
sys.register_component_name("compressor", n_comp);
|
||
sys.register_component_name("condenser", n_cond);
|
||
sys.register_component_name("expansion_valve", n_exv);
|
||
sys.register_component_name("evaporator", n_evap);
|
||
|
||
// Connect: comp → cond → exv → evap → comp
|
||
sys.add_edge(n_comp, n_cond).unwrap();
|
||
sys.add_edge(n_cond, n_exv).unwrap();
|
||
sys.add_edge(n_exv, n_evap).unwrap();
|
||
sys.add_edge(n_evap, n_comp).unwrap();
|
||
|
||
sys.finalize().expect("system finalize");
|
||
|
||
// ConvergedState from NIST R134a reference data:
|
||
// T_evap_sat = 0°C → P_sat ≈ 292800 Pa (2.928 bar)
|
||
// T_cond_sat = 40°C → P_sat ≈ 1017000 Pa (10.17 bar)
|
||
// h_g(0°C) ≈ 398600 J/kg, h_f(40°C) ≈ 256400 J/kg
|
||
// With SH=5K and SC=3K
|
||
let state = vec![
|
||
1017000.0, 440000.0, // edge 0: comp→cond (discharge, superheated ~440 kJ/kg)
|
||
1000000.0, 250000.0, // edge 1: cond→exv (subcooled liquid ~250 kJ/kg, ~3K SC)
|
||
292800.0, 250000.0, // edge 2: exv→evap (isenthalpic expansion, same h)
|
||
285000.0, 405000.0, // edge 3: evap→comp (superheated ~5K above sat)
|
||
];
|
||
|
||
let converged = ConvergedState::new(
|
||
state,
|
||
23,
|
||
5.1e-8,
|
||
ConvergenceStatus::Converged,
|
||
SimulationMetadata::new("r134a_chiller_nist_ref".to_string()),
|
||
);
|
||
|
||
(sys, converged)
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Mock components for testing
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Mock component that reports energy transfers (simulates a compressor).
|
||
struct MockCompressor;
|
||
|
||
impl Component for MockCompressor {
|
||
fn compute_residuals(
|
||
&self,
|
||
_state: &[f64],
|
||
_residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &[f64],
|
||
_jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
fn n_equations(&self) -> usize {
|
||
2
|
||
}
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&[]
|
||
}
|
||
fn energy_transfers(&self, _state: &[f64]) -> Option<(Power, Power)> {
|
||
// Compressor: no heat exchange, consumes 3000W work
|
||
Some((Power::from_watts(0.0), Power::from_watts(3000.0)))
|
||
}
|
||
fn signature(&self) -> String {
|
||
"Compressor(eff=0.7)".to_string()
|
||
}
|
||
}
|
||
|
||
/// Mock component that absorbs heat (simulates an evaporator).
|
||
struct MockEvaporator;
|
||
|
||
impl Component for MockEvaporator {
|
||
fn compute_residuals(
|
||
&self,
|
||
_state: &[f64],
|
||
_residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &[f64],
|
||
_jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
fn n_equations(&self) -> usize {
|
||
2
|
||
}
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&[]
|
||
}
|
||
fn energy_transfers(&self, _state: &[f64]) -> Option<(Power, Power)> {
|
||
// Evaporator: absorbs 10000W of heat (Q > 0 = cooling)
|
||
Some((Power::from_watts(10000.0), Power::from_watts(0.0)))
|
||
}
|
||
fn signature(&self) -> String {
|
||
"Evaporator(UA=5.0kW/K)".to_string()
|
||
}
|
||
}
|
||
|
||
/// Mock component with no energy transfers (simulates a pipe).
|
||
struct MockPipe;
|
||
|
||
impl Component for MockPipe {
|
||
fn compute_residuals(
|
||
&self,
|
||
_state: &[f64],
|
||
_residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &[f64],
|
||
_jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
fn n_equations(&self) -> usize {
|
||
2
|
||
}
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&[]
|
||
}
|
||
fn signature(&self) -> String {
|
||
"Pipe(L=10m,D=0.02m)".to_string()
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Tests
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Helper: build a realistic 4-component vapor compression cycle with mock components.
|
||
fn build_realistic_cycle() -> (entropyk_solver::System, ConvergedState) {
|
||
let system = SystemBuilder::new()
|
||
.component("compressor", Box::new(MockCompressor))
|
||
.expect("add compressor")
|
||
.component("condenser", Box::new(MockPipe)) // no energy transfer
|
||
.expect("add condenser")
|
||
.component("expansion_valve", Box::new(MockPipe))
|
||
.expect("add expansion valve")
|
||
.component("evaporator", Box::new(MockEvaporator))
|
||
.expect("add evaporator")
|
||
.edge("compressor", "condenser")
|
||
.expect("edge comp->cond")
|
||
.edge("condenser", "expansion_valve")
|
||
.expect("edge cond->exv")
|
||
.edge("expansion_valve", "evaporator")
|
||
.expect("edge exv->evap")
|
||
.edge("evaporator", "compressor")
|
||
.expect("edge evap->comp")
|
||
.build()
|
||
.expect("build system");
|
||
|
||
// R410A-like state vector: 4 edges × 2 (P, h)
|
||
// Realistic values: high side ~24 bar, low side ~8 bar
|
||
let state = vec![
|
||
2400000.0, 440000.0, // edge 0: compressor → condenser (discharge, high P, superheated)
|
||
2350000.0, 280000.0, // edge 1: condenser → expansion (subcooled liquid)
|
||
800000.0, 260000.0, // edge 2: expansion → evaporator (two-phase, low P)
|
||
780000.0, 400000.0, // edge 3: evaporator → compressor (superheated vapor, low P)
|
||
];
|
||
|
||
let converged = ConvergedState::new(
|
||
state,
|
||
12,
|
||
2.3e-8,
|
||
ConvergenceStatus::Converged,
|
||
SimulationMetadata::new("r410a_chiller_35c_ambient".to_string()),
|
||
);
|
||
|
||
(system, converged)
|
||
}
|
||
|
||
/// Helper: build a 4-component system and create a fake ConvergedState.
|
||
fn build_test_system() -> (entropyk_solver::System, ConvergedState) {
|
||
let system = SystemBuilder::new()
|
||
.component("comp", Box::new(MockCompressor))
|
||
.expect("add comp")
|
||
.component("pipe1", Box::new(MockPipe))
|
||
.expect("add pipe1")
|
||
.component("evap", Box::new(MockEvaporator))
|
||
.expect("add evap")
|
||
.component("pipe2", Box::new(MockPipe))
|
||
.expect("add pipe2")
|
||
.edge("comp", "pipe1")
|
||
.expect("edge comp->pipe1")
|
||
.edge("pipe1", "evap")
|
||
.expect("edge pipe1->evap")
|
||
.edge("evap", "pipe2")
|
||
.expect("edge evap->pipe2")
|
||
.edge("pipe2", "comp")
|
||
.expect("edge pipe2->comp")
|
||
.build()
|
||
.expect("build system");
|
||
|
||
// Create a fake converged state with 4 edges = 8 state variables
|
||
// [P0, h0, P1, h1, P2, h2, P3, h3]
|
||
let state = vec![
|
||
500000.0, 450000.0, // edge 0: comp -> pipe1 (high pressure)
|
||
490000.0, 440000.0, // edge 1: pipe1 -> evap
|
||
200000.0, 250000.0, // edge 2: evap -> pipe2 (low pressure)
|
||
190000.0, 240000.0, // edge 3: pipe2 -> comp
|
||
];
|
||
|
||
let converged = ConvergedState::new(
|
||
state,
|
||
15,
|
||
1e-9,
|
||
ConvergenceStatus::Converged,
|
||
SimulationMetadata::new("test_input_hash".to_string()),
|
||
);
|
||
|
||
(system, converged)
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_simulation_result_basic() {
|
||
let (system, converged) = build_test_system();
|
||
let result = extract_simulation_result(&system, &converged);
|
||
|
||
// Status and convergence
|
||
assert_eq!(result.status, SimulationOutcome::Converged);
|
||
assert!(result.convergence.converged);
|
||
assert_eq!(result.convergence.iterations, 15);
|
||
assert_relative_eq!(result.convergence.final_residual, 1e-9);
|
||
|
||
// Components
|
||
assert_eq!(result.components.len(), 4);
|
||
|
||
// Edges
|
||
assert_eq!(result.edges.len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_per_component_results() {
|
||
let (system, converged) = build_test_system();
|
||
let result = extract_simulation_result(&system, &converged);
|
||
|
||
let comp = result
|
||
.components
|
||
.iter()
|
||
.find(|c| c.name == "comp")
|
||
.expect("comp not found");
|
||
assert_eq!(comp.component_type, "Compressor(eff=0.7)");
|
||
assert_eq!(comp.circuit, 0);
|
||
assert!(comp.energy.is_some());
|
||
let energy = comp.energy.as_ref().unwrap();
|
||
assert_relative_eq!(energy.work_w, 3000.0);
|
||
assert_relative_eq!(energy.heat_transfer_w, 0.0);
|
||
|
||
let evap = result
|
||
.components
|
||
.iter()
|
||
.find(|c| c.name == "evap")
|
||
.expect("evap not found");
|
||
assert_eq!(evap.component_type, "Evaporator(UA=5.0kW/K)");
|
||
let evap_energy = evap.energy.as_ref().unwrap();
|
||
assert_relative_eq!(evap_energy.heat_transfer_w, 10000.0);
|
||
assert_relative_eq!(evap_energy.work_w, 0.0);
|
||
|
||
// Pipe has no energy transfers
|
||
let pipe1 = result
|
||
.components
|
||
.iter()
|
||
.find(|c| c.name == "pipe1")
|
||
.expect("pipe1 not found");
|
||
assert!(pipe1.energy.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_per_edge_results() {
|
||
let (system, converged) = build_test_system();
|
||
let result = extract_simulation_result(&system, &converged);
|
||
|
||
// Edge 0: comp -> pipe1 (high pressure side)
|
||
let edge0 = result.edges.iter().find(|e| e.edge_id == 0).expect("edge 0");
|
||
assert_relative_eq!(edge0.pressure_pa, 500000.0);
|
||
assert_relative_eq!(edge0.enthalpy_j_kg, 450000.0);
|
||
assert_eq!(edge0.source.as_deref(), Some("comp"));
|
||
assert_eq!(edge0.target.as_deref(), Some("pipe1"));
|
||
|
||
// Edge 2: evap -> pipe2 (low pressure side)
|
||
let edge2 = result.edges.iter().find(|e| e.edge_id == 2).expect("edge 2");
|
||
assert_relative_eq!(edge2.pressure_pa, 200000.0);
|
||
assert_relative_eq!(edge2.enthalpy_j_kg, 250000.0);
|
||
assert_eq!(edge2.source.as_deref(), Some("evap"));
|
||
assert_eq!(edge2.target.as_deref(), Some("pipe2"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_system_summary() {
|
||
let (system, converged) = build_test_system();
|
||
let result = extract_simulation_result(&system, &converged);
|
||
|
||
// Evaporator absorbs 10000W (cooling), compressor uses 3000W
|
||
assert!(result.summary.total_cooling_capacity_w.is_some());
|
||
assert_relative_eq!(
|
||
result.summary.total_cooling_capacity_w.unwrap(),
|
||
10000.0
|
||
);
|
||
assert!(result.summary.total_compressor_power_w.is_some());
|
||
assert_relative_eq!(
|
||
result.summary.total_compressor_power_w.unwrap(),
|
||
3000.0
|
||
);
|
||
|
||
// COP_cooling = 10000 / 3000
|
||
assert!(result.summary.cop_cooling.is_some());
|
||
assert_relative_eq!(
|
||
result.summary.cop_cooling.unwrap(),
|
||
10000.0 / 3000.0,
|
||
epsilon = 1e-10
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_simulation_result_json_roundtrip() {
|
||
let (system, converged) = build_test_system();
|
||
let result = extract_simulation_result(&system, &converged);
|
||
|
||
let json = result.to_json().expect("to_json should succeed");
|
||
assert!(json.contains("\"status\": \"converged\""));
|
||
assert!(json.contains("\"iterations\": 15"));
|
||
assert!(json.contains("Compressor"));
|
||
assert!(json.contains("Evaporator"));
|
||
|
||
// Round-trip (compare structurally; floats via relative_eq)
|
||
let deserialized: SimulationResult =
|
||
serde_json::from_str(&json).expect("deserialize should succeed");
|
||
assert_eq!(result.status, deserialized.status);
|
||
assert_eq!(result.convergence.iterations, deserialized.convergence.iterations);
|
||
assert_relative_eq!(
|
||
result.convergence.final_residual,
|
||
deserialized.convergence.final_residual,
|
||
epsilon = 1e-15
|
||
);
|
||
assert_eq!(result.convergence.converged, deserialized.convergence.converged);
|
||
assert_eq!(result.convergence.status, deserialized.convergence.status);
|
||
assert_eq!(result.components.len(), deserialized.components.len());
|
||
assert_eq!(result.edges.len(), deserialized.edges.len());
|
||
// Verify key float fields survive round-trip
|
||
for (a, b) in result.edges.iter().zip(deserialized.edges.iter()) {
|
||
assert_relative_eq!(a.pressure_pa, b.pressure_pa, epsilon = 1e-5);
|
||
assert_relative_eq!(a.enthalpy_j_kg, b.enthalpy_j_kg, epsilon = 1e-5);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_inlet_outlet_from_edges() {
|
||
let (system, converged) = build_test_system();
|
||
let result = extract_simulation_result(&system, &converged);
|
||
|
||
// "comp" has: incoming edge 3 (pipe2->comp), outgoing edge 0 (comp->pipe1)
|
||
let comp = result
|
||
.components
|
||
.iter()
|
||
.find(|c| c.name == "comp")
|
||
.expect("comp");
|
||
|
||
// Inlet: edge 3 -> P=190000, h=240000
|
||
assert!(comp.inlet.is_some());
|
||
let inlet = comp.inlet.as_ref().unwrap();
|
||
assert_relative_eq!(inlet.pressure_pa, 190000.0);
|
||
assert_relative_eq!(inlet.enthalpy_j_kg, 240000.0);
|
||
|
||
// Outlet: edge 0 -> P=500000, h=450000
|
||
assert!(comp.outlet.is_some());
|
||
let outlet = comp.outlet.as_ref().unwrap();
|
||
assert_relative_eq!(outlet.pressure_pa, 500000.0);
|
||
assert_relative_eq!(outlet.enthalpy_j_kg, 450000.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_metadata_preserved() {
|
||
let (system, converged) = build_test_system();
|
||
let result = extract_simulation_result(&system, &converged);
|
||
|
||
assert_eq!(result.metadata.input_hash, "test_input_hash");
|
||
assert!(!result.metadata.solver_version.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_realistic_cycle_json_output() {
|
||
let (system, converged) = build_real_r134a_cycle();
|
||
let result = extract_simulation_result(&system, &converged);
|
||
let json = result.to_json().expect("to_json");
|
||
|
||
println!("\n{}", json);
|
||
|
||
// Basic structure checks
|
||
assert_eq!(result.status, SimulationOutcome::Converged);
|
||
assert!(result.convergence.converged);
|
||
assert_eq!(result.components.len(), 4);
|
||
assert_eq!(result.edges.len(), 4);
|
||
assert!(json.contains("\"compressor\""));
|
||
assert!(json.contains("\"evaporator\""));
|
||
assert!(json.contains("\"condenser\""));
|
||
assert!(json.contains("\"expansion_valve\""));
|
||
|
||
// Compressor should have real component type (not Mock)
|
||
let comp = result.components.iter().find(|c| c.name == "compressor").expect("comp");
|
||
assert!(comp.component_type.contains("Screw"), "expected ScrewEconomizer, got {}", comp.component_type);
|
||
|
||
// Condenser should be MchxCondenserCoil
|
||
let cond = result.components.iter().find(|c| c.name == "condenser").expect("cond");
|
||
assert!(cond.component_type.contains("Mchx"), "expected MchxCondenserCoil, got {}", cond.component_type);
|
||
|
||
// Expansion valve should have real type
|
||
let exv = result.components.iter().find(|c| c.name == "expansion_valve").expect("exv");
|
||
assert!(exv.component_type.contains("ExpansionValve"), "expected ExpansionValve, got {}", exv.component_type);
|
||
|
||
// Evaporator
|
||
let evap = result.components.iter().find(|c| c.name == "evaporator").expect("evap");
|
||
assert!(evap.component_type.contains("Evaporator"), "expected Evaporator, got {}", evap.component_type);
|
||
|
||
// Check edge pressures are from NIST data
|
||
let edge0 = result.edges.iter().find(|e| e.edge_id == 0).unwrap();
|
||
assert_relative_eq!(edge0.pressure_pa, 1017000.0);
|
||
assert_eq!(edge0.source.as_deref(), Some("compressor"));
|
||
assert_eq!(edge0.target.as_deref(), Some("condenser"));
|
||
|
||
let edge2 = result.edges.iter().find(|e| e.edge_id == 2).unwrap();
|
||
assert_relative_eq!(edge2.pressure_pa, 292800.0); // P_sat at 0°C (NIST)
|
||
assert_eq!(edge2.source.as_deref(), Some("expansion_valve"));
|
||
assert_eq!(edge2.target.as_deref(), Some("evaporator"));
|
||
|
||
println!("\n=== Component types ===");
|
||
for c in &result.components {
|
||
println!(" {} (circuit {}): {}", c.name, c.circuit, c.component_type);
|
||
}
|
||
}
|