Update project structure and configurations
This commit is contained in:
@@ -2,114 +2,372 @@
|
||||
//!
|
||||
//! 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::{Enthalpy, Pressure};
|
||||
use entropyk_solver::System;
|
||||
use entropyk_core::{CircuitId, Enthalpy, Pressure, ThermalConductance};
|
||||
use entropyk_solver::{System, ThermalCoupling};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
fn test_simple_system_round_trip() {
|
||||
// Create a simple system with one component
|
||||
/// Helper: create a minimal system with a single compressor component.
|
||||
fn build_single_compressor_system() -> System {
|
||||
let mut system = System::new();
|
||||
|
||||
// Create compressor with Ahri540 coefficients
|
||||
let coefficients = entropyk_components::Ahri540Coefficients::new(
|
||||
0.85, // m1
|
||||
2.5, // m2
|
||||
500.0, // m3
|
||||
1500.0, // m4
|
||||
-2.5, // m5
|
||||
1.8, // m6
|
||||
600.0, // m7
|
||||
1600.0, // m8
|
||||
-3.0, // m9
|
||||
2.0, // m10
|
||||
0.85, 2.5, 500.0, 1500.0, -2.5, 1.8, 600.0, 1600.0, -3.0, 2.0,
|
||||
);
|
||||
|
||||
// Create disconnected ports
|
||||
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),
|
||||
);
|
||||
|
||||
// Create disconnected compressor
|
||||
let disconnected_compressor = Compressor::new(
|
||||
let disconnected = Compressor::new(
|
||||
coefficients,
|
||||
port_suction,
|
||||
port_discharge,
|
||||
2900.0, // speed_rpm
|
||||
0.0001, // displacement_m3_per_rev
|
||||
0.85, // mechanical_efficiency
|
||||
).expect("Failed to create compressor");
|
||||
2900.0,
|
||||
0.0001,
|
||||
0.85,
|
||||
)
|
||||
.expect("Failed to create compressor");
|
||||
|
||||
// Connect the ports (this converts to Compressor<Connected>)
|
||||
let suction_port = Port::new(
|
||||
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 discharge_port = Port::new(
|
||||
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 connected_compressor = disconnected_compressor
|
||||
.connect(suction_port, discharge_port)
|
||||
.expect("Failed to connect compressor");
|
||||
let node_comp = system.add_component(Box::new(comp));
|
||||
system.register_component_name("compressor", node_comp);
|
||||
|
||||
// Add to system as Box<dyn Component>
|
||||
system.add_component(Box::new(connected_compressor));
|
||||
// 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");
|
||||
|
||||
// Test to_json_string and from_json_string
|
||||
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 JSON is valid and human-readable
|
||||
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parsing failed");
|
||||
assert!(parsed.is_object());
|
||||
assert!(parsed.get("version").is_some());
|
||||
assert_eq!(parsed["version"], "1.0");
|
||||
// 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);
|
||||
|
||||
// Deserialize
|
||||
let restored_system = System::from_json_string(&json_str).expect("Deserialization failed");
|
||||
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 system is reconstructed
|
||||
// (Full component reconstruction will be implemented in future tasks)
|
||||
assert!(true);
|
||||
// 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": "999.0", // Incompatible version
|
||||
"version": "99.0",
|
||||
"topology": {
|
||||
"edges": [],
|
||||
"thermal_couplings": []
|
||||
"thermalCouplings": []
|
||||
},
|
||||
"parameters": {},
|
||||
"fluid_backend": {
|
||||
"fluidBackend": {
|
||||
"name": "TestBackend",
|
||||
"version": "1.0.0",
|
||||
"hash": "abc123"
|
||||
}
|
||||
}).to_string();
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let result = System::from_json_string(&json_with_wrong_version);
|
||||
assert!(result.is_err());
|
||||
// Just verify it's an error - don't try to unwrap
|
||||
assert!(true);
|
||||
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]
|
||||
@@ -117,43 +375,60 @@ fn test_json_is_human_readable() {
|
||||
let system = System::new();
|
||||
let json_str = system.to_json_string().expect("Serialization failed");
|
||||
|
||||
// Check that JSON is pretty-printed (contains newlines and indentation)
|
||||
assert!(json_str.contains('\n'));
|
||||
assert!(json_str.contains(" ")); // Indentation
|
||||
assert!(json_str.contains(" "));
|
||||
|
||||
// Verify it's valid JSON
|
||||
let _: Value = serde_json::from_str(&json_str).expect("Should be valid JSON");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_serialization() {
|
||||
let system = System::new();
|
||||
|
||||
// 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");
|
||||
|
||||
// Same system should produce same JSON
|
||||
assert_eq!(json1, json2);
|
||||
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_file_save_and_load() {
|
||||
let system = System::new();
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let file_path = temp_dir.join("test_system.json");
|
||||
fn test_bounded_variables_in_snapshot() {
|
||||
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
|
||||
|
||||
// Save to file
|
||||
system.save_json(&file_path).expect("Save failed");
|
||||
let mut system = build_single_compressor_system();
|
||||
|
||||
// Verify file exists
|
||||
assert!(file_path.exists());
|
||||
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");
|
||||
|
||||
// Load from file
|
||||
let _loaded_system = System::load_json(&file_path).expect("Load failed");
|
||||
let json_str = system.to_json_string().expect("Serialization failed");
|
||||
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
|
||||
|
||||
// Clean up
|
||||
std::fs::remove_file(&file_path).ok();
|
||||
let bounded = parsed.get("boundedVariables").expect("boundedVariables field");
|
||||
assert!(bounded.is_array());
|
||||
assert_eq!(bounded.as_array().unwrap().len(), 1);
|
||||
|
||||
// Verify system is reconstructed
|
||||
assert!(true);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user