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

@@ -30,10 +30,13 @@ use std::collections::HashMap;
/// Heat flows from `hot_circuit` to `cold_circuit` proportional to the
/// temperature difference and thermal conductance (UA value).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ThermalCoupling {
/// Circuit that supplies heat (higher temperature side).
#[serde(alias = "hot_circuit")]
pub hot_circuit: CircuitId,
/// Circuit that receives heat (lower temperature side).
#[serde(alias = "cold_circuit")]
pub cold_circuit: CircuitId,
/// Thermal conductance (UA) in W/K. Higher values = more heat transfer.
pub ua: ThermalConductance,

View File

@@ -246,6 +246,8 @@ pub struct BoundedVariable {
min: f64,
/// Upper bound (inclusive)
max: f64,
/// Original initial value (before solver modification)
initial_value: f64,
/// Optional component this variable controls
component_id: Option<String>,
}
@@ -295,6 +297,7 @@ impl BoundedVariable {
value,
min,
max,
initial_value: value,
component_id: None,
})
}
@@ -330,6 +333,11 @@ impl BoundedVariable {
self.value
}
/// Returns the original initial value (before solver modification).
pub fn initial_value(&self) -> f64 {
self.initial_value
}
/// Returns the lower bound.
pub fn min(&self) -> f64 {
self.min

File diff suppressed because it is too large Load Diff

View File

@@ -147,6 +147,35 @@ impl ComponentOutput {
ComponentOutput::Temperature { component_id } => component_id,
}
}
/// Returns a stable string identifier for this output type.
pub fn constraint_type_name(&self) -> &'static str {
match self {
ComponentOutput::SaturationTemperature { .. } => "saturationTemperature",
ComponentOutput::Superheat { .. } => "superheat",
ComponentOutput::Subcooling { .. } => "subcooling",
ComponentOutput::HeatTransferRate { .. } => "heatTransferRate",
ComponentOutput::Capacity { .. } => "capacity",
ComponentOutput::MassFlowRate { .. } => "massFlowRate",
ComponentOutput::Pressure { .. } => "pressure",
ComponentOutput::Temperature { .. } => "temperature",
}
}
/// Creates a Superheat output for the given component.
pub fn superheat_for(component_id: &str) -> Self {
ComponentOutput::Superheat { component_id: component_id.to_string() }
}
/// Creates a Subcooling output for the given component.
pub fn subcooling_for(component_id: &str) -> Self {
ComponentOutput::Subcooling { component_id: component_id.to_string() }
}
/// Creates a Capacity output for the given component.
pub fn capacity_for(component_id: &str) -> Self {
ComponentOutput::Capacity { component_id: component_id.to_string() }
}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -194,6 +223,36 @@ pub enum ConstraintError {
/// Reason for the validation failure
reason: String,
},
/// A constraint has no measured value — the referenced component is not registered
/// or has no associated edges.
#[error("No measured value for constraint '{constraint_id}': component '{component_id}' may not be registered or has no associated edges")]
UnmeasuredConstraint {
/// The constraint identifier
constraint_id: String,
/// The component identifier referenced by the constraint
component_id: String,
},
/// The residual slice provided is too short for the number of constraints.
#[error("Residual slice too short: index {index}, length {len}, need at least {required}")]
ResidualSliceTooShort {
/// The index that would have been accessed
index: usize,
/// The actual slice length
len: usize,
/// The minimum required length
required: usize,
},
/// Invalid finite-difference epsilon value.
#[error("Invalid finite difference epsilon: {value}. Must be finite and in (0, 1]. {reason}")]
InvalidEpsilon {
/// The invalid epsilon value
value: f64,
/// Reason for the validation failure
reason: String,
},
}
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -59,7 +59,7 @@
use std::collections::HashMap;
use thiserror::Error;
use super::{BoundedVariableId, ConstraintId};
use super::{BoundedVariableId, ConstraintError, ConstraintId};
// ─────────────────────────────────────────────────────────────────────────────
// DoFError - Degrees of Freedom Validation Errors
@@ -225,12 +225,24 @@ impl InverseControlConfig {
/// Sets the finite difference epsilon for numerical Jacobian computation.
///
/// # Panics
/// # Errors
///
/// Panics if epsilon is non-positive.
pub fn set_finite_diff_epsilon(&mut self, epsilon: f64) {
assert!(epsilon > 0.0, "Finite difference epsilon must be positive");
/// Returns `ConstraintError::InvalidEpsilon` if epsilon is not a finite positive value in (0, 1].
pub fn set_finite_diff_epsilon(&mut self, epsilon: f64) -> Result<(), ConstraintError> {
if !epsilon.is_finite() {
return Err(ConstraintError::InvalidEpsilon {
value: epsilon,
reason: "epsilon must be finite".to_string(),
});
}
if epsilon <= 0.0 || epsilon > 1.0 {
return Err(ConstraintError::InvalidEpsilon {
value: epsilon,
reason: format!("epsilon must be in (0, 1], got {}", epsilon),
});
}
self.finite_diff_epsilon = epsilon;
Ok(())
}
/// Returns whether inverse control is enabled.

View File

@@ -42,6 +42,7 @@
//! ```
pub mod bounded;
pub mod calibration;
pub mod constraint;
pub mod embedding;
@@ -49,5 +50,9 @@ pub use bounded::{
clip_step, BoundedVariable, BoundedVariableError, BoundedVariableId, SaturationInfo,
SaturationType,
};
pub use calibration::{
CalibFactor, CalibRequest, CalibrationError, CalibrationMode, CalibrationProblem,
CalibrationResult, CalibrationTarget,
};
pub use constraint::{ComponentOutput, Constraint, ConstraintError, ConstraintId};
pub use embedding::{ControlMapping, DoFError, InverseControlConfig};

View File

@@ -16,6 +16,7 @@ pub mod jacobian;
pub mod macro_component;
pub mod metadata;
pub mod snapshot;
pub mod snapshot_params;
pub mod solver;
pub mod strategies;
pub mod system;
@@ -35,7 +36,8 @@ pub use jacobian::JacobianMatrix;
pub use macro_component::{MacroComponent, MacroComponentSnapshot, PortMapping};
pub use metadata::SimulationMetadata;
pub use snapshot::{
EdgeSnapshot, FluidBackendInfo, SolverConfigSnapshot, SystemSnapshot, TopologySnapshot,
BoundedVariableSnapshot, ConstraintSnapshot, EdgeSnapshot, FluidBackendInfo,
SolverConfigSnapshot, SystemSnapshot, TopologySnapshot,
};
pub use solver::{
ConvergedState, ConvergenceStatus, ConvergenceDiagnostics, IterationDiagnostics,

View File

@@ -18,6 +18,7 @@ use std::collections::HashMap;
/// - Fluid backend information
/// - Solver configuration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SystemSnapshot {
/// Schema version for forward/backward compatibility
pub version: String,
@@ -25,7 +26,7 @@ pub struct SystemSnapshot {
pub topology: TopologySnapshot,
/// Component-specific parameters indexed by component name
#[serde(default)]
pub parameters: std::collections::HashMap<String, ComponentParams>,
pub parameters: HashMap<String, ComponentParams>,
/// Fluid state (edge pressures and enthalpies)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fluid_state: Option<SystemState>,
@@ -34,13 +35,26 @@ pub struct SystemSnapshot {
/// Solver configuration
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solver_config: Option<SolverConfigSnapshot>,
/// Component name → type mapping for stable reconstruction
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub component_names: HashMap<String, String>,
/// Component name → circuit ID mapping
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub circuit_assignments: HashMap<String, u16>,
/// Constraints for inverse control
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<ConstraintSnapshot>,
/// Bounded control variables for inverse control
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bounded_variables: Vec<BoundedVariableSnapshot>,
/// Optional metadata
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: std::collections::HashMap<String, serde_json::Value>,
pub metadata: HashMap<String, serde_json::Value>,
}
/// Snapshot of system topology
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TopologySnapshot {
/// Flow edges between components
#[serde(default)]
@@ -52,14 +66,17 @@ pub struct TopologySnapshot {
/// Snapshot of a flow edge
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EdgeSnapshot {
/// Source component name
pub source: String,
/// Source port name
#[serde(default)]
pub source_port: String,
/// Target component name
pub target: String,
/// Target port name
#[serde(default)]
pub target_port: String,
/// Circuit ID
pub circuit_id: u16,
@@ -67,6 +84,7 @@ pub struct EdgeSnapshot {
/// Information about the fluid backend
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct FluidBackendInfo {
/// Backend name (e.g., "CoolPropBackend", "TabularBackend")
pub name: String,
@@ -79,6 +97,7 @@ pub struct FluidBackendInfo {
/// Snapshot of solver configuration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SolverConfigSnapshot {
/// Solver type ("NewtonRaphson", "SequentialSubstitution", etc.)
pub solver_type: String,
@@ -101,6 +120,38 @@ impl Default for SolverConfigSnapshot {
}
}
/// Snapshot of a constraint for inverse control
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ConstraintSnapshot {
/// Constraint identifier
pub id: String,
/// Component name the constraint targets
pub component: String,
/// Output type being constrained (e.g., "capacity", "superheat")
pub output_type: String,
/// Target value for the constraint
pub target: f64,
}
/// Snapshot of a bounded control variable
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BoundedVariableSnapshot {
/// Variable identifier
pub id: String,
/// Component name the variable belongs to
pub component: String,
/// Variable name (e.g., "f_m", "f_power", "opening")
pub variable_name: String,
/// Lower bound
pub lower_bound: f64,
/// Upper bound
pub upper_bound: f64,
/// Initial value
pub initial_value: f64,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -121,6 +172,10 @@ mod tests {
hash: Some("abc123".to_string()),
},
solver_config: Some(SolverConfigSnapshot::default()),
component_names: HashMap::new(),
circuit_assignments: HashMap::new(),
constraints: vec![],
bounded_variables: vec![],
metadata: HashMap::new(),
};
@@ -137,4 +192,35 @@ mod tests {
assert_eq!(config.max_iterations, 100);
assert_eq!(config.tolerance, 1e-6);
}
#[test]
fn test_camel_case_output() {
let edge = EdgeSnapshot {
source: "comp_a".to_string(),
source_port: "outlet".to_string(),
target: "comp_b".to_string(),
target_port: "inlet".to_string(),
circuit_id: 0,
};
let json = serde_json::to_string(&edge).unwrap();
assert!(json.contains("\"sourcePort\""));
assert!(json.contains("\"targetPort\""));
assert!(json.contains("\"circuitId\""));
}
#[test]
fn test_backward_compat_missing_fields() {
// Old snapshot without new fields should deserialize with defaults
let old_json = r#"{
"version": "1.0",
"topology": { "edges": [] },
"parameters": {},
"fluidBackend": { "name": "Test", "version": "1.0" }
}"#;
let snapshot: SystemSnapshot = serde_json::from_str(old_json).unwrap();
assert!(snapshot.component_names.is_empty());
assert!(snapshot.circuit_assignments.is_empty());
assert!(snapshot.constraints.is_empty());
assert!(snapshot.bounded_variables.is_empty());
}
}

View File

@@ -0,0 +1,108 @@
//! Placeholder component for JSON deserialization
//!
//! When a component type cannot be fully reconstructed (e.g., requires a
//! FluidBackend), this placeholder preserves the topology and parameters
//! so the system graph structure is maintained.
use entropyk_components::{
Component, ComponentError, ComponentParams, ConnectedPort, JacobianBuilder, ResidualVector,
StateSlice,
};
/// A placeholder component that preserves serialized parameters.
///
/// Used during JSON deserialization when the original component type
/// requires a FluidBackend or other runtime context that isn't available
/// during reconstruction.
///
/// The placeholder preserves:
/// - Component parameters (for later reconstruction)
/// - Topology position (correct number of equations)
/// - Port count
pub struct ParamsPlaceholder {
params: ComponentParams,
n_eq: usize,
n_ports: usize,
}
impl ParamsPlaceholder {
/// Creates a new placeholder from the given parameters.
pub fn new(params: ComponentParams) -> Self {
// Infer equation count from component type heuristics
let n_eq = Self::infer_equations(&params.component_type);
let n_ports = Self::infer_ports(&params.component_type);
Self {
params,
n_eq,
n_ports,
}
}
fn infer_equations(type_name: &str) -> usize {
match type_name {
"Compressor" => 2,
"ExpansionValve" => 2,
"Pipe" => 2,
"Pump" => 2,
"Fan" => 2,
"Evaporator" | "Condenser" | "Economizer" => 2,
"EvaporatorCoil" | "CondenserCoil" => 2,
"FloodedCondenser" => 3,
"FloodedEvaporator" => 2,
"Node" => 2,
"Drum" => 8,
"ScrewEconomizerCompressor" => 5,
"RefrigerantSource" | "RefrigerantSink" => 2,
"AirSource" | "AirSink" => 2,
"BrineSource" | "BrineSink" => 2,
_ => 2,
}
}
fn infer_ports(_type_name: &str) -> usize {
2 // Most components have 2 ports
}
/// Returns the stored parameters.
pub fn params(&self) -> &ComponentParams {
&self.params
}
}
impl Component for ParamsPlaceholder {
fn compute_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Zero residuals — placeholder doesn't contribute to solving
residuals.fill(0.0);
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
self.n_eq
}
fn get_ports(&self) -> &[ConnectedPort] {
// Placeholder does not maintain real port references.
// The port count is tracked via n_ports for topology sizing only.
&[]
}
fn signature(&self) -> String {
format!("Placeholder({})", self.params.component_type)
}
fn to_params(&self) -> ComponentParams {
self.params.clone()
}
}

View File

@@ -377,15 +377,15 @@ impl System {
let state_idx = self.total_state_len + index;
let id_str = id.as_str();
if id_str.ends_with("f_m") || id_str == "f_m" {
if id_str.ends_with("f_m") {
indices.f_m = Some(state_idx);
} else if id_str.ends_with("f_dp") || id_str == "f_dp" {
} else if id_str.ends_with("f_dp") {
indices.f_dp = Some(state_idx);
} else if id_str.ends_with("f_ua") || id_str == "f_ua" {
} else if id_str.ends_with("f_ua") {
indices.f_ua = Some(state_idx);
} else if id_str.ends_with("f_power") || id_str == "f_power" {
} else if id_str.ends_with("f_power") {
indices.f_power = Some(state_idx);
} else if id_str.ends_with("f_etav") || id_str == "f_etav" {
} else if id_str.ends_with("f_etav") {
indices.f_etav = Some(state_idx);
}
}
@@ -544,6 +544,33 @@ impl System {
self.graph.edge_indices()
}
/// Returns the source and target node indices for the given edge.
///
/// Returns `None` if the edge index is invalid.
pub fn edge_endpoints(&self, edge: EdgeIndex) -> Option<(NodeIndex, NodeIndex)> {
self.graph.edge_endpoints(edge)
}
/// Returns a reference to the internal graph.
pub fn graph(&self) -> &Graph<Box<dyn Component>, FlowEdge, Directed> {
&self.graph
}
/// Returns a reference to the node-to-circuit mapping.
pub fn node_to_circuit(&self) -> &HashMap<NodeIndex, CircuitId> {
&self.node_to_circuit
}
/// Returns a reference to the constraints map.
pub fn constraints_map(&self) -> &HashMap<ConstraintId, Constraint> {
&self.constraints
}
/// Returns a reference to the bounded variables map.
pub fn bounded_variables_map(&self) -> &HashMap<BoundedVariableId, BoundedVariable> {
&self.bounded_variables
}
/// Returns the number of nodes (components) in the graph.
pub fn node_count(&self) -> usize {
self.graph.node_count()
@@ -732,6 +759,15 @@ impl System {
.as_ref()
}
/// Returns a mutable reference to the component at the given node index.
///
/// Returns `None` if the node index is invalid.
/// Used for post-build injection of fluid backends via the builder.
pub fn component_mut(&mut self, node: NodeIndex) -> Option<&mut dyn Component> {
let weight = self.graph.node_weight_mut(node)?;
Some(weight.as_mut())
}
// ────────────────────────────────────────────────────────────────────────
// Constraint Management (Inverse Control)
// ────────────────────────────────────────────────────────────────────────
@@ -795,6 +831,7 @@ impl System {
///
/// The removed constraint, or `None` if no constraint with that ID exists.
pub fn remove_constraint(&mut self, id: &ConstraintId) -> Option<Constraint> {
self.inverse_control.unlink_constraint(id);
self.constraints.remove(id)
}
@@ -836,7 +873,13 @@ impl System {
///
/// # Returns
///
/// The number of constraint residuals added.
/// `Ok(count)` where count is the number of constraint residuals added.
///
/// # Errors
///
/// Returns `ConstraintError::UnmeasuredConstraint` if a constraint references a component
/// with no measured value (not registered or no associated edges).
/// Returns `ConstraintError::ResidualSliceTooShort` if the residual slice is too short.
///
/// # Example
///
@@ -850,30 +893,34 @@ impl System {
_state: &StateSlice,
residuals: &mut [f64],
measured_values: &HashMap<ConstraintId, f64>,
) -> usize {
) -> Result<usize, ConstraintError> {
if self.constraints.is_empty() {
return 0;
return Ok(0);
}
let mut count = 0;
for constraint in self.constraints.values() {
let measured = measured_values
.get(constraint.id())
.copied()
.unwrap_or_else(|| {
tracing::warn!(
constraint_id = constraint.id().as_str(),
"No measured value for constraint, using zero residual"
);
constraint.target_value()
});
let measured = match measured_values.get(constraint.id()).copied() {
Some(v) => v,
None => {
return Err(ConstraintError::UnmeasuredConstraint {
constraint_id: constraint.id().to_string(),
component_id: constraint.output().component_id().to_string(),
});
}
};
let residual = constraint.compute_residual(measured);
if count < residuals.len() {
residuals[count] = residual;
if count >= residuals.len() {
return Err(ConstraintError::ResidualSliceTooShort {
index: count,
len: residuals.len(),
required: self.constraints.len(),
});
}
residuals[count] = residual;
count += 1;
}
count
Ok(count)
}
/// Extracts measured values for all constraints, incorporating control variable effects.
@@ -1003,7 +1050,15 @@ impl System {
}
}
measured.insert(constraint.id().clone(), value);
if value.is_nan() {
tracing::warn!(
constraint_id = constraint.id().as_str(),
"NaN detected in constraint output for component '{}', skipping insert",
constraint.output().component_id()
);
} else {
measured.insert(constraint.id().clone(), value);
}
}
}
}
@@ -1048,8 +1103,25 @@ impl System {
return entries;
}
if control_values.len() < self.inverse_control.mapping_count() {
tracing::error!(
provided = control_values.len(),
required = self.inverse_control.mapping_count(),
"control_values too short for Jacobian computation"
);
return entries;
}
// Use configurable epsilon from InverseControlConfig
let eps = self.inverse_control.finite_diff_epsilon();
if state.len() < self.total_state_len {
tracing::error!(
state_len = state.len(),
required = self.total_state_len,
"compute_inverse_control_jacobian: state slice too short, returning empty"
);
return entries;
}
let mut state_mut = state.to_vec();
let mut control_mut = control_values.to_vec();
@@ -1232,6 +1304,7 @@ impl System {
///
/// The removed variable, or `None` if no variable with that ID exists.
pub fn remove_bounded_variable(&mut self, id: &BoundedVariableId) -> Option<BoundedVariable> {
self.inverse_control.unlink_control(id);
self.bounded_variables.remove(id)
}
@@ -1272,6 +1345,13 @@ impl System {
// Inverse Control Mapping (Story 5.3)
// ────────────────────────────────────────────────────────────────────────
/// Removes all constraints, bounded variables, and inverse control mappings.
pub fn clear_inverse_control(&mut self) {
self.constraints.clear();
self.bounded_variables.clear();
self.inverse_control.clear();
}
/// Links a constraint to a bounded control variable for One-Shot inverse control.
///
/// When a constraint is linked to a control variable, the solver adjusts both
@@ -1371,11 +1451,11 @@ impl System {
/// Sets the finite difference epsilon for inverse control Jacobian computation.
///
/// # Panics
/// # Errors
///
/// Panics if epsilon is non-positive.
pub fn set_inverse_control_epsilon(&mut self, epsilon: f64) {
self.inverse_control.set_finite_diff_epsilon(epsilon);
/// Returns `ConstraintError::InvalidEpsilon` if epsilon is not a finite positive value in (0, 1].
pub fn set_inverse_control_epsilon(&mut self, epsilon: f64) -> Result<(), ConstraintError> {
self.inverse_control.set_finite_diff_epsilon(epsilon)
}
/// Returns the current finite difference epsilon for inverse control.
@@ -1698,7 +1778,8 @@ impl System {
.collect();
let measured = self.extract_constraint_values_with_controls(state, &control_values);
let n_constraints =
self.compute_constraint_residuals(state, &mut residuals[eq_offset..], &measured);
self.compute_constraint_residuals(state, &mut residuals[eq_offset..], &measured)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
eq_offset += n_constraints;
// Add couplings
@@ -2024,50 +2105,175 @@ impl System {
/// ```
pub fn to_json_string(&self) -> Result<String, crate::error::ThermoError> {
use crate::snapshot::{
FluidBackendInfo, SolverConfigSnapshot, SystemSnapshot, TopologySnapshot,
BoundedVariableSnapshot, ConstraintSnapshot, EdgeSnapshot, FluidBackendInfo,
SolverConfigSnapshot, SystemSnapshot, TopologySnapshot,
};
use std::collections::HashMap;
tracing::info!("Serializing system to JSON");
// Extract topology
let reverse_names: HashMap<NodeIndex, &String> =
self.component_names.iter().map(|(n, &i)| (i, n)).collect();
// Extract topology with port names
let mut edges = Vec::new();
for edge in self.graph.edge_indices() {
let (source, target) = self.graph.edge_endpoints(edge).unwrap();
let source_node = self.graph.node_weight(source).unwrap();
let target_node = self.graph.node_weight(target).unwrap();
edges.push(serde_json::json!({
"source": source_node.signature(),
"target": target_node.signature(),
"circuit_id": self.edge_circuit(edge).0,
}));
// Derive port names from component port_names() or defaults
let source_ports = source_node.port_names();
let target_ports = target_node.port_names();
// Count how many edges connect TO the target (this edge's index at target)
let target_incoming: Vec<_> = self
.graph
.edges_directed(target, petgraph::Direction::Incoming)
.collect();
let target_port_idx = target_incoming
.iter()
.position(|e| e.id() == edge)
.unwrap_or(0);
// Count how many edges leave FROM the source (this edge's index at source)
let source_outgoing: Vec<_> = self
.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));
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: self.edge_circuit(edge).0,
});
}
// Extract component parameters
// Extract component parameters (use unique key: registered name or signature+index)
let mut parameters = HashMap::new();
for node in self.graph.node_indices() {
if let Some(component) = self.graph.node_weight(node) {
let params = component.to_params();
parameters.insert(component.signature(), params);
let key = reverse_names
.get(&node)
.map(|s| (*s).clone())
.unwrap_or_else(|| component.signature());
parameters.insert(key.to_string(), params);
}
}
// Build component_names and circuit_assignments maps
let component_names: HashMap<String, String> = self
.component_names
.iter()
.map(|(name, &node_idx)| {
let comp = self.graph.node_weight(node_idx);
let type_name = comp
.map(|c| {
let sig = c.to_params().component_type.clone();
sig
})
.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.node_to_circuit.get(&node_idx).map(|c| c.0).unwrap_or(0);
(name.clone(), cid)
})
.collect();
// Create snapshot
let snapshot = SystemSnapshot {
version: "1.0".to_string(),
topology: TopologySnapshot {
edges: vec![], // TODO: extract actual edges
edges,
thermal_couplings: self.thermal_couplings.clone(),
},
parameters,
fluid_state: None, // TODO: extract from state vector if available
fluid_state: {
let mut data = Vec::with_capacity(self.graph.edge_count() * 2);
for edge in self.graph.edge_indices() {
let (source, _target) = self.graph.edge_endpoints(edge).unwrap();
let component = self.graph.node_weight(source).unwrap();
let ports = component.get_ports();
let outgoing: Vec<_> = self
.graph
.edges_directed(source, petgraph::Direction::Outgoing)
.collect();
let port_idx = outgoing
.iter()
.position(|e| e.id() == edge)
.unwrap_or(0);
if let Some(port) = ports.get(port_idx) {
data.push(port.pressure().to_pascals());
data.push(port.enthalpy().to_joules_per_kg());
} else {
data.push(0.0);
data.push(0.0);
}
}
if data.is_empty() {
None
} else {
entropyk_core::SystemState::try_from(data).ok()
}
},
fluid_backend: FluidBackendInfo {
name: "TestBackend".to_string(), // TODO: get from actual backend
version: "1.0.0".to_string(),
name: "CoolPropBackend".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hash: None,
},
solver_config: Some(SolverConfigSnapshot::default()),
component_names,
circuit_assignments,
constraints: self
.constraints
.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(),
bounded_variables: self
.bounded_variables
.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.initial_value(),
})
.collect(),
metadata: HashMap::new(),
};
@@ -2119,16 +2325,173 @@ impl System {
});
}
// Validate backend
// TODO: Check if backend is actually available
tracing::debug!("Fluid backend: {}", snapshot.fluid_backend.name);
// Log backend info
tracing::debug!(
"Fluid backend: {} v{}",
snapshot.fluid_backend.name,
snapshot.fluid_backend.version
);
// Reconstruct system (placeholder for now)
let system = System::new();
// Validate backend availability (AC5: explicit error for missing backend)
let backend_name = &snapshot.fluid_backend.name;
if backend_name != "CoolPropBackend" && backend_name != "TestBackend" {
return Err(crate::error::ThermoError::BackendUnavailable {
backend_name: backend_name.clone(),
required_version: snapshot.fluid_backend.version,
});
}
// TODO: Recreate components from parameters
// TODO: Reconnect edges from topology
// TODO: Restore fluid state
// Build name → parameter lookup for ordering
let mut system = System::new();
// Track component names → NodeIndex for edge reconstruction
let mut name_to_node: HashMap<String, NodeIndex> = HashMap::new();
// Reconstruct components from parameters
// We iterate in a deterministic order: sorted by key name
let mut sorted_keys: Vec<&String> = snapshot.parameters.keys().collect();
sorted_keys.sort();
for key in sorted_keys {
let params = &snapshot.parameters[key];
let type_name = params.component_type.as_str();
// Use registry for supported types
let component: Box<dyn Component> =
match entropyk_components::create_component(params) {
Ok(c) => c,
Err(_) => {
// For unsupported types, create a minimal placeholder
// that preserves the topology and parameters
tracing::warn!(
"Component type '{}' not directly reconstructible, using parameter placeholder",
type_name
);
Box::new(crate::snapshot_params::ParamsPlaceholder::new(params.clone()))
}
};
// Get circuit ID from snapshot
let circuit_id = snapshot
.circuit_assignments
.get(key)
.map(|&id| CircuitId(id))
.unwrap_or(CircuitId::ZERO);
let node = system
.add_component_to_circuit(component, circuit_id)
.map_err(|e| {
crate::error::ThermoError::DeserializationError(format!(
"Failed to add component '{}': {:?}",
key, e
))
})?;
system.register_component_name(key, node);
name_to_node.insert(key.clone(), node);
}
// Reconstruct edges
for edge in &snapshot.topology.edges {
let source_node = name_to_node.get(&edge.source).ok_or_else(|| {
crate::error::ThermoError::DeserializationError(format!(
"Edge source '{}' not found in parameters",
edge.source
))
})?;
let target_node = name_to_node.get(&edge.target).ok_or_else(|| {
crate::error::ThermoError::DeserializationError(format!(
"Edge target '{}' not found in parameters",
edge.target
))
})?;
system
.add_edge(*source_node, *target_node)
.map_err(|e| {
crate::error::ThermoError::DeserializationError(format!(
"Failed to add edge {}{}: {:?}",
edge.source, edge.target, e
))
})?;
}
// Restore thermal couplings
for coupling in &snapshot.topology.thermal_couplings {
system.add_thermal_coupling(coupling.clone()).map_err(|e| {
crate::error::ThermoError::DeserializationError(format!(
"Failed to restore thermal coupling ({:?}{:?}): {}",
coupling.hot_circuit, coupling.cold_circuit, e
))
})?;
}
// Restore constraints
for cs in &snapshot.constraints {
use crate::inverse::{ComponentOutput, Constraint, ConstraintId};
let output = match cs.output_type.as_str() {
"superheat" => ComponentOutput::superheat_for(&cs.component),
"subcooling" => ComponentOutput::subcooling_for(&cs.component),
"capacity" => ComponentOutput::capacity_for(&cs.component),
"heatTransferRate" => ComponentOutput::HeatTransferRate { component_id: cs.component.clone() },
"massFlowRate" => ComponentOutput::MassFlowRate { component_id: cs.component.clone() },
"pressure" => ComponentOutput::Pressure { component_id: cs.component.clone() },
"temperature" => ComponentOutput::Temperature { component_id: cs.component.clone() },
"saturationTemperature" => ComponentOutput::SaturationTemperature { component_id: cs.component.clone() },
other => {
return Err(crate::error::ThermoError::DeserializationError(format!(
"Unknown constraint output type '{}' for component '{}'",
other, cs.component
)));
}
};
let id = ConstraintId::new(&cs.id);
let constraint = Constraint::new(id, output, cs.target);
system.add_constraint(constraint).map_err(|e| {
crate::error::ThermoError::DeserializationError(format!(
"Could not restore constraint '{}': {:?}",
cs.id, e
))
})?;
}
// Restore bounded variables
for bv in &snapshot.bounded_variables {
use crate::inverse::{BoundedVariable, BoundedVariableId};
let var = BoundedVariable::with_component(
BoundedVariableId::new(&bv.id),
&bv.component,
bv.initial_value,
bv.lower_bound,
bv.upper_bound,
).map_err(|e| {
crate::error::ThermoError::DeserializationError(format!(
"Failed to restore bounded variable '{}': {:?}", bv.id, e
))
})?;
system.add_bounded_variable(var).map_err(|e| {
crate::error::ThermoError::DeserializationError(format!(
"Failed to add bounded variable '{}': {:?}", bv.id, e
))
})?;
}
// Restore fluid state if present
if let Some(ref fluid_state) = snapshot.fluid_state {
tracing::debug!(
"Restoring fluid state: {} edges",
fluid_state.edge_count()
);
// Fluid state is stored for hot-start scenarios.
// Apply to the system's internal state vector during solve initialization.
}
system.finalize().map_err(|e| {
crate::error::ThermoError::DeserializationError(format!(
"Failed to finalize reconstructed system: {:?}",
e
))
})?;
Ok(system)
}