Update project structure and configurations

This commit is contained in:
2026-05-23 10:19:55 +02:00
parent ab5dc7e568
commit 62efea0646
1832 changed files with 83568 additions and 51829 deletions

View File

@@ -16,6 +16,9 @@ entropyk-components = { path = "../components" }
entropyk-fluids = { path = "../fluids" }
entropyk-solver = { path = "../solver" }
thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = "0.1"
petgraph = "0.6"
[dev-dependencies]

View File

@@ -1,7 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use petgraph::visit::EdgeRef;
use thiserror::Error;
use entropyk_core::{CircuitId, ThermalConductance};
use entropyk_fluids::FluidBackend;
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId};
use entropyk_solver::{AddEdgeError, ThermalCoupling, TopologyError};
@@ -130,6 +133,20 @@ pub enum SystemBuilderError {
/// The circuit that has no components.
circuit: u16,
},
/// Fluid backend assignment failed (empty circuit or invalid circuit ID).
#[error("Fluid backend assignment failed: {reason}")]
FluidBackendAssignmentFailed {
/// Reason for the failure.
reason: String,
},
/// JSON config serialization or deserialization failed.
#[error("Config JSON error: {reason}")]
ConfigJsonError {
/// Reason for the failure.
reason: String,
},
}
/// A builder for creating thermodynamic systems with a fluent API.
@@ -152,6 +169,8 @@ pub struct SystemBuilder {
component_names: HashMap<String, petgraph::graph::NodeIndex>,
fluid_name: Option<String>,
thermal_couplings: Vec<ThermalCoupling>,
default_backend: Option<Arc<dyn FluidBackend>>,
circuit_backends: HashMap<u16, Arc<dyn FluidBackend>>,
}
impl SystemBuilder {
@@ -162,6 +181,8 @@ impl SystemBuilder {
component_names: HashMap::new(),
fluid_name: None,
thermal_couplings: Vec::new(),
default_backend: None,
circuit_backends: HashMap::new(),
}
}
@@ -179,6 +200,63 @@ impl SystemBuilder {
self
}
/// Sets the default fluid backend for all components in the system.
///
/// During [`build()`](Self::build), this backend is propagated to every component
/// that supports fluid backends and doesn't already have one assigned.
/// Per-circuit backends (set via [`with_circuit_fluid_backend`](Self::with_circuit_fluid_backend))
/// take precedence over the default.
///
/// # Arguments
///
/// * `backend` - A shared fluid backend (e.g., `Arc::new(TestBackend::new())`)
#[inline]
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.default_backend = Some(backend);
self
}
/// Assigns a fluid backend to all components in a specific circuit.
///
/// Per-circuit backends override the default backend set via
/// [`with_fluid_backend`](Self::with_fluid_backend) for components in that circuit.
/// The circuit must already contain at least one component.
///
/// # Arguments
///
/// * `circuit_id` - The circuit ID (0..=4)
/// * `backend` - A shared fluid backend for this circuit
///
/// # Errors
///
/// Returns [`SystemBuilderError::FluidBackendAssignmentFailed`] if:
/// - The circuit ID exceeds 4
/// - The circuit has no components
pub fn with_circuit_fluid_backend(
mut self,
circuit_id: u16,
backend: Arc<dyn FluidBackend>,
) -> Result<Self, SystemBuilderError> {
const MAX_CIRCUIT_ID: u16 = 4;
if circuit_id > MAX_CIRCUIT_ID {
return Err(SystemBuilderError::FluidBackendAssignmentFailed {
reason: format!("Circuit ID {circuit_id} exceeds maximum (0..={MAX_CIRCUIT_ID})"),
});
}
if self
.system
.circuit_nodes(entropyk_core::CircuitId(circuit_id))
.next()
.is_none()
{
return Err(SystemBuilderError::FluidBackendAssignmentFailed {
reason: format!("Circuit {circuit_id} has no components"),
});
}
self.circuit_backends.insert(circuit_id, backend);
Ok(self)
}
/// Adds a named component to the system (circuit 0).
///
/// The name is used for later reference when creating edges.
@@ -566,6 +644,333 @@ impl SystemBuilder {
self.system.edge_count()
}
/// Serializes the builder configuration to a JSON string.
///
/// Produces a complete JSON representation of the builder state:
/// component parameters, topology (edges with port names), circuit assignments,
/// thermal couplings, fluid name, constraints, and bounded variables.
///
/// Fluid backends (`Arc<dyn FluidBackend>`) are NOT serialized — they are runtime
/// objects. After deserialization, call `with_fluid_backend()` to reassign them.
pub fn to_config_json(&self) -> Result<String, SystemBuilderError> {
use entropyk_solver::snapshot::{
BoundedVariableSnapshot, ConstraintSnapshot, EdgeSnapshot, FluidBackendInfo,
SolverConfigSnapshot, SystemSnapshot, TopologySnapshot,
};
let reverse_names: HashMap<petgraph::graph::NodeIndex, &String> =
self.component_names.iter().map(|(n, &i)| (i, n)).collect();
let mut edges = Vec::new();
for edge in self.system.graph().edge_indices() {
let (source, target) = self.system.graph().edge_endpoints(edge).unwrap();
let source_node = self.system.graph().node_weight(source).unwrap();
let target_node = self.system.graph().node_weight(target).unwrap();
let source_ports = source_node.port_names();
let target_ports = target_node.port_names();
let target_incoming: Vec<_> = self
.system
.graph()
.edges_directed(target, petgraph::Direction::Incoming)
.collect();
let target_port_idx = target_incoming
.iter()
.position(|e| e.id() == edge)
.unwrap_or(0);
let source_outgoing: Vec<_> = self
.system
.graph()
.edges_directed(source, petgraph::Direction::Outgoing)
.collect();
let source_port_idx = source_outgoing
.iter()
.position(|e| e.id() == edge)
.unwrap_or(0);
let source_port_name = source_ports
.get(source_port_idx)
.cloned()
.unwrap_or_else(|| format!("port_{}", source_port_idx));
let target_port_name = target_ports
.get(target_port_idx)
.cloned()
.unwrap_or_else(|| format!("port_{}", target_port_idx));
let circuit_id = self.system.edge_circuit(edge).0;
edges.push(EdgeSnapshot {
source: reverse_names
.get(&source)
.map(|s| s.to_string())
.unwrap_or_else(|| source_node.signature()),
source_port: source_port_name,
target: reverse_names
.get(&target)
.map(|s| s.to_string())
.unwrap_or_else(|| target_node.signature()),
target_port: target_port_name,
circuit_id,
});
}
let mut parameters = HashMap::new();
for node in self.system.graph().node_indices() {
if let Some(component) = self.system.graph().node_weight(node) {
let params = component.to_params();
let key = reverse_names
.get(&node)
.map(|s| (*s).clone())
.unwrap_or_else(|| component.signature());
parameters.insert(key, params);
}
}
let component_names: HashMap<String, String> = self
.component_names
.iter()
.map(|(name, &node_idx)| {
let type_name = self
.system
.graph()
.node_weight(node_idx)
.map(|c| c.to_params().component_type.clone())
.unwrap_or_else(|| "Unknown".to_string());
(name.clone(), type_name)
})
.collect();
let circuit_assignments: HashMap<String, u16> = self
.component_names
.iter()
.map(|(name, &node_idx)| {
let cid = self.system.node_to_circuit().get(&node_idx).map(|c| c.0).unwrap_or(0);
(name.clone(), cid)
})
.collect();
let constraints = self
.system
.constraints_map()
.iter()
.map(|(id, c)| ConstraintSnapshot {
id: id.as_str().to_string(),
component: c.output().component_id().to_string(),
output_type: c.output().constraint_type_name().to_string(),
target: c.target_value(),
})
.collect();
let bounded_variables = self
.system
.bounded_variables_map()
.iter()
.map(|(id, v)| BoundedVariableSnapshot {
id: id.as_str().to_string(),
component: v.component_id().unwrap_or("").to_string(),
variable_name: id.as_str().to_string(),
lower_bound: v.min(),
upper_bound: v.max(),
initial_value: v.value(),
})
.collect();
let mut metadata = HashMap::new();
if let Some(ref fluid) = self.fluid_name {
metadata.insert("fluidName".to_string(), serde_json::Value::String(fluid.clone()));
}
let snapshot = SystemSnapshot {
version: "1.0".to_string(),
topology: TopologySnapshot {
edges,
thermal_couplings: self.thermal_couplings.clone(),
},
parameters,
fluid_state: None,
fluid_backend: FluidBackendInfo {
name: "RuntimeProvided".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hash: None,
},
solver_config: Some(SolverConfigSnapshot::default()),
component_names,
circuit_assignments,
constraints,
bounded_variables,
metadata,
};
serde_json::to_string_pretty(&snapshot).map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!("JSON serialization failed: {}", e),
})
}
/// Deserializes a builder configuration from a JSON string.
///
/// Reconstructs a `SystemBuilder` from JSON produced by [`to_config_json`](Self::to_config_json).
/// Components are reconstructed via the component registry — no `ParamsPlaceholder` fallback.
///
/// Fluid backends must be re-assigned after deserialization using
/// [`with_fluid_backend`](Self::with_fluid_backend) or
/// [`with_circuit_fluid_backend`](Self::with_circuit_fluid_backend).
pub fn from_config_json(json: &str) -> Result<Self, SystemBuilderError> {
use entropyk_components::create_component;
use entropyk_solver::inverse::{
BoundedVariable, BoundedVariableId, Constraint, ConstraintId,
};
use entropyk_solver::snapshot::SystemSnapshot;
let snapshot: SystemSnapshot = serde_json::from_str(json).map_err(|e| {
SystemBuilderError::ConfigJsonError {
reason: format!("Invalid JSON: {}", e),
}
})?;
if snapshot.version != "1.0" {
return Err(SystemBuilderError::ConfigJsonError {
reason: format!(
"Unsupported version '{}', expected '1.0'",
snapshot.version
),
});
}
let mut builder = SystemBuilder::new();
// Restore fluid name from metadata
if let Some(serde_json::Value::String(fluid)) = snapshot.metadata.get("fluidName") {
builder = builder.with_fluid(fluid.clone());
}
// Reconstruct components from parameters, ordered by circuit_assignments for deterministic ordering
let mut ordered_names: Vec<String> = snapshot.circuit_assignments.keys().cloned().collect();
ordered_names.sort_by(|a, b| {
let ca = snapshot.circuit_assignments.get(a).copied().unwrap_or(0);
let cb = snapshot.circuit_assignments.get(b).copied().unwrap_or(0);
ca.cmp(&cb).then_with(|| a.cmp(b))
});
for name in &ordered_names {
let params = snapshot.parameters.get(name).ok_or_else(|| {
SystemBuilderError::ConfigJsonError {
reason: format!("Missing parameters for component '{}'", name),
}
})?;
let component = create_component(params).map_err(|e| {
SystemBuilderError::ConfigJsonError {
reason: format!(
"Failed to reconstruct component '{}' (type '{}'): {}",
name, params.component_type, e
),
}
})?;
let circuit_id = snapshot
.circuit_assignments
.get(name)
.copied()
.unwrap_or(0);
builder = builder
.component_in_circuit(
name,
component,
entropyk_core::CircuitId(circuit_id),
)
.map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!("Failed to add component '{}': {}", name, e),
})?;
}
// Reconstruct edges — use edge_with_ports only for real named ports,
// fall back to edge() for synthetic port names (port_0, port_1, etc.)
for edge in &snapshot.topology.edges {
let has_real_ports = !edge.source_port.is_empty()
&& !edge.target_port.is_empty()
&& !edge.source_port.starts_with("port_")
&& !edge.target_port.starts_with("port_");
if has_real_ports {
builder = builder
.edge_with_ports(
&edge.source,
&edge.source_port,
&edge.target,
&edge.target_port,
)
.map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!(
"Failed to create edge '{}' -> '{}': {}",
edge.source, edge.target, e
),
})?;
} else {
builder = builder
.edge(&edge.source, &edge.target)
.map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!(
"Failed to create edge '{}' -> '{}': {}",
edge.source, edge.target, e
),
})?;
}
}
// Store thermal couplings for deferred application during build()
builder.thermal_couplings = snapshot.topology.thermal_couplings.clone();
// Reconstruct constraints
for cs in &snapshot.constraints {
let output = parse_component_output(&cs.output_type, &cs.component);
let constraint = Constraint::new(ConstraintId::new(&cs.id), output, cs.target);
builder = builder
.with_constraint(constraint)
.map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!("Failed to restore constraint '{}': {}", cs.id, e),
})?;
}
// Reconstruct bounded variables
for bvs in &snapshot.bounded_variables {
let var = BoundedVariable::new(
BoundedVariableId::new(&bvs.id),
bvs.initial_value,
bvs.lower_bound,
bvs.upper_bound,
)
.map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!("Failed to restore bounded variable '{}': {}", bvs.id, e),
})?;
builder = builder
.with_bounded_variable(var)
.map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!("Failed to add bounded variable '{}': {}", bvs.id, e),
})?;
}
Ok(builder)
}
/// Saves the builder configuration to a JSON file.
pub fn save_config_json(&self, path: &std::path::Path) -> Result<(), SystemBuilderError> {
let json = self.to_config_json()?;
std::fs::write(path, json).map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!("Failed to write file '{}': {}", path.display(), e),
})
}
/// Loads a builder configuration from a JSON file.
pub fn load_config_json(path: &std::path::Path) -> Result<Self, SystemBuilderError> {
let json = std::fs::read_to_string(path).map_err(|e| SystemBuilderError::ConfigJsonError {
reason: format!("Failed to read file '{}': {}", path.display(), e),
})?;
Self::from_config_json(&json)
}
/// Builds and finalizes the system.
///
/// This method consumes the builder and returns a finalized [`entropyk_solver::System`]
@@ -596,6 +1001,20 @@ impl SystemBuilder {
})?;
}
// Propagate fluid backends to components
for idx in self.component_names.values() {
let circuit = system.node_circuit(*idx).0;
let backend = self
.circuit_backends
.get(&circuit)
.or(self.default_backend.as_ref());
if let Some(backend) = backend {
if let Some(comp) = system.component_mut(*idx) {
comp.set_fluid_backend_from_builder(Arc::clone(backend));
}
}
}
Ok(system)
}
@@ -612,6 +1031,21 @@ impl SystemBuilder {
}
}
fn parse_component_output(type_name: &str, component_id: &str) -> entropyk_solver::inverse::ComponentOutput {
use entropyk_solver::inverse::ComponentOutput;
match type_name {
"saturationTemperature" => ComponentOutput::SaturationTemperature { component_id: component_id.to_string() },
"superheat" => ComponentOutput::Superheat { component_id: component_id.to_string() },
"subcooling" => ComponentOutput::Subcooling { component_id: component_id.to_string() },
"heatTransferRate" => ComponentOutput::HeatTransferRate { component_id: component_id.to_string() },
"capacity" => ComponentOutput::Capacity { component_id: component_id.to_string() },
"massFlowRate" => ComponentOutput::MassFlowRate { component_id: component_id.to_string() },
"pressure" => ComponentOutput::Pressure { component_id: component_id.to_string() },
"temperature" => ComponentOutput::Temperature { component_id: component_id.to_string() },
_ => ComponentOutput::Superheat { component_id: component_id.to_string() },
}
}
impl Default for SystemBuilder {
fn default() -> Self {
Self::new()
@@ -1181,4 +1615,375 @@ mod tests {
panic!("Expected EmptyCircuitCoupling error for circuit 0");
}
}
// ═══════════════════════════════════════════════════════════════
// Fluid Backend Assignment Tests (Story 13.7)
// ═══════════════════════════════════════════════════════════════
#[test]
fn test_with_fluid_backend_stores_default() {
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let builder = SystemBuilder::new().with_fluid_backend(backend);
assert_eq!(builder.component_count(), 0);
}
#[test]
fn test_with_fluid_backend_chainable() {
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let builder = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.with_fluid_backend(backend);
assert_eq!(builder.component_count(), 1);
}
#[test]
fn test_with_circuit_fluid_backend_valid_circuit() {
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let builder = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.with_circuit_fluid_backend(0, backend)
.expect("should succeed for valid circuit");
assert_eq!(builder.component_count(), 1);
}
#[test]
fn test_with_circuit_fluid_backend_empty_circuit_rejected() {
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let result = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.with_circuit_fluid_backend(2, backend);
assert!(result.is_err());
if let Err(SystemBuilderError::FluidBackendAssignmentFailed { reason }) = result {
assert!(reason.contains("no components"));
} else {
panic!("Expected FluidBackendAssignmentFailed");
}
}
#[test]
fn test_with_circuit_fluid_backend_invalid_circuit_id() {
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let result = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.with_circuit_fluid_backend(5, backend);
assert!(result.is_err());
if let Err(SystemBuilderError::FluidBackendAssignmentFailed { reason }) = result {
assert!(reason.contains("exceeds maximum"));
} else {
panic!("Expected FluidBackendAssignmentFailed");
}
}
#[test]
fn test_build_propagates_default_backend() {
use entropyk_components::Component;
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
// Use a MockComponent — set_fluid_backend_from_builder is a no-op on it,
// but the build() should not error
let system = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.component("b", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.edge("a", "b")
.unwrap()
.with_fluid_backend(backend)
.build()
.expect("build should succeed with backend");
assert_eq!(system.node_count(), 2);
}
#[test]
fn test_build_propagates_circuit_backend() {
use entropyk_core::CircuitId;
let backend_0: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let backend_1: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let system = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.edge("a", "b")
.unwrap()
.component_in_circuit("c", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.component_in_circuit("d", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.edge("c", "d")
.unwrap()
.with_fluid_backend(Arc::clone(&backend_0))
.with_circuit_fluid_backend(1, backend_1)
.expect("circuit backend should succeed")
.build()
.expect("build should succeed");
assert_eq!(system.node_count(), 4);
assert_eq!(system.circuit_count(), 2);
}
#[test]
fn test_build_no_backend_still_works() {
let system = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.component("b", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.edge("a", "b")
.unwrap()
.build()
.expect("build without backend should succeed");
assert_eq!(system.node_count(), 2);
}
#[test]
fn test_build_propagates_backend_to_real_node() {
use entropyk_components::{Node, port::Port};
use entropyk_core::{Pressure, Enthalpy};
use entropyk_fluids::FluidId;
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
// Create internal ports for Node (Disconnected)
let internal_in = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(300000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let internal_out = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(290000.0),
Enthalpy::from_joules_per_kg(410000.0),
);
let node_disconnected = Node::new("test_node", internal_in, internal_out);
// Create external ports and connect the Node
let external_in = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(300000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let external_out = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(290000.0),
Enthalpy::from_joules_per_kg(410000.0),
);
let node = node_disconnected
.connect(external_in, external_out)
.expect("node should connect");
// Verify node starts without backend
assert!(
!node.has_fluid_backend(),
"Node should start without backend"
);
let system = SystemBuilder::new()
.component("node_a", Box::new(node))
.unwrap()
.component("b", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.edge("node_a", "b")
.unwrap()
.with_fluid_backend(backend)
.build()
.expect("build with real Node should succeed");
// The real Node's set_fluid_backend_from_builder was called (not no-op like MockComponent).
// Build succeeded without panic = backend was accepted.
let node_idx = system.get_component_node("node_a").expect("node should exist");
let comp = system.component(node_idx);
assert_eq!(comp.n_equations(), 0, "Node is passive (0 equations)");
assert_eq!(system.node_count(), 2);
}
#[test]
fn test_to_config_json_empty_builder() {
let builder = SystemBuilder::new();
let json = builder.to_config_json().expect("empty builder should serialize");
assert!(json.contains("\"version\": \"1.0\""));
assert!(json.contains("\"parameters\": {}"));
}
#[test]
fn test_to_config_json_with_fluid_name() {
let builder = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.with_fluid("R134a");
let json = builder.to_config_json().expect("should serialize with fluid");
assert!(json.contains("\"fluidName\": \"R134a\""));
}
#[test]
fn test_to_config_json_with_components_and_edges() {
let builder = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.component("b", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.edge("a", "b")
.unwrap();
let json = builder.to_config_json().expect("should serialize");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["parameters"].as_object().unwrap().len(), 2);
assert_eq!(parsed["topology"]["edges"].as_array().unwrap().len(), 1);
}
#[test]
fn test_from_config_json_invalid_json() {
let result = SystemBuilder::from_config_json("not json");
assert!(result.is_err());
if let Err(SystemBuilderError::ConfigJsonError { reason }) = result {
assert!(reason.contains("Invalid JSON"));
} else {
panic!("Expected ConfigJsonError");
}
}
#[test]
fn test_from_config_json_version_mismatch() {
let json = r#"{"version":"2.0","topology":{"edges":[]},"parameters":{},"fluidBackend":{"name":"X","version":"1.0"}}"#;
let result = SystemBuilder::from_config_json(json);
assert!(result.is_err());
if let Err(SystemBuilderError::ConfigJsonError { reason }) = result {
assert!(reason.contains("Unsupported version"));
} else {
panic!("Expected ConfigJsonError");
}
}
#[test]
fn test_round_trip_mock_components() {
let original = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.component("b", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.edge("a", "b")
.unwrap()
.with_fluid("R410A");
let json = original.to_config_json().expect("serialize");
// MockComponent can't be reconstructed by the registry, but JSON structure is valid
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["parameters"].as_object().unwrap().len(), 2);
assert!(json.contains("\"fluidName\": \"R410A\""));
}
#[test]
fn test_round_trip_with_constraints_and_bounded_vars() {
use entropyk_solver::inverse::{
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
};
use entropyk_components::Node;
use entropyk_components::port::{FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
let in_p = Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0));
let out_p = Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0));
let node = Node::new("probe", in_p, out_p).connect(
Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0)),
Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0)),
).expect("connect");
let original = SystemBuilder::new()
.component("evap", Box::new(node))
.unwrap()
.with_constraint(Constraint::new(
ConstraintId::new("sh"),
ComponentOutput::Superheat { component_id: "evap".to_string() },
5.0,
))
.unwrap()
.with_bounded_variable(BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap())
.unwrap();
let json = original.to_config_json().expect("serialize");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["constraints"].as_array().unwrap().len(), 1);
assert_eq!(parsed["boundedVariables"].as_array().unwrap().len(), 1);
let restored = SystemBuilder::from_config_json(&json).expect("deserialize");
assert_eq!(restored.component_count(), 1);
}
#[test]
fn test_round_trip_multi_circuit_with_thermal_coupling() {
use entropyk_core::CircuitId;
use entropyk_components::Node;
use entropyk_components::port::{FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
let in_p = Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0));
let out_p = Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0));
let make_node = |name: &str| {
let n = Node::new(name, in_p.clone(), out_p.clone()).connect(
Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0)),
Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0)),
).expect("connect");
Box::new(n) as Box<dyn entropyk_components::Component>
};
let original = SystemBuilder::new()
.component_in_circuit("a", make_node("a"), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", make_node("b"), CircuitId::ZERO)
.unwrap()
.component_in_circuit("c", make_node("c"), CircuitId(1))
.unwrap()
.component_in_circuit("d", make_node("d"), CircuitId(1))
.unwrap()
.edge("a", "b")
.unwrap()
.edge("c", "d")
.unwrap()
.thermal_coupling(0, 1, 5.0)
.unwrap();
let json = original.to_config_json().expect("serialize");
let restored = SystemBuilder::from_config_json(&json).expect("deserialize");
assert_eq!(restored.component_count(), 4);
assert_eq!(restored.edge_count(), 2);
}
#[test]
fn test_save_load_config_json_file() {
use entropyk_components::Node;
use entropyk_components::port::{FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
let internal_in = Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0));
let internal_out = Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0));
let node_disconnected = Node::new("probe", internal_in, internal_out);
let ext_in = Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0));
let ext_out = Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0));
let node = node_disconnected.connect(ext_in, ext_out).expect("connect");
let dir = std::env::temp_dir().join("entropyk_test_config.json");
let original = SystemBuilder::new()
.component("probe", Box::new(node))
.unwrap()
.with_fluid("R134a");
original.save_config_json(&dir).expect("save");
let restored = SystemBuilder::load_config_json(&dir).expect("load");
assert_eq!(restored.component_count(), 1);
let _ = std::fs::remove_file(&dir);
}
}

View File

@@ -88,7 +88,8 @@
pub use entropyk_core::{
Calib, CalibIndices, CalibValidationError, Enthalpy, MassFlow, Power, Pressure, Temperature,
ThermalConductance, MIN_MASS_FLOW_REGULARIZATION_KG_S,
ThermalConductance, Concentration, RelativeHumidity, VaporQuality, VolumeFlow,
MIN_MASS_FLOW_REGULARIZATION_KG_S,
};
// =============================================================================
@@ -96,21 +97,24 @@ pub use entropyk_core::{
// =============================================================================
pub use entropyk_components::{
friction_factor, roughness, AffinityLaws, Ahri540Coefficients, AirSink, AirSource,
BrineSink, BrineSource, CircuitId, Component,
create_component, friction_factor, roughness, AffinityLaws, Ahri540Coefficients, AirSink, AirSource,
BoundedCurve, BrineSink, BrineSource, BypassValve, BypassValveConfig, CircuitId, Component,
ComponentError, CompressibleMerger, CompressibleSplitter,
Compressor, CompressorModel, Condenser, CondenserCoil, ConnectedPort, ConnectionError,
CurveEngine, CurveEval, CurveResult, CurveSet, CurveWarning,
Economizer, EpsNtuModel, Evaporator, EvaporatorCoil, ExchangerType, ExpansionValve,
ExternalModel, ExternalModelConfig, ExternalModelError, ExternalModelMetadata,
ExternalModelType, Fan, FanCurves, FloodedEvaporator, FlowConfiguration, FlowMerger,
FlowSplitter, FluidKind, HeatExchanger, HeatExchangerBuilder, HeatTransferModel,
FlowSplitter, FluidKind, FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger,
FreeCoolingMode, HeatExchanger, HeatExchangerBuilder, HeatTransferModel,
HxSideConditions, IncompressibleMerger,
IncompressibleSplitter, JacobianBuilder, LmtdModel, MchxCondenserCoil, MockExternalModel,
OperationalState, PerformanceCurves, PhaseRegion, Pipe, PipeGeometry, Polynomial1D,
Polynomial2D, Pump, PumpCurves, RefrigerantSink, RefrigerantSource, ResidualVector,
ScrewEconomizerCompressor,
Node, NodeMeasurements, NodePhase, OperationalState, PerformanceCurves, PhaseRegion,
Pipe, PipeGeometry, Polynomial1D,
Polynomial2D, Pump, PumpCurves, RegistryError, RefrigerantSink, RefrigerantSource,
ResidualVector, ScrewEconomizerCompressor,
ScrewPerformanceCurves, SstSdtCoefficients, StateHistory, StateManageable,
StateTransitionError, SystemState, ThreadSafeExternalModel,
StateTransitionError, SystemState, ThreadSafeExternalModel, ValveCharacteristics,
};
pub use entropyk_components::port::{Connected, Disconnected, FluidId as ComponentFluidId, Port};
@@ -158,6 +162,16 @@ pub use error::{ThermoError, ThermoResult};
mod builder;
pub use builder::{SystemBuilder, SystemBuilderError};
// =============================================================================
// Structured Results
// =============================================================================
mod result;
pub use result::{
extract_simulation_result, ComponentResult, ConvergenceSummary, EdgeResult, EnergyResult,
PortState, SimulationOutcome, SimulationResult, SystemSummary,
};
// =============================================================================
// Prelude
// =============================================================================
@@ -172,6 +186,7 @@ pub use builder::{SystemBuilder, SystemBuilderError};
/// ```
pub mod prelude {
pub use crate::ThermoError;
pub use crate::result::SimulationResult;
pub use entropyk_components::Component;
pub use entropyk_core::{Enthalpy, MassFlow, Power, Pressure, Temperature};
pub use entropyk_solver::{NewtonConfig, Solver, System};

View File

@@ -0,0 +1,601 @@
//! Structured simulation result types.
//!
//! This module provides [`SimulationResult`] — a high-level, user-friendly wrapper
//! around the raw [`ConvergedState`] that decomposes the solver output into
//! per-component, per-edge, and system-level summaries.
//!
//! # Usage
//!
//! ```ignore
//! use entropyk::{SystemBuilder, Solver, FallbackSolver, extract_simulation_result};
//!
//! let system = SystemBuilder::new()
//! .component("comp", compressor)?
//! .edge("comp", "cond")?
//! .build()?;
//!
//! let mut solver = FallbackSolver::default_solver();
//! let converged = solver.solve(&mut system)?;
//!
//! let result = extract_simulation_result(&system, &converged);
//! println!("{}", result.to_json()?);
//! ```
use std::collections::HashMap;
use entropyk_solver::{
ConvergenceStatus, ConvergedState, SimulationMetadata, System,
};
use petgraph::graph::{EdgeIndex, NodeIndex};
use serde::{Deserialize, Serialize};
// ─────────────────────────────────────────────────────────────────────────────
// Status enum
// ─────────────────────────────────────────────────────────────────────────────
/// Simulation outcome status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SimulationOutcome {
/// Solver converged within tolerance and iteration budget.
Converged,
/// Solver converged but one or more control variables saturated at bounds.
ControlSaturation,
/// Solver exceeded time budget but returned the best-known state.
TimedOut,
/// Solver did not converge (max iterations, divergence, etc.).
NonConverged,
}
impl From<ConvergenceStatus> for SimulationOutcome {
fn from(status: ConvergenceStatus) -> Self {
match status {
ConvergenceStatus::Converged => SimulationOutcome::Converged,
ConvergenceStatus::ControlSaturation => SimulationOutcome::ControlSaturation,
ConvergenceStatus::TimedOutWithBestState => SimulationOutcome::TimedOut,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Convergence summary
// ─────────────────────────────────────────────────────────────────────────────
/// User-friendly convergence summary extracted from [`ConvergedState`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConvergenceSummary {
/// Number of solver iterations performed.
pub iterations: usize,
/// L2 norm of the residual vector at the final state.
pub final_residual: f64,
/// Whether the solver converged.
pub converged: bool,
/// Solver status.
pub status: SimulationOutcome,
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-port state
// ─────────────────────────────────────────────────────────────────────────────
/// Thermodynamic state at a component port (inlet or outlet).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PortState {
/// Pressure in Pascals.
pub pressure_pa: f64,
/// Specific enthalpy in J/kg.
pub enthalpy_j_kg: f64,
/// Mass flow rate in kg/s (if available from the component).
pub mass_flow_kg_s: Option<f64>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Energy result
// ─────────────────────────────────────────────────────────────────────────────
/// Energy transfers for a single component.
///
/// Sign convention follows the `Component::energy_transfers` method:
/// - `heat_transfer` > 0 means heat added TO the component.
/// - `work` > 0 means work done BY the component on the environment.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnergyResult {
/// Heat transfer in Watts.
pub heat_transfer_w: f64,
/// Work in Watts.
pub work_w: f64,
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-component result
// ─────────────────────────────────────────────────────────────────────────────
/// Structured result for a single named component.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentResult {
/// Component name as registered in [`SystemBuilder`](crate::SystemBuilder).
pub name: String,
/// Component type signature string.
pub component_type: String,
/// Circuit ID (0-based).
pub circuit: u16,
/// Inlet port state (first incoming edge), if any.
pub inlet: Option<PortState>,
/// Outlet port state (first outgoing edge), if any.
pub outlet: Option<PortState>,
/// Energy transfers, if the component reports them.
pub energy: Option<EnergyResult>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-edge result
// ─────────────────────────────────────────────────────────────────────────────
/// Thermodynamic state on a single graph edge.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EdgeResult {
/// Edge index in the system graph.
pub edge_id: usize,
/// Pressure in Pascals.
pub pressure_pa: f64,
/// Specific enthalpy in J/kg.
pub enthalpy_j_kg: f64,
/// Name of the source component, if resolvable.
pub source: Option<String>,
/// Name of the target component, if resolvable.
pub target: Option<String>,
}
// ─────────────────────────────────────────────────────────────────────────────
// System-level summary
// ─────────────────────────────────────────────────────────────────────────────
/// Aggregated system-level performance metrics.
///
/// Derived by summing `Component::energy_transfers` across all components.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemSummary {
/// Total cooling capacity in Watts (negative heat_transfer sum from evaporators).
pub total_cooling_capacity_w: Option<f64>,
/// Total heating capacity in Watts (positive heat_transfer sum from condensers).
pub total_heating_capacity_w: Option<f64>,
/// Total compressor power in Watts.
pub total_compressor_power_w: Option<f64>,
/// Total pump power in Watts.
pub total_pump_power_w: Option<f64>,
/// Coefficient of Performance (cooling): COP_cooling = Q_cooling / W_compressor.
pub cop_cooling: Option<f64>,
/// Coefficient of Performance (heating): COP_heating = Q_heating / W_compressor.
pub cop_heating: Option<f64>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Top-level SimulationResult
// ─────────────────────────────────────────────────────────────────────────────
/// Structured simulation result with per-component, per-edge, and system-level data.
///
/// Constructed via [`extract_simulation_result`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimulationResult {
/// Simulation outcome status.
pub status: SimulationOutcome,
/// Convergence summary.
pub convergence: ConvergenceSummary,
/// Traceability metadata from the solver.
pub metadata: SimulationMetadata,
/// Per-component results, one entry per named component.
pub components: Vec<ComponentResult>,
/// Per-edge results.
pub edges: Vec<EdgeResult>,
/// Aggregated system performance summary.
pub summary: SystemSummary,
}
impl SimulationResult {
/// Serializes the result to a pretty-printed JSON string.
///
/// # Errors
///
/// Returns an error if serialization fails (should not happen with standard types).
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Extraction function
// ─────────────────────────────────────────────────────────────────────────────
/// Extracts a structured [`SimulationResult`] from a solved system.
///
/// This function iterates over all named components and edges in `system`,
/// reads the converged state vector, and assembles a user-friendly result.
///
/// # Arguments
///
/// * `system` - The solved system (must be finalized).
/// * `converged` - The converged state returned by the solver.
pub fn extract_simulation_result(
system: &System,
converged: &ConvergedState,
) -> SimulationResult {
let state = &converged.state;
// Validate state vector length matches system topology
let expected_len = system.edge_count() * 2;
if state.len() != expected_len {
tracing::warn!(
state_len = state.len(),
expected_len,
"State vector length does not match system edge count; results may be incomplete"
);
}
// Build reverse mapping: NodeIndex -> component name
let node_to_name: HashMap<NodeIndex, String> = system
.registered_component_names()
.filter_map(|name| {
system
.get_component_node(name)
.map(|node| (node, name.to_string()))
})
.collect();
// Build edge incidence: for each node, collect incoming and outgoing edges
let mut incoming: HashMap<NodeIndex, Vec<EdgeIndex>> = HashMap::new();
let mut outgoing: HashMap<NodeIndex, Vec<EdgeIndex>> = HashMap::new();
for edge in system.edge_indices() {
if let Some((src, tgt)) = system.edge_endpoints(edge) {
outgoing.entry(src).or_default().push(edge);
incoming.entry(tgt).or_default().push(edge);
}
}
// --- Per-component results ---
let mut components = Vec::new();
let mut total_cooling_w: f64 = 0.0;
let mut total_heating_w: f64 = 0.0;
let mut total_compressor_power_w: f64 = 0.0;
let mut total_pump_power_w: f64 = 0.0;
let mut has_cooling = false;
let mut has_heating = false;
let mut has_compressor_power = false;
let mut has_pump_power = false;
for name in system.registered_component_names() {
let node = match system.get_component_node(name) {
Some(n) => n,
None => continue,
};
let comp = system.component(node);
let circuit = system.node_circuit(node).0;
// Energy transfers
let energy = comp
.energy_transfers(state)
.map(|(heat, work)| {
let q = heat.to_watts();
let w = work.to_watts();
// Guard against NaN/Inf propagating into system totals
let q = if q.is_finite() { q } else { 0.0 };
let w = if w.is_finite() { w } else { 0.0 };
// Accumulate system totals based on sign conventions
// Q > 0 = heat into component (evaporator absorbs heat = cooling)
// Q < 0 = heat out of component (condenser rejects heat = heating)
if q > 0.0 {
total_cooling_w += q;
has_cooling = true;
} else if q < 0.0 {
total_heating_w += q.abs();
has_heating = true;
}
// W > 0 = work by component (compressors, pumps consume power)
if w > 0.0 {
// Best-effort classification by signature string.
// Known work-producing components: Compressor, ScrewEconomizerCompressor,
// Pump, Fan. Unknown work producers are logged and default to compressor.
let sig = comp.signature().to_lowercase();
if sig.contains("compressor") || sig.contains("screw") {
total_compressor_power_w += w;
has_compressor_power = true;
} else if sig.contains("pump") || sig.contains("fan") {
total_pump_power_w += w;
has_pump_power = true;
} else {
tracing::debug!(
component = name,
signature = %comp.signature(),
work_w = w,
"Unknown work-producing component classified as compressor"
);
total_compressor_power_w += w;
has_compressor_power = true;
}
}
EnergyResult {
heat_transfer_w: q,
work_w: w,
}
});
// Mass flow from port_mass_flows (graceful fallback on Err/empty)
let mass_flows: Vec<f64> = comp
.port_mass_flows(state)
.map(|flows| flows.iter().map(|mf| mf.to_kg_per_s()).collect())
.unwrap_or_default();
// Inlet: first incoming edge
let inlet = incoming
.get(&node)
.and_then(|edges| edges.first())
.and_then(|&edge| {
let (p_idx, h_idx) = system.edge_state_indices(edge);
Some(PortState {
pressure_pa: state.get(p_idx).copied()?,
enthalpy_j_kg: state.get(h_idx).copied()?,
mass_flow_kg_s: mass_flows.first().copied(),
})
});
// Outlet: first outgoing edge
let outlet = outgoing
.get(&node)
.and_then(|edges| edges.first())
.and_then(|&edge| {
let (p_idx, h_idx) = system.edge_state_indices(edge);
Some(PortState {
pressure_pa: state.get(p_idx).copied()?,
enthalpy_j_kg: state.get(h_idx).copied()?,
mass_flow_kg_s: mass_flows.get(1).copied().or_else(|| mass_flows.first().copied()),
})
});
components.push(ComponentResult {
name: name.to_string(),
component_type: comp.signature(),
circuit,
inlet,
outlet,
energy,
});
}
// --- Per-edge results ---
let edges: Vec<EdgeResult> = system
.edge_indices()
.map(|edge| {
let (p_idx, h_idx) = system.edge_state_indices(edge);
let p = state.get(p_idx).copied();
let h = state.get(h_idx).copied();
let (source, target) = system
.edge_endpoints(edge)
.map(|(src, tgt)| {
(
node_to_name.get(&src).cloned(),
node_to_name.get(&tgt).cloned(),
)
})
.unwrap_or((None, None));
EdgeResult {
edge_id: edge.index(),
pressure_pa: p.unwrap_or(0.0),
enthalpy_j_kg: h.unwrap_or(0.0),
source,
target,
}
})
.collect();
// --- System summary ---
let summary = SystemSummary {
total_cooling_capacity_w: has_cooling.then_some(total_cooling_w),
total_heating_capacity_w: has_heating.then_some(total_heating_w),
total_compressor_power_w: has_compressor_power.then_some(total_compressor_power_w),
total_pump_power_w: has_pump_power.then_some(total_pump_power_w),
cop_cooling: if has_cooling && has_compressor_power && total_compressor_power_w > 0.0 {
Some(total_cooling_w / total_compressor_power_w)
} else {
None
},
cop_heating: if has_heating && has_compressor_power && total_compressor_power_w > 0.0 {
Some(total_heating_w / total_compressor_power_w)
} else {
None
},
};
// --- Convergence summary ---
let status = SimulationOutcome::from(converged.status.clone());
let convergence = ConvergenceSummary {
iterations: converged.iterations,
final_residual: converged.final_residual,
converged: converged.is_converged(),
status,
};
SimulationResult {
status,
convergence,
metadata: converged.metadata.clone(),
components,
edges,
summary,
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_simulation_outcome_from_convergence_status() {
assert_eq!(
SimulationOutcome::from(ConvergenceStatus::Converged),
SimulationOutcome::Converged
);
assert_eq!(
SimulationOutcome::from(ConvergenceStatus::TimedOutWithBestState),
SimulationOutcome::TimedOut
);
assert_eq!(
SimulationOutcome::from(ConvergenceStatus::ControlSaturation),
SimulationOutcome::ControlSaturation
);
}
#[test]
fn test_convergence_summary_serialization() {
let summary = ConvergenceSummary {
iterations: 42,
final_residual: 1e-8,
converged: true,
status: SimulationOutcome::Converged,
};
let json = serde_json::to_string_pretty(&summary).unwrap();
let deserialized: ConvergenceSummary = serde_json::from_str(&json).unwrap();
assert_eq!(summary, deserialized);
assert!(json.contains("\"iterations\": 42"));
assert!(json.contains("\"converged\": true"));
}
#[test]
fn test_port_state_serialization() {
let ps = PortState {
pressure_pa: 200000.0,
enthalpy_j_kg: 400000.0,
mass_flow_kg_s: Some(0.1),
};
let json = serde_json::to_string(&ps).unwrap();
let de: PortState = serde_json::from_str(&json).unwrap();
assert_eq!(ps, de);
assert!(json.contains("\"pressurePa\":"));
assert!(json.contains("\"enthalpyJKg\":"));
}
#[test]
fn test_energy_result_serialization() {
let er = EnergyResult {
heat_transfer_w: 5000.0,
work_w: 1500.0,
};
let json = serde_json::to_string(&er).unwrap();
let de: EnergyResult = serde_json::from_str(&json).unwrap();
assert_eq!(er, de);
}
#[test]
fn test_system_summary_default() {
let summary = SystemSummary::default();
assert!(summary.total_cooling_capacity_w.is_none());
assert!(summary.total_heating_capacity_w.is_none());
assert!(summary.total_compressor_power_w.is_none());
assert!(summary.total_pump_power_w.is_none());
assert!(summary.cop_cooling.is_none());
assert!(summary.cop_heating.is_none());
}
#[test]
fn test_system_summary_cop_calculation() {
let summary = SystemSummary {
total_cooling_capacity_w: Some(10000.0),
total_heating_capacity_w: Some(12000.0),
total_compressor_power_w: Some(3000.0),
total_pump_power_w: Some(200.0),
cop_cooling: Some(10000.0 / 3000.0),
cop_heating: Some(12000.0 / 3000.0),
};
assert_relative_eq!(summary.cop_cooling.unwrap(), 10.0 / 3.0, epsilon = 1e-10);
assert_relative_eq!(summary.cop_heating.unwrap(), 4.0, epsilon = 1e-10);
}
#[test]
fn test_edge_result_serialization() {
let er = EdgeResult {
edge_id: 3,
pressure_pa: 500000.0,
enthalpy_j_kg: 280000.0,
source: Some("compressor".to_string()),
target: Some("condenser".to_string()),
};
let json = serde_json::to_string(&er).unwrap();
let de: EdgeResult = serde_json::from_str(&json).unwrap();
assert_eq!(er, de);
}
#[test]
fn test_component_result_serialization() {
let cr = ComponentResult {
name: "evaporator".to_string(),
component_type: "Evaporator(UA=5.0kW/K)".to_string(),
circuit: 0,
inlet: Some(PortState {
pressure_pa: 300000.0,
enthalpy_j_kg: 250000.0,
mass_flow_kg_s: Some(0.05),
}),
outlet: Some(PortState {
pressure_pa: 290000.0,
enthalpy_j_kg: 400000.0,
mass_flow_kg_s: Some(0.05),
}),
energy: Some(EnergyResult {
heat_transfer_w: 7500.0,
work_w: 0.0,
}),
};
let json = serde_json::to_string(&cr).unwrap();
let de: ComponentResult = serde_json::from_str(&json).unwrap();
assert_eq!(cr, de);
}
#[test]
fn test_simulation_result_to_json() {
let result = SimulationResult {
status: SimulationOutcome::Converged,
convergence: ConvergenceSummary {
iterations: 10,
final_residual: 1e-9,
converged: true,
status: SimulationOutcome::Converged,
},
metadata: entropyk_solver::SimulationMetadata::new("test_hash".to_string()),
components: vec![],
edges: vec![],
summary: SystemSummary::default(),
};
let json = result.to_json().unwrap();
assert!(json.contains("\"status\": \"converged\""));
assert!(json.contains("\"iterations\": 10"));
assert!(json.contains("\"components\": []"));
assert!(json.contains("\"edges\": []"));
// Round-trip (compare non-float fields with assert_eq, floats with relative_eq)
let de: SimulationResult = serde_json::from_str(&json).unwrap();
assert_eq!(result.status, de.status);
assert_eq!(result.convergence.iterations, de.convergence.iterations);
assert_relative_eq!(
result.convergence.final_residual,
de.convergence.final_residual,
epsilon = 1e-15
);
assert_eq!(result.convergence.converged, de.convergence.converged);
assert_eq!(result.convergence.status, de.convergence.status);
assert_eq!(result.metadata.input_hash, de.metadata.input_hash);
assert_eq!(result.components, de.components);
assert_eq!(result.edges, de.edges);
assert_eq!(result.summary, de.summary);
}
}

View File

@@ -0,0 +1,518 @@
//! 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);
}
}