Files
Entropyk/crates/entropyk/tests/simulation_result.rs

519 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}