Update project structure and configurations
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
601
crates/entropyk/src/result.rs
Normal file
601
crates/entropyk/src/result.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
518
crates/entropyk/tests/simulation_result.rs
Normal file
518
crates/entropyk/tests/simulation_result.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user