Files
Entropyk/crates/solver/tests/serialization_test.rs

435 lines
17 KiB
Rust

//! Integration tests for JSON serialization/deserialization of systems
//!
//! Tests cover:
//! - Round-trip serialization (system → JSON → system)
//! - Topology preservation (nodes, edges, component types)
//! - Constraint and bounded variable preservation
//! - Thermal coupling preservation
//! - Version compatibility checks
//! - Backend validation
//! - File save/load round-trip
//! - Human-readable JSON format
use entropyk_components::{Compressor, FluidId, Port};
use entropyk_core::{CircuitId, Enthalpy, Pressure, ThermalConductance};
use entropyk_solver::{System, ThermalCoupling};
use serde_json::{json, Value};
/// Helper: create a minimal system with a single compressor component.
fn build_single_compressor_system() -> System {
let mut system = System::new();
let coefficients = entropyk_components::Ahri540Coefficients::new(
0.85, 2.5, 500.0, 1500.0, -2.5, 1.8, 600.0, 1600.0, -3.0, 2.0,
);
let port_suction = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port_discharge = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
);
let disconnected = Compressor::new(
coefficients,
port_suction,
port_discharge,
2900.0,
0.0001,
0.85,
)
.expect("Failed to create compressor");
let connected = disconnected
.connect(
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(400000.0),
),
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
),
)
.expect("Failed to connect compressor");
let node = system.add_component(Box::new(connected));
system.register_component_name("compressor", node);
system
}
/// Helper: create a system with two components and an edge between them,
/// plus a thermal coupling.
fn build_two_component_system() -> System {
let mut system = System::new();
let coefficients = entropyk_components::Ahri540Coefficients::new(
0.85, 2.5, 500.0, 1500.0, -2.5, 1.8, 600.0, 1600.0, -3.0, 2.0,
);
// Create compressor
let port_s = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port_d = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
);
let comp = Compressor::new(coefficients, port_s, port_d, 2900.0, 0.0001, 0.85)
.expect("create compressor")
.connect(
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(400000.0),
),
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
),
)
.expect("connect compressor");
let node_comp = system.add_component(Box::new(comp));
system.register_component_name("compressor", node_comp);
// Create a second compressor (acting as condenser proxy)
let coefficients2 = entropyk_components::Ahri540Coefficients::new(
0.9, 3.0, 600.0, 1400.0, -1.5, 2.0, 700.0, 1700.0, -2.0, 1.5,
);
let port_s2 = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
);
let port_d2 = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(8.0),
Enthalpy::from_joules_per_kg(420000.0),
);
let comp2 = Compressor::new(coefficients2, port_s2, port_d2, 2900.0, 0.00012, 0.88)
.expect("create comp2")
.connect(
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
),
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(8.0),
Enthalpy::from_joules_per_kg(420000.0),
),
)
.expect("connect comp2");
let node_comp2 = system.add_component(Box::new(comp2));
system.register_component_name("condenser", node_comp2);
// Add edge between them
system.add_edge(node_comp, node_comp2).expect("add edge");
// Add thermal coupling
let coupling = ThermalCoupling::new(
CircuitId(0),
CircuitId(0),
ThermalConductance::from_watts_per_kelvin(500.0),
);
let _ = system.add_thermal_coupling(coupling);
system
}
// ────────────────────────────────────────────────────────────────────────
// Test 1: Topology round-trip
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_topology_round_trip() {
let original = build_two_component_system();
let json_str = original.to_json_string().expect("Serialization failed");
let restored = System::from_json_string(&json_str).expect("Deserialization failed");
// Verify topology is identical
assert_eq!(
original.node_count(),
restored.node_count(),
"Node count mismatch"
);
assert_eq!(
original.edge_count(),
restored.edge_count(),
"Edge count mismatch"
);
assert_eq!(
original.thermal_coupling_count(),
restored.thermal_coupling_count(),
"Thermal coupling count mismatch"
);
// Verify component names are preserved (order-independent since deserialization sorts keys)
let mut original_names: Vec<&str> = original.registered_component_names().collect();
let mut restored_names: Vec<&str> = restored.registered_component_names().collect();
original_names.sort();
restored_names.sort();
assert_eq!(original_names, restored_names, "Component names mismatch");
// Verify component types via the JSON snapshot
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
let params = parsed.get("parameters").expect("parameters field");
assert!(params.get("compressor").is_some(), "compressor in params");
assert!(params.get("condenser").is_some(), "condenser in params");
}
// ────────────────────────────────────────────────────────────────────────
// Test 3: Constraints preservation
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_constraints_preserved_in_round_trip() {
use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
let mut system = build_single_compressor_system();
// Add a constraint referencing the compressor
let constraint = Constraint::new(
ConstraintId::new("superheat_ctrl"),
ComponentOutput::Superheat {
component_id: "compressor".to_string(),
},
5.0,
);
system.add_constraint(constraint).expect("add constraint");
assert_eq!(system.constraint_count(), 1);
// Serialize
let json_str = system.to_json_string().expect("Serialization failed");
// Verify constraints are in the JSON
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
let constraints = parsed.get("constraints").expect("constraints field");
assert!(constraints.is_array());
assert_eq!(constraints.as_array().unwrap().len(), 1);
let c = &constraints.as_array().unwrap()[0];
assert_eq!(c["id"], "superheat_ctrl");
assert_eq!(c["component"], "compressor");
assert_eq!(c["target"], 5.0);
// Verify the constraint snapshot round-trips through serde
let snapshot: entropyk_solver::SystemSnapshot =
serde_json::from_str(&json_str).expect("snapshot parse");
assert_eq!(snapshot.constraints.len(), 1);
assert_eq!(snapshot.constraints[0].id, "superheat_ctrl");
assert_eq!(snapshot.constraints[0].component, "compressor");
assert!((snapshot.constraints[0].target - 5.0).abs() < 1e-12);
}
// ────────────────────────────────────────────────────────────────────────
// Test 4: Thermal couplings preservation
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_thermal_couplings_preserved_in_round_trip() {
let original = build_two_component_system();
let json_str = original.to_json_string().expect("Serialization failed");
// Verify thermal couplings in JSON
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
let couplings = parsed
.get("topology")
.and_then(|t| t.get("thermalCouplings"))
.expect("thermal couplings in topology");
assert!(couplings.is_array());
assert_eq!(couplings.as_array().unwrap().len(), 1);
let c = &couplings.as_array().unwrap()[0];
assert_eq!(c["hotCircuit"], 0);
assert_eq!(c["coldCircuit"], 0);
// Verify the snapshot round-trips
let snapshot: entropyk_solver::SystemSnapshot =
serde_json::from_str(&json_str).expect("snapshot parse");
assert_eq!(snapshot.topology.thermal_couplings.len(), 1);
assert_eq!(snapshot.topology.thermal_couplings[0].hot_circuit, CircuitId(0));
assert_eq!(snapshot.topology.thermal_couplings[0].cold_circuit, CircuitId(0));
// Verify ua value round-trip
let ua_val = snapshot.topology.thermal_couplings[0].ua.to_watts_per_kelvin();
assert!((ua_val - 500.0).abs() < 1e-6, "UA value mismatch: {}", ua_val);
}
// ────────────────────────────────────────────────────────────────────────
// Test 5: File save/load round-trip
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_file_save_and_load() {
let system = build_two_component_system();
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("entropyk_test_round_trip.json");
// Save
system.save_json(&file_path).expect("Save failed");
assert!(file_path.exists());
// Load
let loaded = System::load_json(&file_path).expect("Load failed");
// Verify topology matches
assert_eq!(system.node_count(), loaded.node_count());
assert_eq!(system.edge_count(), loaded.edge_count());
assert_eq!(
system.thermal_coupling_count(),
loaded.thermal_coupling_count()
);
// Clean up
std::fs::remove_file(&file_path).ok();
}
// ────────────────────────────────────────────────────────────────────────
// Test 6: Missing backend error
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_missing_backend_returns_error() {
// AC5: missing backend must produce an explicit error
let json_with_unknown_backend = json!({
"version": "1.0",
"topology": {
"edges": [],
"thermalCouplings": []
},
"parameters": {},
"fluidBackend": {
"name": "NonExistentBackend",
"version": "99.0.0"
}
})
.to_string();
let result = System::from_json_string(&json_with_unknown_backend);
assert!(result.is_err(), "Should fail with BackendUnavailable for unknown backend");
}
// ────────────────────────────────────────────────────────────────────────
// Test 7: Version mismatch error
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_version_mismatch() {
let json_with_wrong_version = json!({
"version": "99.0",
"topology": {
"edges": [],
"thermalCouplings": []
},
"parameters": {},
"fluidBackend": {
"name": "TestBackend",
"version": "1.0.0",
"hash": "abc123"
}
})
.to_string();
let result = System::from_json_string(&json_with_wrong_version);
assert!(result.is_err(), "Should fail with version mismatch");
}
// ────────────────────────────────────────────────────────────────────────
// Additional: JSON human-readable and deterministic
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_simple_system_round_trip() {
let system = build_single_compressor_system();
let json_str = system.to_json_string().expect("Serialization failed");
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parsing failed");
assert!(parsed.is_object());
assert_eq!(parsed["version"], "1.0");
// Single isolated component should fail finalize during deserialization
let result = System::from_json_string(&json_str);
assert!(result.is_err(), "Isolated node should fail deserialization");
}
#[test]
fn test_json_is_human_readable() {
let system = System::new();
let json_str = system.to_json_string().expect("Serialization failed");
assert!(json_str.contains('\n'));
assert!(json_str.contains(" "));
let _: Value = serde_json::from_str(&json_str).expect("Should be valid JSON");
}
#[test]
fn test_deterministic_serialization() {
// Note: HashMap-based fields (parameters, ComponentParams) may produce
// different key ordering across serializations, so we compare parsed
// JSON values rather than raw strings.
let system = build_single_compressor_system();
let json1 = system.to_json_string().expect("Serialization failed");
let json2 = system.to_json_string().expect("Serialization failed");
let val1: Value = serde_json::from_str(&json1).expect("parse json1");
let val2: Value = serde_json::from_str(&json2).expect("parse json2");
assert_eq!(val1, val2, "Same system should produce identical JSON (structurally)");
}
// ────────────────────────────────────────────────────────────────────────
// Test: Bounded variables in snapshot
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_bounded_variables_in_snapshot() {
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
let mut system = build_single_compressor_system();
let valve =
BoundedVariable::with_component(BoundedVariableId::new("valve"), "compressor", 0.5, 0.0, 1.0)
.expect("create bounded var");
system.add_bounded_variable(valve).expect("add bounded var");
let json_str = system.to_json_string().expect("Serialization failed");
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
let bounded = parsed.get("boundedVariables").expect("boundedVariables field");
assert!(bounded.is_array());
assert_eq!(bounded.as_array().unwrap().len(), 1);
let bv = &bounded.as_array().unwrap()[0];
assert_eq!(bv["id"], "valve");
assert_eq!(bv["component"], "compressor");
assert!((bv["initialValue"].as_f64().unwrap() - 0.5).abs() < 1e-12);
// Verify snapshot round-trip
let snapshot: entropyk_solver::SystemSnapshot =
serde_json::from_str(&json_str).expect("snapshot parse");
assert_eq!(snapshot.bounded_variables.len(), 1);
assert_eq!(snapshot.bounded_variables[0].id, "valve");
assert_eq!(snapshot.bounded_variables[0].component, "compressor");
assert!((snapshot.bounded_variables[0].initial_value - 0.5).abs() < 1e-12);
assert!((snapshot.bounded_variables[0].lower_bound - 0.0).abs() < 1e-12);
assert!((snapshot.bounded_variables[0].upper_bound - 1.0).abs() < 1e-12);
}