4134 lines
151 KiB
Rust
4134 lines
151 KiB
Rust
//! System graph structure for thermodynamic simulation.
|
||
//!
|
||
//! This module provides the core graph representation of a thermodynamic system,
|
||
//! where nodes are components and edges represent flow connections. Edges index
|
||
//! into the solver's state vector (P and h per edge).
|
||
//!
|
||
//! Multi-circuit support (Story 3.3): A machine can have up to 5 independent
|
||
//! circuits (valid circuit IDs: 0, 1, 2, 3, 4). Each node belongs to exactly one
|
||
//! circuit. Flow edges connect only nodes within the same circuit.
|
||
|
||
use entropyk_components::{
|
||
validate_port_continuity, Component, ComponentError, ConnectionError, JacobianBuilder,
|
||
ResidualVector, StateSlice,
|
||
};
|
||
use petgraph::algo;
|
||
use petgraph::graph::{EdgeIndex, Graph, NodeIndex};
|
||
use petgraph::visit::EdgeRef;
|
||
use petgraph::Directed;
|
||
use std::collections::HashMap;
|
||
|
||
use crate::coupling::{has_circular_dependencies, ThermalCoupling};
|
||
use crate::error::{AddEdgeError, TopologyError};
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableError, BoundedVariableId, Constraint, ConstraintError,
|
||
ConstraintId, DoFError, InverseControlConfig,
|
||
};
|
||
use entropyk_core::{CircuitId, Temperature};
|
||
|
||
/// Maximum circuit ID (inclusive). Machine supports up to 5 circuits.
|
||
pub const MAX_CIRCUIT_ID: u16 = 4;
|
||
|
||
/// Weight for flow edges in the system graph.
|
||
///
|
||
/// Each edge represents a flow connection between two component ports and stores
|
||
/// the state vector indices for pressure (P) and enthalpy (h) at that connection.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct FlowEdge {
|
||
/// State vector index for pressure (Pa)
|
||
pub state_index_p: usize,
|
||
/// State vector index for enthalpy (J/kg)
|
||
pub state_index_h: usize,
|
||
}
|
||
|
||
/// System graph structure.
|
||
///
|
||
/// Nodes are components (`Box<dyn Component>`), edges are flow connections with
|
||
/// state indices. The state vector layout is:
|
||
///
|
||
/// ```text
|
||
/// [P_edge0, h_edge0, P_edge1, h_edge1, ...]
|
||
/// ```
|
||
///
|
||
/// Edge order follows the graph's internal edge iteration order (stable after
|
||
/// `finalize()` is called).
|
||
pub struct System {
|
||
graph: Graph<Box<dyn Component>, FlowEdge, Directed>,
|
||
/// Maps EdgeIndex to (state_index_p, state_index_h) - built in finalize()
|
||
edge_to_state: HashMap<EdgeIndex, (usize, usize)>,
|
||
/// Maps NodeIndex to CircuitId. Nodes without entry default to circuit 0.
|
||
node_to_circuit: HashMap<NodeIndex, CircuitId>,
|
||
/// Thermal couplings between circuits (heat transfer without fluid mixing).
|
||
thermal_couplings: Vec<ThermalCoupling>,
|
||
/// Constraints for inverse control (output - target = 0)
|
||
constraints: HashMap<ConstraintId, Constraint>,
|
||
/// Bounded control variables for inverse control (with box constraints)
|
||
bounded_variables: HashMap<BoundedVariableId, BoundedVariable>,
|
||
/// Inverse control configuration (constraint → control variable mappings)
|
||
inverse_control: InverseControlConfig,
|
||
/// Registry of component names for constraint validation.
|
||
/// Maps human-readable names (e.g., "evaporator") to NodeIndex.
|
||
component_names: HashMap<String, NodeIndex>,
|
||
finalized: bool,
|
||
total_state_len: usize,
|
||
}
|
||
|
||
impl System {
|
||
/// Creates a new empty system graph.
|
||
pub fn new() -> Self {
|
||
Self {
|
||
graph: Graph::new(),
|
||
edge_to_state: HashMap::new(),
|
||
node_to_circuit: HashMap::new(),
|
||
thermal_couplings: Vec::new(),
|
||
constraints: HashMap::new(),
|
||
bounded_variables: HashMap::new(),
|
||
inverse_control: InverseControlConfig::new(),
|
||
component_names: HashMap::new(),
|
||
finalized: false,
|
||
total_state_len: 0,
|
||
}
|
||
}
|
||
|
||
/// Adds a component as a node in the default circuit (circuit 0) and returns its node index.
|
||
///
|
||
/// For multi-circuit machines, use [`add_component_to_circuit`](Self::add_component_to_circuit).
|
||
pub fn add_component(&mut self, component: Box<dyn Component>) -> NodeIndex {
|
||
self.add_component_to_circuit(component, CircuitId::ZERO)
|
||
.unwrap()
|
||
}
|
||
|
||
/// Adds a component as a node in the specified circuit and returns its node index.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `TopologyError::TooManyCircuits` if `circuit_id.0 > 4`.
|
||
pub fn add_component_to_circuit(
|
||
&mut self,
|
||
component: Box<dyn Component>,
|
||
circuit_id: CircuitId,
|
||
) -> Result<NodeIndex, TopologyError> {
|
||
if circuit_id.0 > MAX_CIRCUIT_ID {
|
||
return Err(TopologyError::TooManyCircuits {
|
||
requested: circuit_id.0,
|
||
});
|
||
}
|
||
self.finalized = false;
|
||
let node_idx = self.graph.add_node(component);
|
||
self.node_to_circuit.insert(node_idx, circuit_id);
|
||
Ok(node_idx)
|
||
}
|
||
|
||
/// Returns the circuit ID for a node, or circuit 0 if not found (backward compat).
|
||
pub fn node_circuit(&self, node: NodeIndex) -> CircuitId {
|
||
self.node_to_circuit
|
||
.get(&node)
|
||
.copied()
|
||
.unwrap_or(CircuitId::ZERO)
|
||
}
|
||
|
||
/// Returns the circuit ID for an edge based on its source node.
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if the edge index is invalid.
|
||
pub fn edge_circuit(&self, edge: EdgeIndex) -> CircuitId {
|
||
let (src, _tgt) = self.graph.edge_endpoints(edge).expect("invalid edge index");
|
||
self.node_circuit(src)
|
||
}
|
||
|
||
/// Adds a flow edge from `source` to `target` without port validation.
|
||
///
|
||
/// **No port compatibility validation is performed.** Use
|
||
/// [`add_edge_with_ports`](Self::add_edge_with_ports) for components with ports to validate
|
||
/// fluid, pressure, and enthalpy continuity. This method is intended for components without
|
||
/// ports (e.g. mock components in tests).
|
||
///
|
||
/// Flow edges connect only nodes within the same circuit. Cross-circuit connections
|
||
/// are rejected (thermal coupling is Story 3.4).
|
||
///
|
||
/// State indices are assigned when `finalize()` is called.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `TopologyError::CrossCircuitConnection` if source and target are in different circuits.
|
||
pub fn add_edge(
|
||
&mut self,
|
||
source: NodeIndex,
|
||
target: NodeIndex,
|
||
) -> Result<EdgeIndex, TopologyError> {
|
||
let src_circuit = self.node_circuit(source);
|
||
let tgt_circuit = self.node_circuit(target);
|
||
if src_circuit != tgt_circuit {
|
||
tracing::warn!(
|
||
"Cross-circuit edge rejected: source circuit {}, target circuit {}",
|
||
src_circuit.0,
|
||
tgt_circuit.0
|
||
);
|
||
return Err(TopologyError::CrossCircuitConnection {
|
||
source_circuit: src_circuit.0,
|
||
target_circuit: tgt_circuit.0,
|
||
});
|
||
}
|
||
|
||
// Safety check: Warn if connecting components with ports using the non-validating method
|
||
if let (Some(src), Some(tgt)) = (
|
||
self.graph.node_weight(source),
|
||
self.graph.node_weight(target),
|
||
) {
|
||
if !src.get_ports().is_empty() || !tgt.get_ports().is_empty() {
|
||
tracing::warn!(
|
||
"add_edge called on components with ports (src: {:?}, tgt: {:?}). \
|
||
This bypasses port validation. Use add_edge_with_ports instead.",
|
||
source,
|
||
target
|
||
);
|
||
}
|
||
}
|
||
|
||
self.finalized = false;
|
||
Ok(self.graph.add_edge(
|
||
source,
|
||
target,
|
||
FlowEdge {
|
||
state_index_p: 0,
|
||
state_index_h: 0,
|
||
},
|
||
))
|
||
}
|
||
|
||
/// Adds a flow edge from `source` outlet port to `target` inlet port with validation.
|
||
///
|
||
/// Validates circuit membership (same circuit), then fluid compatibility, pressure and
|
||
/// enthalpy continuity using port.rs tolerances. For 2-port components: `source_port_idx=1`
|
||
/// (outlet), `target_port_idx=0` (inlet).
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `AddEdgeError::Topology` if source and target are in different circuits.
|
||
/// Returns `AddEdgeError::Connection` if ports are incompatible (fluid, pressure, or enthalpy mismatch).
|
||
pub fn add_edge_with_ports(
|
||
&mut self,
|
||
source: NodeIndex,
|
||
source_port_idx: usize,
|
||
target: NodeIndex,
|
||
target_port_idx: usize,
|
||
) -> Result<EdgeIndex, AddEdgeError> {
|
||
// Circuit validation first
|
||
let src_circuit = self.node_circuit(source);
|
||
let tgt_circuit = self.node_circuit(target);
|
||
if src_circuit != tgt_circuit {
|
||
tracing::warn!(
|
||
"Cross-circuit edge rejected: source circuit {}, target circuit {}",
|
||
src_circuit.0,
|
||
tgt_circuit.0
|
||
);
|
||
return Err(TopologyError::CrossCircuitConnection {
|
||
source_circuit: src_circuit.0,
|
||
target_circuit: tgt_circuit.0,
|
||
}
|
||
.into());
|
||
}
|
||
|
||
let source_comp = self
|
||
.graph
|
||
.node_weight(source)
|
||
.ok_or_else(|| ConnectionError::InvalidNodeIndex(source.index()))?;
|
||
let target_comp = self
|
||
.graph
|
||
.node_weight(target)
|
||
.ok_or_else(|| ConnectionError::InvalidNodeIndex(target.index()))?;
|
||
|
||
let source_ports = source_comp.get_ports();
|
||
let target_ports = target_comp.get_ports();
|
||
|
||
if source_ports.is_empty() && target_ports.is_empty() {
|
||
// No ports: add edge without validation (backward compat)
|
||
self.finalized = false;
|
||
return Ok(self.graph.add_edge(
|
||
source,
|
||
target,
|
||
FlowEdge {
|
||
state_index_p: 0,
|
||
state_index_h: 0,
|
||
},
|
||
));
|
||
}
|
||
|
||
if source_port_idx >= source_ports.len() {
|
||
return Err(ConnectionError::InvalidPortIndex {
|
||
index: source_port_idx,
|
||
port_count: source_ports.len(),
|
||
max_index: source_ports.len().saturating_sub(1),
|
||
}
|
||
.into());
|
||
}
|
||
if target_port_idx >= target_ports.len() {
|
||
return Err(ConnectionError::InvalidPortIndex {
|
||
index: target_port_idx,
|
||
port_count: target_ports.len(),
|
||
max_index: target_ports.len().saturating_sub(1),
|
||
}
|
||
.into());
|
||
}
|
||
|
||
let outlet = &source_ports[source_port_idx];
|
||
let inlet = &target_ports[target_port_idx];
|
||
if let Err(ref e) = validate_port_continuity(outlet, inlet) {
|
||
tracing::warn!("Port validation failed: {}", e);
|
||
return Err(e.clone().into());
|
||
}
|
||
|
||
self.finalized = false;
|
||
Ok(self.graph.add_edge(
|
||
source,
|
||
target,
|
||
FlowEdge {
|
||
state_index_p: 0,
|
||
state_index_h: 0,
|
||
},
|
||
))
|
||
}
|
||
|
||
/// Finalizes the graph: builds edge→state index mapping and validates topology.
|
||
///
|
||
/// # State vector layout
|
||
///
|
||
/// The state vector has length `2 * edge_count`. For each edge (in graph iteration order):
|
||
/// - `state[2*i]` = pressure at edge i (Pa)
|
||
/// - `state[2*i + 1]` = enthalpy at edge i (J/kg)
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `TopologyError` if:
|
||
/// - Any node is isolated (no edges)
|
||
/// - The graph is empty (no components)
|
||
pub fn finalize(&mut self) -> Result<(), TopologyError> {
|
||
self.validate_topology()?;
|
||
|
||
if !self.thermal_couplings.is_empty() && has_circular_dependencies(self.thermal_couplings())
|
||
{
|
||
tracing::warn!("Circular thermal coupling detected, simultaneous solving required");
|
||
}
|
||
|
||
self.edge_to_state.clear();
|
||
let mut idx = 0usize;
|
||
for edge_idx in self.graph.edge_indices() {
|
||
let (p_idx, h_idx) = (idx, idx + 1);
|
||
self.edge_to_state.insert(edge_idx, (p_idx, h_idx));
|
||
if let Some(weight) = self.graph.edge_weight_mut(edge_idx) {
|
||
weight.state_index_p = p_idx;
|
||
weight.state_index_h = h_idx;
|
||
}
|
||
idx += 2;
|
||
}
|
||
self.finalized = true;
|
||
|
||
// Notify each component about its position in the global state vector.
|
||
// Collect context first (to avoid borrow conflicts), then apply.
|
||
//
|
||
// State offset: for the parent System the state layout is a flat array of
|
||
// 2 * edge_count entries for the *parent's own* edges. An embedded
|
||
// MacroComponent has its internal state appended after the parent edges,
|
||
// addressed via its own global_state_offset. We start with `2 * edge_count` as
|
||
// the base offset, and accumulate `internal_state_len` for each component.
|
||
let mut current_offset = 2 * self.graph.edge_count();
|
||
|
||
// Gather (node_idx, offset, incident_edge_indices) before mutating nodes.
|
||
#[allow(clippy::type_complexity)]
|
||
let mut node_context: Vec<(
|
||
petgraph::graph::NodeIndex,
|
||
usize,
|
||
Vec<(usize, usize)>,
|
||
)> = Vec::new();
|
||
for node_idx in self.graph.node_indices() {
|
||
let component = self.graph.node_weight(node_idx).unwrap();
|
||
let mut incident: Vec<(usize, usize)> = Vec::new();
|
||
for edge_ref in self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Incoming)
|
||
{
|
||
if let Some(&ph) = self.edge_to_state.get(&edge_ref.id()) {
|
||
incident.push(ph);
|
||
}
|
||
}
|
||
for edge_ref in self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Outgoing)
|
||
{
|
||
if let Some(&ph) = self.edge_to_state.get(&edge_ref.id()) {
|
||
incident.push(ph);
|
||
}
|
||
}
|
||
node_context.push((node_idx, current_offset, incident));
|
||
|
||
// Advance the global offset by this component's internal state length
|
||
current_offset += component.internal_state_len();
|
||
}
|
||
|
||
self.total_state_len = current_offset;
|
||
|
||
// Notify components about their calibration control variables (Story 5.5)
|
||
let mut comp_calib_indices: HashMap<String, entropyk_core::CalibIndices> = HashMap::new();
|
||
for (index, id) in self.inverse_control.linked_controls().enumerate() {
|
||
if let Some(bounded_var) = self.bounded_variables.get(id) {
|
||
if let Some(comp_id) = bounded_var.component_id() {
|
||
let indices = comp_calib_indices.entry(comp_id.to_string()).or_default();
|
||
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" {
|
||
indices.f_m = Some(state_idx);
|
||
} else if id_str.ends_with("f_dp") || id_str == "f_dp" {
|
||
indices.f_dp = Some(state_idx);
|
||
} else if id_str.ends_with("f_ua") || id_str == "f_ua" {
|
||
indices.f_ua = Some(state_idx);
|
||
} else if id_str.ends_with("f_power") || id_str == "f_power" {
|
||
indices.f_power = Some(state_idx);
|
||
} else if id_str.ends_with("f_etav") || id_str == "f_etav" {
|
||
indices.f_etav = Some(state_idx);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Now mutate each node weight (component) with the gathered context.
|
||
for (node_idx, offset, incident) in node_context {
|
||
if let Some(component) = self.graph.node_weight_mut(node_idx) {
|
||
component.set_system_context(offset, &incident);
|
||
|
||
// If we registered a name for this node, check if we have calib indices for it
|
||
if let Some((name, _)) = self.component_names.iter().find(|(_, &n)| n == node_idx) {
|
||
if let Some(&indices) = comp_calib_indices.get(name) {
|
||
component.set_calib_indices(indices);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !self.constraints.is_empty() {
|
||
match self.validate_inverse_control_dof() {
|
||
Ok(()) => {
|
||
tracing::debug!(
|
||
constraint_count = self.constraints.len(),
|
||
control_count = self.inverse_control.mapping_count(),
|
||
"Inverse control DoF validation passed"
|
||
);
|
||
}
|
||
Err(DoFError::UnderConstrainedSystem { .. }) => {
|
||
tracing::warn!(
|
||
constraint_count = self.constraints.len(),
|
||
control_count = self.inverse_control.mapping_count(),
|
||
"Under-constrained inverse control system - solver may still converge"
|
||
);
|
||
}
|
||
Err(DoFError::OverConstrainedSystem {
|
||
constraint_count,
|
||
control_count,
|
||
equation_count,
|
||
unknown_count,
|
||
}) => {
|
||
tracing::warn!(
|
||
constraint_count,
|
||
control_count,
|
||
equation_count,
|
||
unknown_count,
|
||
"Over-constrained inverse control system - add more control variables or remove constraints"
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("Inverse control DoF validation error: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates the topology: no isolated nodes and edge circuit consistency.
|
||
///
|
||
/// Note: "All ports connected" validation requires port→edge association
|
||
/// (Story 3.2 Port Compatibility Validation).
|
||
fn validate_topology(&self) -> Result<(), TopologyError> {
|
||
let node_count = self.graph.node_count();
|
||
if node_count == 0 {
|
||
return Ok(());
|
||
}
|
||
|
||
for node_idx in self.graph.node_indices() {
|
||
let degree = self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Incoming)
|
||
.count()
|
||
+ self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Outgoing)
|
||
.count();
|
||
if degree == 0 {
|
||
return Err(TopologyError::IsolatedNode {
|
||
node_index: node_idx.index(),
|
||
});
|
||
}
|
||
}
|
||
|
||
// Validate that all edges connect nodes within the same circuit
|
||
for edge_idx in self.graph.edge_indices() {
|
||
if let Some((src, tgt)) = self.graph.edge_endpoints(edge_idx) {
|
||
let src_circuit = self.node_circuit(src);
|
||
let tgt_circuit = self.node_circuit(tgt);
|
||
if src_circuit != tgt_circuit {
|
||
return Err(TopologyError::CrossCircuitConnection {
|
||
source_circuit: src_circuit.0,
|
||
target_circuit: tgt_circuit.0,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Returns the documented state vector layout.
|
||
///
|
||
/// Layout: `[P_edge0, h_edge0, P_edge1, h_edge1, ...]` where each edge (in
|
||
/// graph iteration order) contributes 2 entries: pressure (Pa) then enthalpy (J/kg).
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if `finalize()` has not been called.
|
||
pub fn state_layout(&self) -> &'static str {
|
||
assert!(self.finalized, "call finalize() before state_layout()");
|
||
"[P_edge0, h_edge0, P_edge1, h_edge1, ...] — 2 per edge (pressure Pa, enthalpy J/kg)"
|
||
}
|
||
|
||
/// Returns the length of the state vector: `2 * edge_count + internal_components_length`.
|
||
///
|
||
/// Note: This returns the physical state vector length. For the full solver state vector
|
||
/// including control variables, use [`full_state_vector_len`](Self::full_state_vector_len).
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if `finalize()` has not been called.
|
||
pub fn state_vector_len(&self) -> usize {
|
||
assert!(self.finalized, "call finalize() before state_vector_len()");
|
||
self.total_state_len
|
||
}
|
||
|
||
/// Returns the state indices (P, h) for the given edge.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `(state_index_p, state_index_h)` for the edge.
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if `finalize()` has not been called or if `edge_id` is invalid.
|
||
pub fn edge_state_indices(&self, edge_id: EdgeIndex) -> (usize, usize) {
|
||
assert!(
|
||
self.finalized,
|
||
"call finalize() before edge_state_indices()"
|
||
);
|
||
*self
|
||
.edge_to_state
|
||
.get(&edge_id)
|
||
.expect("invalid edge index")
|
||
}
|
||
|
||
/// Returns the number of edges in the graph.
|
||
pub fn edge_count(&self) -> usize {
|
||
self.graph.edge_count()
|
||
}
|
||
|
||
/// Returns an iterator over all edge indices in the graph.
|
||
pub fn edge_indices(&self) -> impl Iterator<Item = EdgeIndex> + '_ {
|
||
self.graph.edge_indices()
|
||
}
|
||
|
||
/// Returns the number of nodes (components) in the graph.
|
||
pub fn node_count(&self) -> usize {
|
||
self.graph.node_count()
|
||
}
|
||
|
||
/// Returns the number of distinct circuits in the machine.
|
||
///
|
||
/// Circuits are identified by the unique circuit IDs present in `node_to_circuit`.
|
||
/// Empty system returns 0. Systems with components always return >= 1 since
|
||
/// all components are assigned to a circuit (defaulting to circuit 0).
|
||
/// Valid circuit IDs are 0 through 4 (inclusive), supporting up to 5 circuits.
|
||
pub fn circuit_count(&self) -> usize {
|
||
if self.graph.node_count() == 0 {
|
||
return 0;
|
||
}
|
||
let mut ids: Vec<u16> = self.node_to_circuit.values().map(|c| c.0).collect();
|
||
if ids.is_empty() {
|
||
// This shouldn't happen since add_component adds to node_to_circuit,
|
||
// but handle defensively
|
||
return 1;
|
||
}
|
||
ids.sort_unstable();
|
||
ids.dedup();
|
||
ids.len()
|
||
}
|
||
|
||
/// Returns an iterator over node indices belonging to the given circuit.
|
||
pub fn circuit_nodes(&self, circuit_id: CircuitId) -> impl Iterator<Item = NodeIndex> + '_ {
|
||
self.graph.node_indices().filter(move |&idx| {
|
||
self.node_to_circuit
|
||
.get(&idx)
|
||
.copied()
|
||
.unwrap_or(CircuitId::ZERO)
|
||
== circuit_id
|
||
})
|
||
}
|
||
|
||
/// Returns an iterator over edge indices belonging to the given circuit.
|
||
///
|
||
/// An edge belongs to a circuit if both its source and target nodes are in that circuit.
|
||
pub fn circuit_edges(&self, circuit_id: CircuitId) -> impl Iterator<Item = EdgeIndex> + '_ {
|
||
self.graph.edge_indices().filter(move |&edge_idx| {
|
||
let (src, tgt) = self.graph.edge_endpoints(edge_idx).expect("valid edge");
|
||
self.node_circuit(src) == circuit_id && self.node_circuit(tgt) == circuit_id
|
||
})
|
||
}
|
||
|
||
/// Checks if a circuit has any components.
|
||
fn circuit_exists(&self, circuit_id: CircuitId) -> bool {
|
||
self.node_to_circuit.values().any(|&c| c == circuit_id)
|
||
}
|
||
|
||
/// Adds a thermal coupling between two circuits.
|
||
///
|
||
/// Thermal couplings represent heat exchangers that transfer heat from a "hot"
|
||
/// circuit to a "cold" circuit without fluid mixing. Heat flows from hot to cold
|
||
/// proportional to the temperature difference and thermal conductance (UA).
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `coupling` - The thermal coupling to add
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// The index of the added coupling in the internal storage.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `TopologyError::InvalidCircuitForCoupling` if either circuit
|
||
/// referenced in the coupling does not exist in the system.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```no_run
|
||
/// use entropyk_solver::{System, ThermalCoupling, CircuitId};
|
||
/// use entropyk_core::ThermalConductance;
|
||
/// use entropyk_components::Component;
|
||
/// # fn make_mock() -> Box<dyn Component> { unimplemented!() }
|
||
///
|
||
/// let mut sys = System::new();
|
||
/// sys.add_component_to_circuit(make_mock(), CircuitId(0)).unwrap();
|
||
/// sys.add_component_to_circuit(make_mock(), CircuitId(1)).unwrap();
|
||
///
|
||
/// let coupling = ThermalCoupling::new(
|
||
/// CircuitId(0),
|
||
/// CircuitId(1),
|
||
/// ThermalConductance::from_watts_per_kelvin(1000.0),
|
||
/// );
|
||
/// let idx = sys.add_thermal_coupling(coupling).unwrap();
|
||
/// ```
|
||
pub fn add_thermal_coupling(
|
||
&mut self,
|
||
coupling: ThermalCoupling,
|
||
) -> Result<usize, TopologyError> {
|
||
// Validate that both circuits exist
|
||
if !self.circuit_exists(coupling.hot_circuit) {
|
||
return Err(TopologyError::InvalidCircuitForCoupling {
|
||
circuit_id: coupling.hot_circuit.0,
|
||
});
|
||
}
|
||
if !self.circuit_exists(coupling.cold_circuit) {
|
||
return Err(TopologyError::InvalidCircuitForCoupling {
|
||
circuit_id: coupling.cold_circuit.0,
|
||
});
|
||
}
|
||
|
||
self.finalized = false;
|
||
self.thermal_couplings.push(coupling);
|
||
Ok(self.thermal_couplings.len() - 1)
|
||
}
|
||
|
||
/// Returns the number of thermal couplings in the system.
|
||
pub fn thermal_coupling_count(&self) -> usize {
|
||
self.thermal_couplings.len()
|
||
}
|
||
|
||
/// Returns a reference to all thermal couplings.
|
||
pub fn thermal_couplings(&self) -> &[ThermalCoupling] {
|
||
&self.thermal_couplings
|
||
}
|
||
|
||
/// Returns a reference to a specific thermal coupling by index.
|
||
pub fn get_thermal_coupling(&self, index: usize) -> Option<&ThermalCoupling> {
|
||
self.thermal_couplings.get(index)
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Component Name Registry (for constraint validation)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Registers a human-readable name for a component node.
|
||
///
|
||
/// This name can be used in constraints to reference the component.
|
||
/// For example, register "evaporator" to reference it in a superheat constraint.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `name` - Human-readable name for the component (e.g., "evaporator", "condenser")
|
||
/// * `node` - The NodeIndex returned from `add_component()` or `add_component_to_circuit()`
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `true` if the name was newly registered, `false` if the name was already in use
|
||
/// (in which case the mapping is updated to the new node).
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,ignore
|
||
/// let mut sys = System::new();
|
||
/// let evap_node = sys.add_component(make_evaporator());
|
||
/// sys.register_component_name("evaporator", evap_node);
|
||
///
|
||
/// // Now constraints can reference "evaporator"
|
||
/// let constraint = Constraint::new(
|
||
/// ConstraintId::new("superheat_control"),
|
||
/// ComponentOutput::Superheat { component_id: "evaporator".to_string() },
|
||
/// 5.0,
|
||
/// );
|
||
/// sys.add_constraint(constraint)?; // Validates "evaporator" exists
|
||
/// ```
|
||
pub fn register_component_name(&mut self, name: &str, node: NodeIndex) -> bool {
|
||
self.component_names
|
||
.insert(name.to_string(), node)
|
||
.is_none()
|
||
}
|
||
|
||
/// Returns the NodeIndex for a registered component name, or None if not found.
|
||
pub fn get_component_node(&self, name: &str) -> Option<NodeIndex> {
|
||
self.component_names.get(name).copied()
|
||
}
|
||
|
||
/// Returns the names of all registered components.
|
||
pub fn registered_component_names(&self) -> impl Iterator<Item = &str> {
|
||
self.component_names.keys().map(|s| s.as_str())
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Constraint Management (Inverse Control)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Adds a constraint for inverse control.
|
||
///
|
||
/// Constraints define desired output conditions. During solving, the solver
|
||
/// will attempt to find input values that satisfy all constraints.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `constraint` - The constraint to add
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `ConstraintError::DuplicateId` if a constraint with the same ID
|
||
/// already exists.
|
||
/// Returns `ConstraintError::InvalidReference` if the component referenced
|
||
/// in the constraint's output has not been registered via `register_component_name()`.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,ignore
|
||
/// use entropyk_solver::inverse::{Constraint, ConstraintId, ComponentOutput};
|
||
///
|
||
/// let constraint = Constraint::new(
|
||
/// ConstraintId::new("superheat_control"),
|
||
/// ComponentOutput::Superheat {
|
||
/// component_id: "evaporator".to_string()
|
||
/// },
|
||
/// 5.0, // target: 5K superheat
|
||
/// );
|
||
///
|
||
/// system.add_constraint(constraint)?;
|
||
/// ```
|
||
pub fn add_constraint(&mut self, constraint: Constraint) -> Result<(), ConstraintError> {
|
||
let id = constraint.id().clone();
|
||
if self.constraints.contains_key(&id) {
|
||
return Err(ConstraintError::DuplicateId { id });
|
||
}
|
||
|
||
// AC2: Validate that the component referenced in the constraint exists
|
||
let component_id = constraint.output().component_id();
|
||
if !self.component_names.contains_key(component_id) {
|
||
return Err(ConstraintError::InvalidReference {
|
||
component_id: component_id.to_string(),
|
||
});
|
||
}
|
||
|
||
self.constraints.insert(id, constraint);
|
||
Ok(())
|
||
}
|
||
|
||
/// Removes a constraint by ID.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `id` - The constraint identifier to remove
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// The removed constraint, or `None` if no constraint with that ID exists.
|
||
pub fn remove_constraint(&mut self, id: &ConstraintId) -> Option<Constraint> {
|
||
self.constraints.remove(id)
|
||
}
|
||
|
||
/// Returns the number of constraints in the system.
|
||
pub fn constraint_count(&self) -> usize {
|
||
self.constraints.len()
|
||
}
|
||
|
||
/// Returns a reference to all constraints.
|
||
pub fn constraints(&self) -> impl Iterator<Item = &Constraint> {
|
||
self.constraints.values()
|
||
}
|
||
|
||
/// Returns a reference to a specific constraint by ID.
|
||
pub fn get_constraint(&self, id: &ConstraintId) -> Option<&Constraint> {
|
||
self.constraints.get(id)
|
||
}
|
||
|
||
/// Returns the number of constraint residual equations.
|
||
///
|
||
/// Each constraint adds one equation to the residual vector.
|
||
pub fn constraint_residual_count(&self) -> usize {
|
||
self.constraints.len()
|
||
}
|
||
|
||
/// Computes constraint residuals and appends them to the provided vector.
|
||
///
|
||
/// This method computes the residual for each constraint:
|
||
///
|
||
/// $$r_{constraint} = f(x) - y_{target}$$
|
||
///
|
||
/// where $f(x)$ is the measured output value and $y_{target}$ is the constraint target.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `state` - Current system state (edge pressures and enthalpies)
|
||
/// * `residuals` - Residual vector to append constraint residuals to
|
||
/// * `measured_values` - Map of constraint IDs to their measured values (from component state)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// The number of constraint residuals added.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,ignore
|
||
/// let mut residuals = ResidualVector::new();
|
||
/// let measured = system.extract_constraint_values_with_controls(&state, &control);
|
||
/// let count = system.compute_constraint_residuals(&state, &mut residuals, &measured);
|
||
/// ```
|
||
pub fn compute_constraint_residuals(
|
||
&self,
|
||
_state: &StateSlice,
|
||
residuals: &mut [f64],
|
||
measured_values: &HashMap<ConstraintId, f64>,
|
||
) -> usize {
|
||
if self.constraints.is_empty() {
|
||
return 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 residual = constraint.compute_residual(measured);
|
||
if count < residuals.len() {
|
||
residuals[count] = residual;
|
||
}
|
||
count += 1;
|
||
}
|
||
count
|
||
}
|
||
|
||
/// Extracts measured values for all constraints, incorporating control variable effects.
|
||
///
|
||
/// This method computes the measured output value for each constraint, taking into
|
||
/// account the current state and control variable values. For MIMO (Multi-Input
|
||
/// Multi-Output) systems, ALL control variables can affect ALL constraint outputs
|
||
/// due to system coupling.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `state` - Current system state (edge pressures and enthalpies)
|
||
/// * `control_values` - Current values of control variables
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// A map from constraint ID to measured output value.
|
||
///
|
||
/// # Cross-Coupling for MIMO Systems
|
||
///
|
||
/// In a real thermodynamic system, control variables are coupled:
|
||
/// - Compressor speed affects both capacity AND superheat
|
||
/// - Valve opening affects both superheat AND capacity
|
||
///
|
||
/// The mock implementation simulates this coupling for Jacobian cross-derivative
|
||
/// computation. Each control variable has a primary effect (on its linked constraint)
|
||
/// and a secondary effect (on other constraints) to simulate thermal coupling.
|
||
pub fn extract_constraint_values_with_controls(
|
||
&self,
|
||
state: &StateSlice,
|
||
control_values: &[f64],
|
||
) -> HashMap<ConstraintId, f64> {
|
||
let mut measured = HashMap::new();
|
||
if self.constraints.is_empty() {
|
||
return measured;
|
||
}
|
||
|
||
// Build a map of control variable index -> component_id it controls
|
||
// This uses the proper component_id() field from BoundedVariable
|
||
let mut control_to_component: HashMap<usize, &str> = HashMap::new();
|
||
for (j, bounded_var_id) in self.inverse_control.linked_controls().enumerate() {
|
||
if let Some(bounded_var) = self.bounded_variables.get(bounded_var_id) {
|
||
if let Some(comp_id) = bounded_var.component_id() {
|
||
control_to_component.insert(j, comp_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
for constraint in self.constraints.values() {
|
||
let comp_id = constraint.output().component_id();
|
||
if let Some(&node_idx) = self.component_names.get(comp_id) {
|
||
// Find first associated edge (incoming or outgoing)
|
||
let mut edge_opt = self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Incoming)
|
||
.next();
|
||
if edge_opt.is_none() {
|
||
edge_opt = self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Outgoing)
|
||
.next();
|
||
}
|
||
|
||
if let Some(edge) = edge_opt {
|
||
if let Some(&(p_idx, h_idx)) = self.edge_to_state.get(&edge.id()) {
|
||
let mut value = match constraint.output() {
|
||
crate::inverse::ComponentOutput::Pressure { .. } => state[p_idx],
|
||
crate::inverse::ComponentOutput::Temperature { .. } => 300.0, // Mock for MVP without fluid backend
|
||
crate::inverse::ComponentOutput::Superheat { .. } => {
|
||
// Mock numerical value sensitive to BOTH P and h for Jacobian calculation
|
||
state[h_idx] / 1000.0 - (state[p_idx] / 1e5)
|
||
}
|
||
crate::inverse::ComponentOutput::Subcooling { .. } => {
|
||
(state[p_idx] / 1e5) - state[h_idx] / 1000.0
|
||
}
|
||
crate::inverse::ComponentOutput::Capacity { .. } => {
|
||
// Mock capacity: h * mass_flow. Let's just use h for Jacobian sensitivity
|
||
state[h_idx] * 10.0
|
||
}
|
||
_ => 0.0,
|
||
};
|
||
|
||
// MIMO Cross-Coupling: ALL control variables can affect ALL constraints
|
||
// In a real system, changing compressor speed affects both capacity and superheat,
|
||
// and changing valve opening also affects both. We simulate this coupling here.
|
||
//
|
||
// ⚠️ MOCK COEFFICIENTS: These values (10.0, 2.0) are placeholders for testing.
|
||
// They create a well-conditioned Jacobian with off-diagonal entries that allow
|
||
// Newton-Raphson to converge. Real implementations should replace these with
|
||
// actual component physics derived from:
|
||
// - Component characteristic curves (compressor map, valve Cv curve)
|
||
// - Thermodynamic property calculations via fluid backend
|
||
// - Energy and mass balance equations
|
||
//
|
||
// The 5:1 ratio between primary and secondary effects is arbitrary but creates
|
||
// a diagonally-dominant Jacobian that converges reliably. See Story 5.4
|
||
// Review Follow-ups for tracking real thermodynamics integration.
|
||
//
|
||
// For each control variable:
|
||
// - Primary effect (10.0): if control is linked to this constraint's component
|
||
// - Secondary effect (2.0): cross-coupling to other constraints
|
||
const MIMO_PRIMARY_COEFF: f64 = 10.0;
|
||
const MIMO_SECONDARY_COEFF: f64 = 2.0;
|
||
|
||
for (j, _bounded_var_id) in
|
||
self.inverse_control.linked_controls().enumerate()
|
||
{
|
||
if j >= control_values.len() {
|
||
continue;
|
||
}
|
||
let ctrl_val = control_values[j];
|
||
|
||
// Check if this control variable is primarily associated with this component
|
||
let is_primary = control_to_component
|
||
.get(&j)
|
||
.map_or(false, |&c| c == comp_id);
|
||
|
||
if is_primary {
|
||
// Primary effect: strong influence on the controlled output
|
||
// e.g., valve opening strongly affects superheat
|
||
value += ctrl_val * MIMO_PRIMARY_COEFF;
|
||
} else {
|
||
// Secondary (cross-coupling) effect: weaker influence
|
||
// e.g., compressor speed also affects superheat (through mass flow)
|
||
// This creates the off-diagonal entries in the MIMO Jacobian
|
||
value += ctrl_val * MIMO_SECONDARY_COEFF;
|
||
}
|
||
}
|
||
|
||
measured.insert(constraint.id().clone(), value);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
measured
|
||
}
|
||
|
||
/// Computes the Jacobian entries for inverse control constraints.
|
||
///
|
||
/// For each constraint→control mapping, adds ∂r/∂x entries to the Jacobian:
|
||
///
|
||
/// $$\frac{\partial r}{\partial x_{control}} = \frac{\partial (measured - target)}{\partial x_{control}} = \frac{\partial measured}{\partial x_{control}}$$
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `state` - Current system state
|
||
/// * `row_offset` - Starting row index for constraint equations in the Jacobian
|
||
/// * `control_values` - Current values of control variables (for finite difference)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// A vector of `(row, col, value)` tuples representing Jacobian entries.
|
||
///
|
||
/// # Note
|
||
///
|
||
/// MVP uses finite difference approximation. Future versions may use analytical
|
||
/// derivatives from components for better accuracy and performance.
|
||
///
|
||
/// # Finite Difference Epsilon
|
||
///
|
||
/// Uses the epsilon configured in `InverseControlConfig` (default 1e-6) for central
|
||
/// finite differences. Configure via `set_inverse_control_epsilon()`.
|
||
pub fn compute_inverse_control_jacobian(
|
||
&self,
|
||
state: &StateSlice,
|
||
row_offset: usize,
|
||
control_values: &[f64],
|
||
) -> Vec<(usize, usize, f64)> {
|
||
let mut entries = Vec::new();
|
||
|
||
if self.inverse_control.mapping_count() == 0 {
|
||
return entries;
|
||
}
|
||
|
||
// Use configurable epsilon from InverseControlConfig
|
||
let eps = self.inverse_control.finite_diff_epsilon();
|
||
let mut state_mut = state.to_vec();
|
||
let mut control_mut = control_values.to_vec();
|
||
|
||
// 1. Compute ∂r_i / ∂x_j (Partial derivatives with respect to PHYSICAL states P, h)
|
||
// We do this per constraint to keep perturbations localized where possible
|
||
for (i, (constraint_id, _)) in self.inverse_control.mappings().enumerate() {
|
||
let row = row_offset + i;
|
||
if let Some(constraint) = self.constraints.get(constraint_id) {
|
||
let comp_id = constraint.output().component_id();
|
||
|
||
if let Some(&node_idx) = self.component_names.get(comp_id) {
|
||
let mut state_indices = Vec::new();
|
||
// Gather all edge state indices for this component
|
||
for edge in self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Incoming)
|
||
{
|
||
if let Some(&(p_idx, h_idx)) = self.edge_to_state.get(&edge.id()) {
|
||
if !state_indices.contains(&p_idx) {
|
||
state_indices.push(p_idx);
|
||
}
|
||
if !state_indices.contains(&h_idx) {
|
||
state_indices.push(h_idx);
|
||
}
|
||
}
|
||
}
|
||
for edge in self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Outgoing)
|
||
{
|
||
if let Some(&(p_idx, h_idx)) = self.edge_to_state.get(&edge.id()) {
|
||
if !state_indices.contains(&p_idx) {
|
||
state_indices.push(p_idx);
|
||
}
|
||
if !state_indices.contains(&h_idx) {
|
||
state_indices.push(h_idx);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Central finite difference for Jacobian entries w.r.t physical state
|
||
for &col in &state_indices {
|
||
let orig = state_mut[col];
|
||
|
||
state_mut[col] = orig + eps;
|
||
let plus = self
|
||
.extract_constraint_values_with_controls(&state_mut, control_values);
|
||
let val_plus = plus.get(constraint_id).copied().unwrap_or(0.0);
|
||
|
||
state_mut[col] = orig - eps;
|
||
let minus = self
|
||
.extract_constraint_values_with_controls(&state_mut, control_values);
|
||
let val_minus = minus.get(constraint_id).copied().unwrap_or(0.0);
|
||
|
||
state_mut[col] = orig; // Restore
|
||
|
||
let derivative = (val_plus - val_minus) / (2.0 * eps);
|
||
if derivative.abs() > 1e-10 {
|
||
entries.push((row, col, derivative));
|
||
tracing::trace!(
|
||
constraint = constraint_id.as_str(),
|
||
row,
|
||
col,
|
||
derivative,
|
||
"Inverse control Jacobian actual ∂r/∂state entry"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Compute ∂r_i / ∂u_j (Cross-derivatives with respect to CONTROL variables)
|
||
// Here we must form the full dense block because control variable 'j' could affect constraint 'i'
|
||
// even if they are not explicitly linked, due to system coupling.
|
||
let control_offset = self.state_vector_len();
|
||
|
||
for (j, (_, bounded_var_id)) in self.inverse_control.mappings().enumerate() {
|
||
let col = control_offset + j;
|
||
let orig = control_mut[j];
|
||
|
||
// Perturb control variable +eps
|
||
control_mut[j] = orig + eps;
|
||
let plus = self.extract_constraint_values_with_controls(state, &control_mut);
|
||
|
||
// Perturb control variable -eps
|
||
control_mut[j] = orig - eps;
|
||
let minus = self.extract_constraint_values_with_controls(state, &control_mut);
|
||
|
||
control_mut[j] = orig; // Restore
|
||
|
||
// For this perturbed control variable j, compute the effect on ALL constraints i
|
||
for (i, (constraint_id, _)) in self.inverse_control.mappings().enumerate() {
|
||
let row = row_offset + i;
|
||
|
||
let val_plus = plus.get(constraint_id).copied().unwrap_or(0.0);
|
||
let val_minus = minus.get(constraint_id).copied().unwrap_or(0.0);
|
||
let derivative = (val_plus - val_minus) / (2.0 * eps);
|
||
|
||
// We add it even if it's 0 to maintain block structure (optional but safe)
|
||
// However, for performance we only add non-zeros
|
||
if derivative.abs() > 1e-10 {
|
||
entries.push((row, col, derivative));
|
||
tracing::trace!(
|
||
constraint = ?constraint_id,
|
||
control = ?bounded_var_id,
|
||
row, col, derivative,
|
||
"Inverse control Jacobian cross-derivative ∂r/∂u entry"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
entries
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Bounded Variable Management (Inverse Control)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Adds a bounded control variable for inverse control.
|
||
///
|
||
/// Bounded variables ensure Newton steps stay within physical limits
|
||
/// (e.g., valve position 0.0 to 1.0, VFD speed 0.3 to 1.0).
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `variable` - The bounded variable to add
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `BoundedVariableError::DuplicateId` if a variable with the same ID
|
||
/// already exists.
|
||
/// Returns `BoundedVariableError::InvalidComponent` if the variable is associated
|
||
/// with a component that has not been registered via `register_component_name()`.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,ignore
|
||
/// use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
|
||
///
|
||
/// let valve = BoundedVariable::new(
|
||
/// BoundedVariableId::new("expansion_valve"),
|
||
/// 0.5, // initial: 50% open
|
||
/// 0.0, // min: fully closed
|
||
/// 1.0, // max: fully open
|
||
/// )?;
|
||
///
|
||
/// system.add_bounded_variable(valve)?;
|
||
/// ```
|
||
pub fn add_bounded_variable(
|
||
&mut self,
|
||
variable: BoundedVariable,
|
||
) -> Result<(), BoundedVariableError> {
|
||
let id = variable.id().clone();
|
||
if self.bounded_variables.contains_key(&id) {
|
||
return Err(BoundedVariableError::DuplicateId { id });
|
||
}
|
||
|
||
// Validate that the component referenced in the variable exists (if any)
|
||
if let Some(component_id) = variable.component_id() {
|
||
if !self.component_names.contains_key(component_id) {
|
||
return Err(BoundedVariableError::InvalidComponent {
|
||
component_id: component_id.to_string(),
|
||
});
|
||
}
|
||
}
|
||
|
||
self.bounded_variables.insert(id, variable);
|
||
Ok(())
|
||
}
|
||
|
||
/// Removes a bounded variable by ID.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `id` - The bounded variable identifier to remove
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// The removed variable, or `None` if no variable with that ID exists.
|
||
pub fn remove_bounded_variable(&mut self, id: &BoundedVariableId) -> Option<BoundedVariable> {
|
||
self.bounded_variables.remove(id)
|
||
}
|
||
|
||
/// Returns the number of bounded variables in the system.
|
||
pub fn bounded_variable_count(&self) -> usize {
|
||
self.bounded_variables.len()
|
||
}
|
||
|
||
/// Returns an iterator over all bounded variables.
|
||
pub fn bounded_variables(&self) -> impl Iterator<Item = &BoundedVariable> {
|
||
self.bounded_variables.values()
|
||
}
|
||
|
||
/// Returns a reference to a specific bounded variable by ID.
|
||
pub fn get_bounded_variable(&self, id: &BoundedVariableId) -> Option<&BoundedVariable> {
|
||
self.bounded_variables.get(id)
|
||
}
|
||
|
||
/// Returns a mutable reference to a specific bounded variable by ID.
|
||
pub fn get_bounded_variable_mut(
|
||
&mut self,
|
||
id: &BoundedVariableId,
|
||
) -> Option<&mut BoundedVariable> {
|
||
self.bounded_variables.get_mut(id)
|
||
}
|
||
|
||
/// Checks if any bounded variables are saturated (at bounds).
|
||
///
|
||
/// Returns a vector of saturation info for all variables currently at bounds.
|
||
pub fn saturated_variables(&self) -> Vec<crate::inverse::SaturationInfo> {
|
||
self.bounded_variables
|
||
.values()
|
||
.filter_map(|v| v.is_saturated())
|
||
.collect()
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Inverse Control Mapping (Story 5.3)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
/// 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
|
||
/// the edge states AND the control variable simultaneously to satisfy all
|
||
/// equations (cycle equations + constraint equations).
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `constraint_id` - The constraint to link
|
||
/// * `bounded_variable_id` - The control variable to adjust
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `DoFError::ConstraintNotFound` if the constraint doesn't exist.
|
||
/// Returns `DoFError::BoundedVariableNotFound` if the bounded variable doesn't exist.
|
||
/// Returns `DoFError::AlreadyLinked` if the constraint is already linked.
|
||
/// Returns `DoFError::ControlAlreadyLinked` if the control is already linked.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,ignore
|
||
/// system.link_constraint_to_control(
|
||
/// &ConstraintId::new("superheat_control"),
|
||
/// &BoundedVariableId::new("expansion_valve"),
|
||
/// )?;
|
||
/// ```
|
||
pub fn link_constraint_to_control(
|
||
&mut self,
|
||
constraint_id: &ConstraintId,
|
||
bounded_variable_id: &BoundedVariableId,
|
||
) -> Result<(), DoFError> {
|
||
if !self.constraints.contains_key(constraint_id) {
|
||
return Err(DoFError::ConstraintNotFound {
|
||
constraint_id: constraint_id.clone(),
|
||
});
|
||
}
|
||
|
||
if !self.bounded_variables.contains_key(bounded_variable_id) {
|
||
return Err(DoFError::BoundedVariableNotFound {
|
||
bounded_variable_id: bounded_variable_id.clone(),
|
||
});
|
||
}
|
||
|
||
self.inverse_control
|
||
.link(constraint_id.clone(), bounded_variable_id.clone())
|
||
}
|
||
|
||
/// Unlinks a constraint from its control variable.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `constraint_id` - The constraint to unlink
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// The bounded variable ID that was linked, or `None` if not linked.
|
||
pub fn unlink_constraint(&mut self, constraint_id: &ConstraintId) -> Option<BoundedVariableId> {
|
||
self.inverse_control.unlink_constraint(constraint_id)
|
||
}
|
||
|
||
/// Unlinks a control variable from its constraint.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `bounded_variable_id` - The control variable to unlink
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// The constraint ID that was linked, or `None` if not linked.
|
||
pub fn unlink_control(
|
||
&mut self,
|
||
bounded_variable_id: &BoundedVariableId,
|
||
) -> Option<ConstraintId> {
|
||
self.inverse_control.unlink_control(bounded_variable_id)
|
||
}
|
||
|
||
/// Returns the control variable linked to a constraint.
|
||
pub fn get_control_for_constraint(
|
||
&self,
|
||
constraint_id: &ConstraintId,
|
||
) -> Option<&BoundedVariableId> {
|
||
self.inverse_control.get_control(constraint_id)
|
||
}
|
||
|
||
/// Returns the constraint linked to a control variable.
|
||
pub fn get_constraint_for_control(
|
||
&self,
|
||
bounded_variable_id: &BoundedVariableId,
|
||
) -> Option<&ConstraintId> {
|
||
self.inverse_control.get_constraint(bounded_variable_id)
|
||
}
|
||
|
||
/// Returns the number of constraint-to-control mappings.
|
||
pub fn inverse_control_mapping_count(&self) -> usize {
|
||
self.inverse_control.mapping_count()
|
||
}
|
||
|
||
/// Sets the finite difference epsilon for inverse control Jacobian computation.
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// 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 the current finite difference epsilon for inverse control.
|
||
pub fn inverse_control_epsilon(&self) -> f64 {
|
||
self.inverse_control.finite_diff_epsilon()
|
||
}
|
||
|
||
/// Returns an iterator over linked control variable IDs.
|
||
pub fn linked_controls(&self) -> impl Iterator<Item = &BoundedVariableId> {
|
||
self.inverse_control.linked_controls()
|
||
}
|
||
|
||
/// Checks if a constraint is linked to a control variable.
|
||
pub fn is_constraint_linked(&self, constraint_id: &ConstraintId) -> bool {
|
||
self.inverse_control.is_constraint_linked(constraint_id)
|
||
}
|
||
|
||
/// Checks if a control variable is linked to a constraint.
|
||
pub fn is_control_linked(&self, bounded_variable_id: &BoundedVariableId) -> bool {
|
||
self.inverse_control.is_control_linked(bounded_variable_id)
|
||
}
|
||
|
||
/// Validates degrees of freedom for inverse control.
|
||
///
|
||
/// For a well-posed system with inverse control:
|
||
///
|
||
/// $$n_{equations} = n_{edge\_eqs} + n_{constraints}$$
|
||
/// $$n_{unknowns} = n_{edge\_unknowns} + n_{controls}$$
|
||
///
|
||
/// The system is balanced when: $n_{equations} = n_{unknowns}$
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `Ok(())` if the system is well-posed (balanced or under-constrained).
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `DoFError::OverConstrainedSystem` if there are more equations than unknowns.
|
||
/// Returns `DoFError::UnderConstrainedSystem` if there are fewer equations than unknowns (warning only).
|
||
pub fn validate_inverse_control_dof(&self) -> Result<(), DoFError> {
|
||
let n_edge_unknowns = self.total_state_len;
|
||
let n_controls = self.inverse_control.mapping_count();
|
||
let n_constraints = self.constraints.len();
|
||
let n_unknowns = n_edge_unknowns + n_controls;
|
||
|
||
let n_edge_eqs: usize = self
|
||
.graph
|
||
.node_indices()
|
||
.map(|node| {
|
||
self.graph
|
||
.node_weight(node)
|
||
.map(|c| c.n_equations())
|
||
.unwrap_or(0)
|
||
})
|
||
.sum();
|
||
let n_equations = n_edge_eqs + n_constraints;
|
||
|
||
if n_equations > n_unknowns {
|
||
Err(DoFError::OverConstrainedSystem {
|
||
constraint_count: n_constraints,
|
||
control_count: n_controls,
|
||
equation_count: n_equations,
|
||
unknown_count: n_unknowns,
|
||
})
|
||
} else if n_equations < n_unknowns {
|
||
Err(DoFError::UnderConstrainedSystem {
|
||
constraint_count: n_constraints,
|
||
control_count: n_controls,
|
||
equation_count: n_equations,
|
||
unknown_count: n_unknowns,
|
||
})
|
||
} else {
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Returns the state vector index for a control variable.
|
||
///
|
||
/// Control variables are appended after edge states in the state vector:
|
||
///
|
||
/// ```text
|
||
/// State Vector = [Edge States | Control Variables | Thermal Coupling Temps]
|
||
/// [P0, h0, P1, h1, ... | ctrl0, ctrl1, ... | T_hot0, T_cold0, ...]
|
||
/// ```
|
||
///
|
||
/// The index for control variable `i` is: `2 * edge_count + i`
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `id` - The bounded variable identifier
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// The state vector index, or `None` if the variable is not linked.
|
||
pub fn control_variable_state_index(&self, id: &BoundedVariableId) -> Option<usize> {
|
||
if !self.inverse_control.is_control_linked(id) {
|
||
return None;
|
||
}
|
||
|
||
let base = self.total_state_len;
|
||
for (index, linked_id) in self.inverse_control.linked_controls().enumerate() {
|
||
if linked_id == id {
|
||
return Some(base + index);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Returns the bounded variable for a given state index.
|
||
pub fn get_bounded_variable_by_state_index(
|
||
&self,
|
||
state_index: usize,
|
||
) -> Option<&BoundedVariable> {
|
||
let base = self.total_state_len;
|
||
if state_index < base {
|
||
return None;
|
||
}
|
||
let control_idx = state_index - base;
|
||
self.inverse_control
|
||
.linked_controls()
|
||
.nth(control_idx)
|
||
.and_then(|id| self.bounded_variables.get(id))
|
||
}
|
||
|
||
/// Returns the bounds (min, max) for a given state index if it corresponds to a bounded control variable.
|
||
pub fn get_bounds_for_state_index(&self, state_index: usize) -> Option<(f64, f64)> {
|
||
self.get_bounded_variable_by_state_index(state_index)
|
||
.map(|var| (var.min(), var.max()))
|
||
}
|
||
|
||
/// Returns the total state vector length including control variables.
|
||
///
|
||
/// ```text
|
||
/// full_state_len = 2 * edge_count + control_variable_count + 2 * thermal_coupling_count
|
||
/// ```
|
||
pub fn full_state_vector_len(&self) -> usize {
|
||
self.total_state_len
|
||
+ self.inverse_control.mapping_count()
|
||
+ 2 * self.thermal_couplings.len()
|
||
}
|
||
|
||
/// Returns an ordered list of linked control variable IDs with their state indices.
|
||
///
|
||
/// Useful for constructing the state vector or Jacobian columns.
|
||
pub fn control_variable_indices(&self) -> Vec<(&BoundedVariableId, usize)> {
|
||
let base = self.total_state_len;
|
||
self.inverse_control
|
||
.linked_controls()
|
||
.enumerate()
|
||
.map(|(i, id)| (id, base + i))
|
||
.collect()
|
||
}
|
||
|
||
/// Returns the number of coupling residual equations (one per thermal coupling).
|
||
///
|
||
/// The solver must reserve this many rows in the residual vector for coupling
|
||
/// heat balance equations. Use [`coupling_residuals`](Self::coupling_residuals) to fill them.
|
||
pub fn coupling_residual_count(&self) -> usize {
|
||
self.thermal_couplings.len()
|
||
}
|
||
|
||
/// Fills coupling residuals into `out`.
|
||
///
|
||
/// For each thermal coupling, the residual is the heat transfer rate Q (W) into the cold
|
||
/// circuit: Q = η·UA·(T_hot − T_cold). The solver typically uses this in a heat balance
|
||
/// (e.g. r = Q_actual − Q_expected). Temperatures must be in Kelvin.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `temperatures` - One (T_hot_K, T_cold_K) per coupling; length must equal
|
||
/// `thermal_coupling_count()`. The solver obtains these from state (e.g. P, h → T via fluid backend).
|
||
/// * `out` - Slice to write residuals; length must be at least `coupling_residual_count()`.
|
||
pub fn coupling_residuals(&self, temperatures: &[(f64, f64)], out: &mut [f64]) {
|
||
assert!(
|
||
temperatures.len() == self.thermal_couplings.len(),
|
||
"temperatures.len() must equal thermal_coupling_count()"
|
||
);
|
||
assert!(
|
||
out.len() >= self.thermal_couplings.len(),
|
||
"out.len() must be at least coupling_residual_count()"
|
||
);
|
||
for (i, coupling) in self.thermal_couplings.iter().enumerate() {
|
||
let (t_hot_k, t_cold_k) = temperatures[i];
|
||
let t_hot = Temperature::from_kelvin(t_hot_k);
|
||
let t_cold = Temperature::from_kelvin(t_cold_k);
|
||
out[i] = crate::coupling::compute_coupling_heat(coupling, t_hot, t_cold);
|
||
}
|
||
}
|
||
|
||
/// Returns Jacobian entries for coupling residuals with respect to temperature state.
|
||
///
|
||
/// The solver state may include temperature unknowns for coupling interfaces (or T derived from P, h).
|
||
/// For each coupling i: ∂Q_i/∂T_hot = η·UA, ∂Q_i/∂T_cold = −η·UA.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `row_offset` - First row index for coupling equations in the global residual vector.
|
||
/// * `t_hot_cols` - State column index for T_hot per coupling; length = `thermal_coupling_count()`.
|
||
/// * `t_cold_cols` - State column index for T_cold per coupling; length = `thermal_coupling_count()`.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `(row, col, value)` tuples for the Jacobian. Row is `row_offset + coupling_index`.
|
||
pub fn coupling_jacobian_entries(
|
||
&self,
|
||
row_offset: usize,
|
||
t_hot_cols: &[usize],
|
||
t_cold_cols: &[usize],
|
||
) -> Vec<(usize, usize, f64)> {
|
||
assert!(
|
||
t_hot_cols.len() == self.thermal_couplings.len()
|
||
&& t_cold_cols.len() == self.thermal_couplings.len(),
|
||
"t_hot_cols and t_cold_cols length must equal thermal_coupling_count()"
|
||
);
|
||
let mut entries = Vec::with_capacity(2 * self.thermal_couplings.len());
|
||
for (i, coupling) in self.thermal_couplings.iter().enumerate() {
|
||
let dr_dt_hot = coupling.efficiency * coupling.ua.to_watts_per_kelvin();
|
||
let dr_dt_cold = -dr_dt_hot;
|
||
let row = row_offset + i;
|
||
entries.push((row, t_hot_cols[i], dr_dt_hot));
|
||
entries.push((row, t_cold_cols[i], dr_dt_cold));
|
||
}
|
||
entries
|
||
}
|
||
|
||
/// Returns true if the graph contains a cycle.
|
||
///
|
||
/// Refrigeration circuits form cycles (compressor → condenser → valve → evaporator → compressor),
|
||
/// so cycles are expected and valid.
|
||
pub fn is_cyclic(&self) -> bool {
|
||
algo::is_cyclic_directed(&self.graph)
|
||
}
|
||
|
||
/// Iterates over (component, edge_indices) for Jacobian assembly.
|
||
///
|
||
/// For each node, yields the component and a map from edge index to (state_index_p, state_index_h)
|
||
/// for edges incident to that node (incoming and outgoing).
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if `finalize()` has not been called.
|
||
pub fn traverse_for_jacobian(
|
||
&self,
|
||
) -> impl Iterator<Item = (NodeIndex, &dyn Component, Vec<(EdgeIndex, usize, usize)>)> {
|
||
assert!(
|
||
self.finalized,
|
||
"call finalize() before traverse_for_jacobian()"
|
||
);
|
||
|
||
self.graph.node_indices().map(move |node_idx| {
|
||
let component = self.graph.node_weight(node_idx).unwrap();
|
||
let mut edge_indices = Vec::new();
|
||
|
||
for edge_ref in self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Incoming)
|
||
{
|
||
let edge_idx = edge_ref.id();
|
||
if let Some(&(p, h)) = self.edge_to_state.get(&edge_idx) {
|
||
edge_indices.push((edge_idx, p, h));
|
||
}
|
||
}
|
||
for edge_ref in self
|
||
.graph
|
||
.edges_directed(node_idx, petgraph::Direction::Outgoing)
|
||
{
|
||
let edge_idx = edge_ref.id();
|
||
if let Some(&(p, h)) = self.edge_to_state.get(&edge_idx) {
|
||
edge_indices.push((edge_idx, p, h));
|
||
}
|
||
}
|
||
|
||
(node_idx, component.as_ref(), edge_indices)
|
||
})
|
||
}
|
||
|
||
/// Assembles residuals from all components.
|
||
///
|
||
/// Components receive the full state slice and write to their equation indices.
|
||
/// Equation indices are computed from component order and `n_equations()`.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns `ComponentError::InvalidResidualDimensions` if `residuals.len()` is
|
||
/// less than the total number of equations across all components.
|
||
pub fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
let mut total_eqs: usize = self
|
||
.traverse_for_jacobian()
|
||
.map(|(_, c, _)| c.n_equations())
|
||
.sum();
|
||
total_eqs += self.constraints.len() + self.coupling_residual_count();
|
||
|
||
if residuals.len() < total_eqs {
|
||
return Err(ComponentError::InvalidResidualDimensions {
|
||
expected: total_eqs,
|
||
actual: residuals.len(),
|
||
});
|
||
}
|
||
|
||
let mut eq_offset = 0;
|
||
for (_node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
|
||
let n = component.n_equations();
|
||
if n > 0 {
|
||
let mut temp = vec![0.0; n];
|
||
component.compute_residuals(state, &mut temp)?;
|
||
residuals[eq_offset..eq_offset + n].copy_from_slice(&temp);
|
||
}
|
||
eq_offset += n;
|
||
}
|
||
|
||
// Add constraints
|
||
let control_values: Vec<f64> = self
|
||
.control_variable_indices()
|
||
.into_iter()
|
||
.map(|(_, idx)| state[idx])
|
||
.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);
|
||
eq_offset += n_constraints;
|
||
|
||
// Add couplings
|
||
let n_couplings = self.coupling_residual_count();
|
||
if n_couplings > 0 {
|
||
// MVP: We need real temperatures in K from components, using dummy 300K for now
|
||
let temps = vec![(300.0, 300.0); n_couplings];
|
||
self.coupling_residuals(&temps, &mut residuals[eq_offset..eq_offset + n_couplings]);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Assembles Jacobian entries from all components.
|
||
///
|
||
/// Each component receives the state and writes to JacobianBuilder. The
|
||
/// [`traverse_for_jacobian`](Self::traverse_for_jacobian) iterator provides
|
||
/// the edge→state mapping `(EdgeIndex, state_index_p, state_index_h)` per
|
||
/// component. Components must know their port→state mapping (e.g. from graph
|
||
/// construction in Story 3.2) to write correct column indices.
|
||
pub fn assemble_jacobian(
|
||
&self,
|
||
state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
let mut row_offset = 0;
|
||
for (_node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
|
||
let n = component.n_equations();
|
||
if n > 0 {
|
||
// Components write rows 0..n-1; we offset to global equation indices.
|
||
let mut temp_builder = JacobianBuilder::new();
|
||
component.jacobian_entries(state, &mut temp_builder)?;
|
||
for (r, c, v) in temp_builder.entries() {
|
||
jacobian.add_entry(row_offset + r, *c, *v);
|
||
}
|
||
}
|
||
row_offset += n;
|
||
}
|
||
|
||
// Add constraints jacobian
|
||
let control_values: Vec<f64> = self
|
||
.control_variable_indices()
|
||
.into_iter()
|
||
.map(|(_, idx)| state[idx])
|
||
.collect();
|
||
let constraint_jac =
|
||
self.compute_inverse_control_jacobian(state, row_offset, &control_values);
|
||
for (r, c, v) in constraint_jac {
|
||
jacobian.add_entry(r, c, v);
|
||
}
|
||
row_offset += self.constraints.len();
|
||
|
||
// Add couplings jacobian (MVP: missing T indices)
|
||
let _ = row_offset; // avoid unused warning
|
||
Ok(())
|
||
}
|
||
|
||
/// Tolerance for mass balance validation [kg/s].
|
||
///
|
||
/// This value (1e-9 kg/s) is tight enough to catch numerical issues
|
||
/// while allowing for floating-point rounding errors.
|
||
pub const MASS_BALANCE_TOLERANCE_KG_S: f64 = 1e-9;
|
||
|
||
/// Tolerance for energy balance validation in Watts (1e-6 kW)
|
||
pub const ENERGY_BALANCE_TOLERANCE_W: f64 = 1e-3;
|
||
|
||
/// Verifies that global mass balance is conserved.
|
||
///
|
||
/// Sums the mass flow rates at the ports of each component and ensures they
|
||
/// sum to zero within a tight tolerance (1e-9 kg/s).
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// * `Ok(())` if all components pass mass balance validation
|
||
/// * `Err(SolverError::Validation)` if any component violates mass conservation
|
||
///
|
||
/// # Note
|
||
///
|
||
/// Components without `port_mass_flows` implementation are logged as warnings
|
||
/// and skipped. This ensures visibility of incomplete implementations without
|
||
/// failing the validation.
|
||
pub fn check_mass_balance(&self, state: &StateSlice) -> Result<(), crate::SolverError> {
|
||
let mut total_mass_error = 0.0;
|
||
let mut has_violation = false;
|
||
let mut components_checked = 0usize;
|
||
let mut components_skipped = 0usize;
|
||
|
||
for (node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
|
||
match component.port_mass_flows(state) {
|
||
Ok(mass_flows) => {
|
||
let sum: f64 = mass_flows.iter().map(|m| m.to_kg_per_s()).sum();
|
||
if sum.abs() > Self::MASS_BALANCE_TOLERANCE_KG_S {
|
||
has_violation = true;
|
||
total_mass_error += sum.abs();
|
||
tracing::warn!(
|
||
node_index = node_idx.index(),
|
||
mass_imbalance_kg_s = sum,
|
||
"Mass balance violation detected at component"
|
||
);
|
||
}
|
||
components_checked += 1;
|
||
}
|
||
Err(e) => {
|
||
components_skipped += 1;
|
||
tracing::warn!(
|
||
node_index = node_idx.index(),
|
||
error = %e,
|
||
"Component does not implement port_mass_flows - skipping mass balance check"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
tracing::debug!(
|
||
components_checked,
|
||
components_skipped,
|
||
total_mass_error_kg_s = total_mass_error,
|
||
"Mass balance validation complete"
|
||
);
|
||
|
||
if has_violation {
|
||
return Err(crate::SolverError::Validation {
|
||
mass_error: total_mass_error,
|
||
energy_error: 0.0,
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Verifies the First Law of Thermodynamics for all components in the system.
|
||
///
|
||
/// Validates that ΣQ - ΣW + Σ(ṁ·h) = 0 for each component.
|
||
/// Returns `SolverError::Validation` if any component violates the balance.
|
||
pub fn check_energy_balance(&self, state: &StateSlice) -> Result<(), crate::SolverError> {
|
||
let mut total_energy_error = 0.0;
|
||
let mut has_violation = false;
|
||
let mut components_checked = 0usize;
|
||
let mut components_skipped = 0usize;
|
||
let mut skipped_components: Vec<String> = Vec::new();
|
||
|
||
for (node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
|
||
let energy_transfers = component.energy_transfers(state);
|
||
let mass_flows = component.port_mass_flows(state);
|
||
let enthalpies = component.port_enthalpies(state);
|
||
|
||
match (energy_transfers, mass_flows, enthalpies) {
|
||
(Some((heat, work)), Ok(m_flows), Ok(h_flows))
|
||
if m_flows.len() == h_flows.len() =>
|
||
{
|
||
let mut net_energy_flow = 0.0;
|
||
for (m, h) in m_flows.iter().zip(h_flows.iter()) {
|
||
net_energy_flow += m.to_kg_per_s() * h.to_joules_per_kg();
|
||
}
|
||
|
||
let balance = heat.to_watts() - work.to_watts() + net_energy_flow;
|
||
|
||
if balance.abs() > Self::ENERGY_BALANCE_TOLERANCE_W {
|
||
has_violation = true;
|
||
total_energy_error += balance.abs();
|
||
tracing::warn!(
|
||
node_index = node_idx.index(),
|
||
energy_imbalance_w = balance,
|
||
"Energy balance violation detected at component"
|
||
);
|
||
}
|
||
components_checked += 1;
|
||
}
|
||
_ => {
|
||
components_skipped += 1;
|
||
let component_type = std::any::type_name_of_val(component)
|
||
.split("::")
|
||
.last()
|
||
.unwrap_or("unknown");
|
||
let component_info =
|
||
format!("{} (type: {})", component.signature(), component_type);
|
||
skipped_components.push(component_info.clone());
|
||
|
||
tracing::warn!(
|
||
component = %component_info,
|
||
node_index = node_idx.index(),
|
||
"Component lacks energy_transfers() or port_enthalpies() - SKIPPED in energy balance validation"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Summary warning if components were skipped
|
||
if components_skipped > 0 {
|
||
tracing::warn!(
|
||
components_checked = components_checked,
|
||
components_skipped = components_skipped,
|
||
skipped = ?skipped_components,
|
||
"Energy balance validation incomplete: {} component(s) skipped. \
|
||
Implement energy_transfers() and port_enthalpies() for full validation.",
|
||
components_skipped
|
||
);
|
||
} else {
|
||
tracing::debug!(
|
||
components_checked,
|
||
components_skipped,
|
||
total_energy_error_w = total_energy_error,
|
||
"Energy balance validation complete"
|
||
);
|
||
}
|
||
|
||
if has_violation {
|
||
return Err(crate::SolverError::Validation {
|
||
mass_error: 0.0,
|
||
energy_error: total_energy_error,
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Generates a deterministic byte representation of the system configuration.
|
||
/// Used for simulation traceability logic.
|
||
pub fn generate_canonical_bytes(&self) -> Vec<u8> {
|
||
let mut repr = String::new();
|
||
repr.push_str("Nodes:\n");
|
||
// To be deterministic, we just iterate in graph order which is stable
|
||
// as long as we don't delete nodes.
|
||
for node in self.graph.node_indices() {
|
||
let circuit_id = self.node_to_circuit.get(&node).map(|c| c.0).unwrap_or(0);
|
||
repr.push_str(&format!(
|
||
" Node({}): Circuit({})\n",
|
||
node.index(),
|
||
circuit_id
|
||
));
|
||
if let Some(comp) = self.graph.node_weight(node) {
|
||
repr.push_str(&format!(" Signature: {}\n", comp.signature()));
|
||
}
|
||
}
|
||
repr.push_str("Edges:\n");
|
||
for edge_idx in self.graph.edge_indices() {
|
||
if let Some((src, tgt)) = self.graph.edge_endpoints(edge_idx) {
|
||
repr.push_str(&format!(" Edge: {} -> {}\n", src.index(), tgt.index()));
|
||
}
|
||
}
|
||
repr.push_str("Thermal Couplings:\n");
|
||
for coupling in &self.thermal_couplings {
|
||
repr.push_str(&format!(
|
||
" Hot: {}, Cold: {}, UA: {}\n",
|
||
coupling.hot_circuit.0, coupling.cold_circuit.0, coupling.ua
|
||
));
|
||
}
|
||
repr.push_str("Constraints:\n");
|
||
let mut constraint_keys: Vec<_> = self.constraints.keys().collect();
|
||
constraint_keys.sort_by_key(|k| k.as_str());
|
||
for key in constraint_keys {
|
||
let c = &self.constraints[key];
|
||
repr.push_str(&format!(" {}: {}\n", c.id().as_str(), c.target_value()));
|
||
}
|
||
repr.push_str("Bounded Variables:\n");
|
||
let mut bounded_keys: Vec<_> = self.bounded_variables.keys().collect();
|
||
bounded_keys.sort_by_key(|k| k.as_str());
|
||
for key in bounded_keys {
|
||
let var = &self.bounded_variables[key];
|
||
repr.push_str(&format!(
|
||
" {}: [{}, {}]\n",
|
||
var.id().as_str(),
|
||
var.min(),
|
||
var.max()
|
||
));
|
||
}
|
||
|
||
repr.push_str("Inverse Control Mappings:\n");
|
||
// For inverse control mappings, they are ordered internally. We'll just iterate linked controls.
|
||
for (i, (constraint, bounded_var)) in self.inverse_control.mappings().enumerate() {
|
||
repr.push_str(&format!(
|
||
" Mapping {}: {} -> {}\n",
|
||
i,
|
||
constraint.as_str(),
|
||
bounded_var.as_str()
|
||
));
|
||
}
|
||
|
||
repr.into_bytes()
|
||
}
|
||
|
||
/// Computes the SHA-256 hash uniquely identifying the input configuration.
|
||
pub fn input_hash(&self) -> String {
|
||
use sha2::{Digest, Sha256};
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(self.generate_canonical_bytes());
|
||
format!("{:064x}", hasher.finalize())
|
||
}
|
||
}
|
||
|
||
impl Default for System {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use approx::assert_relative_eq;
|
||
use entropyk_components::port::{FluidId, Port};
|
||
use entropyk_components::{ConnectedPort, StateSlice};
|
||
use entropyk_core::{Enthalpy, Pressure};
|
||
|
||
/// Minimal mock component for testing.
|
||
struct MockComponent {
|
||
n_equations: usize,
|
||
}
|
||
|
||
impl Component for MockComponent {
|
||
fn compute_residuals(
|
||
&self,
|
||
_state: &StateSlice,
|
||
residuals: &mut entropyk_components::ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
for r in residuals.iter_mut().take(self.n_equations) {
|
||
*r = 0.0;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
for i in 0..self.n_equations {
|
||
jacobian.add_entry(i, i, 1.0);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
self.n_equations
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&[]
|
||
}
|
||
}
|
||
|
||
fn make_mock(n: usize) -> Box<dyn Component> {
|
||
Box::new(MockComponent { n_equations: n })
|
||
}
|
||
|
||
/// Mock component with 2 ports (inlet=0, outlet=1) for port validation tests.
|
||
fn make_ported_mock(fluid: &str, pressure_pa: f64, enthalpy_jkg: f64) -> Box<dyn Component> {
|
||
let inlet = Port::new(
|
||
FluidId::new(fluid),
|
||
Pressure::from_pascals(pressure_pa),
|
||
Enthalpy::from_joules_per_kg(enthalpy_jkg),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new(fluid),
|
||
Pressure::from_pascals(pressure_pa),
|
||
Enthalpy::from_joules_per_kg(enthalpy_jkg),
|
||
);
|
||
let (connected_inlet, connected_outlet) = inlet.connect(outlet).unwrap();
|
||
let ports: Vec<ConnectedPort> = vec![connected_inlet, connected_outlet];
|
||
Box::new(PortedMockComponent { ports })
|
||
}
|
||
|
||
struct PortedMockComponent {
|
||
ports: Vec<ConnectedPort>,
|
||
}
|
||
|
||
impl Component for PortedMockComponent {
|
||
fn compute_residuals(
|
||
&self,
|
||
_state: &StateSlice,
|
||
residuals: &mut entropyk_components::ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
for r in residuals.iter_mut() {
|
||
*r = 0.0;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
_jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
0
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&self.ports
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_simple_cycle_builds() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
let n2 = sys.add_component(make_mock(0));
|
||
let n3 = sys.add_component(make_mock(0));
|
||
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n2).unwrap();
|
||
sys.add_edge(n2, n3).unwrap();
|
||
sys.add_edge(n3, n0).unwrap();
|
||
|
||
assert_eq!(sys.node_count(), 4);
|
||
assert_eq!(sys.edge_count(), 4);
|
||
|
||
let result = sys.finalize();
|
||
assert!(
|
||
result.is_ok(),
|
||
"finalize should succeed: {:?}",
|
||
result.err()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_vector_length() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
|
||
sys.finalize().unwrap();
|
||
assert_eq!(sys.state_vector_len(), 4); // 2 edges * 2 = 4
|
||
}
|
||
|
||
#[test]
|
||
fn test_edge_indices_contiguous() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
let n2 = sys.add_component(make_mock(0));
|
||
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n2).unwrap();
|
||
sys.add_edge(n2, n0).unwrap();
|
||
|
||
sys.finalize().unwrap();
|
||
|
||
let mut indices: Vec<usize> = Vec::new();
|
||
for edge_idx in sys.edge_indices() {
|
||
let (p, h) = sys.edge_state_indices(edge_idx);
|
||
indices.push(p);
|
||
indices.push(h);
|
||
}
|
||
|
||
let n = sys.edge_count();
|
||
assert_eq!(indices.len(), 2 * n);
|
||
let expected: Vec<usize> = (0..2 * n).collect();
|
||
assert_eq!(indices, expected, "indices should be 0..2n");
|
||
}
|
||
|
||
#[test]
|
||
fn test_cycle_detected() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
let n2 = sys.add_component(make_mock(0));
|
||
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n2).unwrap();
|
||
sys.add_edge(n2, n0).unwrap();
|
||
|
||
sys.finalize().unwrap();
|
||
assert!(
|
||
sys.is_cyclic(),
|
||
"refrigeration cycle should be detected as cyclic"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_dangling_node_error() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
let n2 = sys.add_component(make_mock(0)); // isolated
|
||
|
||
sys.add_edge(n0, n1).unwrap();
|
||
// n2 has no edges
|
||
|
||
let result = sys.finalize();
|
||
assert!(result.is_err());
|
||
match &result {
|
||
Err(TopologyError::IsolatedNode { node_index }) => {
|
||
assert!(
|
||
*node_index < sys.node_count(),
|
||
"isolated node index {} must be < node_count {}",
|
||
node_index,
|
||
sys.node_count()
|
||
);
|
||
assert_eq!(*node_index, n2.index(), "isolated node should be n2");
|
||
}
|
||
other => panic!("expected IsolatedNode error, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_traverse_components() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(1));
|
||
let n1 = sys.add_component(make_mock(1));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
|
||
sys.finalize().unwrap();
|
||
|
||
let mut count = 0;
|
||
for (_node_idx, component, edge_indices) in sys.traverse_for_jacobian() {
|
||
count += 1;
|
||
assert_eq!(component.n_equations(), 1);
|
||
assert_eq!(
|
||
edge_indices.len(),
|
||
2,
|
||
"each node has 2 incident edges in 2-node cycle"
|
||
);
|
||
for (_edge_idx, p, h) in &edge_indices {
|
||
assert!(p < &sys.state_vector_len());
|
||
assert!(h < &sys.state_vector_len());
|
||
assert_eq!(h, &(p + 1));
|
||
}
|
||
}
|
||
assert_eq!(count, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_empty_graph_finalize_ok() {
|
||
let mut sys = System::new();
|
||
let result = sys.finalize();
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_layout_integration() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(1));
|
||
let n1 = sys.add_component(make_mock(1));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
|
||
sys.finalize().unwrap();
|
||
|
||
let layout = sys.state_layout();
|
||
assert!(layout.contains("P_edge"));
|
||
assert!(layout.contains("h_edge"));
|
||
|
||
let state_len = sys.state_vector_len();
|
||
assert_eq!(state_len, 4);
|
||
|
||
let mut state = vec![0.0; state_len];
|
||
state[0] = 1e5; // P_edge0
|
||
state[1] = 250000.0; // h_edge0
|
||
state[2] = 5e5; // P_edge1
|
||
state[3] = 300000.0; // h_edge1
|
||
|
||
let mut residuals = vec![0.0; 2];
|
||
let result = sys.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_valid_connection_same_fluid() {
|
||
let p = 100_000.0;
|
||
let h = 400_000.0;
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
let n1 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
|
||
let result = sys.add_edge_with_ports(n0, 1, n1, 0);
|
||
assert!(
|
||
result.is_ok(),
|
||
"R134a to R134a should succeed: {:?}",
|
||
result.err()
|
||
);
|
||
sys.add_edge(n1, n0).unwrap(); // backward edge, no ports
|
||
sys.finalize().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_incompatible_fluid_rejected() {
|
||
let p = 100_000.0;
|
||
let h = 400_000.0;
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
let n1 = sys.add_component(make_ported_mock("Water", p, h));
|
||
|
||
let result = sys.add_edge_with_ports(n0, 1, n1, 0);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(AddEdgeError::Connection(ConnectionError::IncompatibleFluid { from, to })) => {
|
||
assert_eq!(from, "R134a");
|
||
assert_eq!(to, "Water");
|
||
}
|
||
other => panic!(
|
||
"expected AddEdgeError::Connection(IncompatibleFluid), got {:?}",
|
||
other
|
||
),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_pressure_mismatch_rejected() {
|
||
let h = 400_000.0;
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_ported_mock("R134a", 100_000.0, h));
|
||
let n1 = sys.add_component(make_ported_mock("R134a", 200_000.0, h));
|
||
|
||
let result = sys.add_edge_with_ports(n0, 1, n1, 0);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(AddEdgeError::Connection(ConnectionError::PressureMismatch {
|
||
from_pressure,
|
||
to_pressure,
|
||
tolerance,
|
||
})) => {
|
||
assert_relative_eq!(from_pressure, 100_000.0, epsilon = 1.0);
|
||
assert_relative_eq!(to_pressure, 200_000.0, epsilon = 1.0);
|
||
assert!(tolerance >= 1.0, "tolerance should be at least 1 Pa");
|
||
}
|
||
other => panic!(
|
||
"expected AddEdgeError::Connection(PressureMismatch), got {:?}",
|
||
other
|
||
),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_enthalpy_mismatch_rejected() {
|
||
let p = 100_000.0;
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_ported_mock("R134a", p, 400_000.0));
|
||
let n1 = sys.add_component(make_ported_mock("R134a", p, 500_000.0));
|
||
|
||
let result = sys.add_edge_with_ports(n0, 1, n1, 0);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(AddEdgeError::Connection(ConnectionError::EnthalpyMismatch {
|
||
from_enthalpy,
|
||
to_enthalpy,
|
||
tolerance,
|
||
})) => {
|
||
assert_relative_eq!(from_enthalpy, 400_000.0, epsilon = 1.0);
|
||
assert_relative_eq!(to_enthalpy, 500_000.0, epsilon = 1.0);
|
||
assert_relative_eq!(tolerance, 100.0, epsilon = 1.0); // ENTHALPY_TOLERANCE_J_KG
|
||
}
|
||
other => panic!(
|
||
"expected AddEdgeError::Connection(EnthalpyMismatch), got {:?}",
|
||
other
|
||
),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_pressure_tolerance_boundary() {
|
||
let h = 400_000.0;
|
||
let base_pressure = 100_000.0; // 100 kPa
|
||
let tolerance: f64 = (base_pressure * 1e-4f64).max(1.0f64); // 10 Pa for 100 kPa
|
||
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_ported_mock("R134a", base_pressure, h));
|
||
|
||
// Exactly at tolerance - should succeed
|
||
let n1 = sys.add_component(make_ported_mock("R134a", base_pressure + tolerance, h));
|
||
let result = sys.add_edge_with_ports(n0, 1, n1, 0);
|
||
assert!(
|
||
result.is_ok(),
|
||
"Connection at exact tolerance ({:.1} Pa diff) should succeed",
|
||
tolerance
|
||
);
|
||
|
||
// Just outside tolerance - should fail
|
||
let n2 = sys.add_component(make_ported_mock(
|
||
"R134a",
|
||
base_pressure + tolerance + 1.0,
|
||
h,
|
||
));
|
||
let result = sys.add_edge_with_ports(n0, 1, n2, 0);
|
||
assert!(
|
||
result.is_err(),
|
||
"Connection just outside tolerance ({:.1} Pa diff) should fail",
|
||
tolerance + 1.0
|
||
);
|
||
match result {
|
||
Err(AddEdgeError::Connection(ConnectionError::PressureMismatch {
|
||
from_pressure,
|
||
to_pressure,
|
||
tolerance: tol,
|
||
})) => {
|
||
assert_relative_eq!(from_pressure, base_pressure, epsilon = 0.1);
|
||
assert_relative_eq!(to_pressure, base_pressure + tolerance + 1.0, epsilon = 0.1);
|
||
assert_relative_eq!(tol, tolerance, epsilon = 0.1);
|
||
}
|
||
other => panic!("expected PressureMismatch at boundary, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalid_port_index_rejected() {
|
||
let p = 100_000.0;
|
||
let h = 400_000.0;
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
let n1 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
|
||
// Port index 2 out of bounds for 2-port component
|
||
let result = sys.add_edge_with_ports(n0, 2, n1, 0);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(AddEdgeError::Connection(ConnectionError::InvalidPortIndex {
|
||
index,
|
||
port_count,
|
||
max_index,
|
||
})) => {
|
||
assert_eq!(index, 2);
|
||
assert_eq!(port_count, 2);
|
||
assert_eq!(max_index, 1);
|
||
}
|
||
other => panic!("expected InvalidPortIndex for source, got {:?}", other),
|
||
}
|
||
|
||
// Target port index out of bounds
|
||
let result = sys.add_edge_with_ports(n0, 1, n1, 5);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(AddEdgeError::Connection(ConnectionError::InvalidPortIndex {
|
||
index,
|
||
port_count,
|
||
max_index,
|
||
})) => {
|
||
assert_eq!(index, 5);
|
||
assert_eq!(port_count, 2);
|
||
assert_eq!(max_index, 1);
|
||
}
|
||
other => panic!("expected InvalidPortIndex for target, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_simple_cycle_port_validation() {
|
||
let p = 100_000.0;
|
||
let h = 400_000.0;
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
let n1 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
let n2 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
let n3 = sys.add_component(make_ported_mock("R134a", p, h));
|
||
|
||
sys.add_edge_with_ports(n0, 1, n1, 0).unwrap();
|
||
sys.add_edge_with_ports(n1, 1, n2, 0).unwrap();
|
||
sys.add_edge_with_ports(n2, 1, n3, 0).unwrap();
|
||
sys.add_edge_with_ports(n3, 1, n0, 0).unwrap();
|
||
|
||
assert_eq!(sys.edge_count(), 4);
|
||
sys.finalize().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_compute_residuals_bounds_check() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(2));
|
||
let n1 = sys.add_component(make_mock(2));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
sys.finalize().unwrap();
|
||
|
||
let state = vec![0.0; sys.state_vector_len()];
|
||
let mut residuals = vec![0.0; 1]; // Too small: need 4
|
||
let result = sys.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(ComponentError::InvalidResidualDimensions { expected, actual }) => {
|
||
assert_eq!(expected, 4);
|
||
assert_eq!(actual, 1);
|
||
}
|
||
other => panic!("expected InvalidResidualDimensions, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
// --- Story 3.3: Multi-Circuit Machine Definition tests ---
|
||
|
||
#[test]
|
||
fn test_two_circuit_machine() {
|
||
let mut sys = System::new();
|
||
let c0 = CircuitId::ZERO;
|
||
let c1 = CircuitId(1);
|
||
|
||
let n0 = sys.add_component_to_circuit(make_mock(0), c0).unwrap();
|
||
let n1 = sys.add_component_to_circuit(make_mock(0), c0).unwrap();
|
||
let n2 = sys.add_component_to_circuit(make_mock(0), c1).unwrap();
|
||
let n3 = sys.add_component_to_circuit(make_mock(0), c1).unwrap();
|
||
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
sys.add_edge(n2, n3).unwrap();
|
||
sys.add_edge(n3, n2).unwrap();
|
||
|
||
assert_eq!(sys.circuit_count(), 2);
|
||
assert_eq!(sys.circuit_nodes(c0).count(), 2);
|
||
assert_eq!(sys.circuit_nodes(c1).count(), 2);
|
||
assert_eq!(sys.circuit_edges(c0).count(), 2);
|
||
assert_eq!(sys.circuit_edges(c1).count(), 2);
|
||
|
||
sys.finalize().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_cross_circuit_edge_rejected() {
|
||
let mut sys = System::new();
|
||
let c0 = CircuitId::ZERO;
|
||
let c1 = CircuitId(1);
|
||
|
||
let n0 = sys.add_component_to_circuit(make_mock(0), c0).unwrap();
|
||
let n1 = sys.add_component_to_circuit(make_mock(0), c1).unwrap();
|
||
|
||
let result = sys.add_edge(n0, n1);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(TopologyError::CrossCircuitConnection {
|
||
source_circuit,
|
||
target_circuit,
|
||
}) => {
|
||
assert_eq!(source_circuit, 0);
|
||
assert_eq!(target_circuit, 1);
|
||
}
|
||
other => panic!("expected CrossCircuitConnection, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_circuit_count_and_accessors() {
|
||
let mut sys = System::new();
|
||
let c0 = CircuitId::ZERO;
|
||
let c1 = CircuitId(1);
|
||
let c2 = CircuitId(2);
|
||
|
||
let _n0 = sys.add_component_to_circuit(make_mock(0), c0).unwrap();
|
||
let _n1 = sys.add_component_to_circuit(make_mock(0), c0).unwrap();
|
||
let _n2 = sys.add_component_to_circuit(make_mock(0), c1).unwrap();
|
||
let _n3 = sys.add_component_to_circuit(make_mock(0), c2).unwrap();
|
||
|
||
assert_eq!(sys.circuit_count(), 3);
|
||
assert_eq!(sys.circuit_nodes(c0).count(), 2);
|
||
assert_eq!(sys.circuit_nodes(c1).count(), 1);
|
||
assert_eq!(sys.circuit_nodes(c2).count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_max_five_circuits() {
|
||
// Test: 5 circuits accepted (0, 1, 2, 3, 4), 6th circuit (5) rejected
|
||
let mut sys = System::new();
|
||
for i in 0..=4 {
|
||
let cid = CircuitId(i);
|
||
let result = sys.add_component_to_circuit(make_mock(0), cid);
|
||
assert!(
|
||
result.is_ok(),
|
||
"circuit {} should be accepted (max 5 circuits: 0-4)",
|
||
i
|
||
);
|
||
}
|
||
assert_eq!(sys.circuit_count(), 5, "should have exactly 5 circuits");
|
||
|
||
// 6th circuit should be rejected
|
||
let result = sys.add_component_to_circuit(make_mock(0), CircuitId(5));
|
||
assert!(
|
||
result.is_err(),
|
||
"circuit 5 should be rejected (exceeds max of 4)"
|
||
);
|
||
match result {
|
||
Err(TopologyError::TooManyCircuits { requested }) => assert_eq!(requested, 5),
|
||
other => panic!("expected TooManyCircuits, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_single_circuit_backward_compat() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
let edge0 = sys.add_edge(n0, n1).unwrap();
|
||
let edge1 = sys.add_edge(n1, n0).unwrap();
|
||
|
||
assert_eq!(sys.circuit_count(), 1);
|
||
assert_eq!(sys.circuit_nodes(CircuitId::ZERO).count(), 2);
|
||
|
||
// Verify edge circuit membership
|
||
assert_eq!(sys.edge_circuit(edge0), CircuitId::ZERO);
|
||
assert_eq!(sys.edge_circuit(edge1), CircuitId::ZERO);
|
||
|
||
// Verify circuit_edges returns correct edges
|
||
let circuit_0_edges: Vec<_> = sys.circuit_edges(CircuitId::ZERO).collect();
|
||
assert_eq!(circuit_0_edges.len(), 2);
|
||
assert!(circuit_0_edges.contains(&edge0));
|
||
assert!(circuit_0_edges.contains(&edge1));
|
||
|
||
sys.finalize().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_cross_circuit_add_edge_with_ports_rejected() {
|
||
let p = 100_000.0;
|
||
let h = 400_000.0;
|
||
let mut sys = System::new();
|
||
let n0 = sys
|
||
.add_component_to_circuit(make_ported_mock("R134a", p, h), CircuitId::ZERO)
|
||
.unwrap();
|
||
let n1 = sys
|
||
.add_component_to_circuit(make_ported_mock("R134a", p, h), CircuitId(1))
|
||
.unwrap();
|
||
|
||
let result = sys.add_edge_with_ports(n0, 1, n1, 0);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(AddEdgeError::Topology(TopologyError::CrossCircuitConnection {
|
||
source_circuit,
|
||
target_circuit,
|
||
})) => {
|
||
assert_eq!(source_circuit, 0);
|
||
assert_eq!(target_circuit, 1);
|
||
}
|
||
other => panic!(
|
||
"expected AddEdgeError::Topology(CrossCircuitConnection), got {:?}",
|
||
other
|
||
),
|
||
}
|
||
}
|
||
|
||
// --- Story 3.4: Thermal Coupling Between Circuits tests ---
|
||
|
||
#[test]
|
||
fn test_add_thermal_coupling_valid() {
|
||
use entropyk_core::ThermalConductance;
|
||
|
||
let mut sys = System::new();
|
||
let _n0 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(0))
|
||
.unwrap();
|
||
let _n1 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(1))
|
||
.unwrap();
|
||
|
||
let coupling = ThermalCoupling::new(
|
||
CircuitId(0),
|
||
CircuitId(1),
|
||
ThermalConductance::from_watts_per_kelvin(1000.0),
|
||
);
|
||
|
||
let idx = sys.add_thermal_coupling(coupling).unwrap();
|
||
assert_eq!(idx, 0);
|
||
assert_eq!(sys.thermal_coupling_count(), 1);
|
||
|
||
let retrieved = sys.get_thermal_coupling(0).unwrap();
|
||
assert_eq!(retrieved.hot_circuit, CircuitId(0));
|
||
assert_eq!(retrieved.cold_circuit, CircuitId(1));
|
||
}
|
||
|
||
#[test]
|
||
fn test_add_thermal_coupling_invalid_circuit() {
|
||
use entropyk_core::ThermalConductance;
|
||
|
||
let mut sys = System::new();
|
||
let _n0 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(0))
|
||
.unwrap();
|
||
// Circuit 1 has no components
|
||
|
||
let coupling = ThermalCoupling::new(
|
||
CircuitId(0),
|
||
CircuitId(1), // This circuit doesn't exist
|
||
ThermalConductance::from_watts_per_kelvin(1000.0),
|
||
);
|
||
|
||
let result = sys.add_thermal_coupling(coupling);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(TopologyError::InvalidCircuitForCoupling { circuit_id }) => {
|
||
assert_eq!(circuit_id, 1);
|
||
}
|
||
other => panic!("expected InvalidCircuitForCoupling, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_add_thermal_coupling_hot_circuit_invalid() {
|
||
use entropyk_core::ThermalConductance;
|
||
|
||
let mut sys = System::new();
|
||
let _n0 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(0))
|
||
.unwrap();
|
||
|
||
let coupling = ThermalCoupling::new(
|
||
CircuitId(99), // This circuit doesn't exist
|
||
CircuitId(0),
|
||
ThermalConductance::from_watts_per_kelvin(1000.0),
|
||
);
|
||
|
||
let result = sys.add_thermal_coupling(coupling);
|
||
assert!(result.is_err());
|
||
match result {
|
||
Err(TopologyError::InvalidCircuitForCoupling { circuit_id }) => {
|
||
assert_eq!(circuit_id, 99);
|
||
}
|
||
other => panic!("expected InvalidCircuitForCoupling, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_multiple_thermal_couplings() {
|
||
use entropyk_core::ThermalConductance;
|
||
|
||
let mut sys = System::new();
|
||
let _n0 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(0))
|
||
.unwrap();
|
||
let _n1 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(1))
|
||
.unwrap();
|
||
let _n2 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(2))
|
||
.unwrap();
|
||
|
||
let coupling1 = ThermalCoupling::new(
|
||
CircuitId(0),
|
||
CircuitId(1),
|
||
ThermalConductance::from_watts_per_kelvin(1000.0),
|
||
);
|
||
let coupling2 = ThermalCoupling::new(
|
||
CircuitId(1),
|
||
CircuitId(2),
|
||
ThermalConductance::from_watts_per_kelvin(500.0),
|
||
);
|
||
|
||
let idx1 = sys.add_thermal_coupling(coupling1).unwrap();
|
||
let idx2 = sys.add_thermal_coupling(coupling2).unwrap();
|
||
|
||
assert_eq!(idx1, 0);
|
||
assert_eq!(idx2, 1);
|
||
assert_eq!(sys.thermal_coupling_count(), 2);
|
||
|
||
let all_couplings = sys.thermal_couplings();
|
||
assert_eq!(all_couplings.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_thermal_coupling_same_circuit() {
|
||
// It's valid to couple a circuit to itself (internal heat exchanger / economizer)
|
||
use entropyk_core::ThermalConductance;
|
||
|
||
let mut sys = System::new();
|
||
let _n0 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(0))
|
||
.unwrap();
|
||
let _n1 = sys
|
||
.add_component_to_circuit(make_mock(0), CircuitId(0))
|
||
.unwrap();
|
||
|
||
let coupling = ThermalCoupling::new(
|
||
CircuitId(0),
|
||
CircuitId(0), // Same circuit
|
||
ThermalConductance::from_watts_per_kelvin(1000.0),
|
||
);
|
||
|
||
let result = sys.add_thermal_coupling(coupling);
|
||
assert!(result.is_ok(), "Same-circuit coupling should be allowed");
|
||
}
|
||
|
||
// --- Story 3.5: Zero-Flow Branch Handling ---
|
||
|
||
/// Mock component that behaves like an Off branch: residual = state[0] (ṁ - 0), Jacobian ∂r/∂state[0] = 1.
|
||
/// At state[0] = 0 (zero flow) residuals and Jacobian remain finite (no division by zero).
|
||
struct ZeroFlowMock;
|
||
|
||
impl Component for ZeroFlowMock {
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut entropyk_components::ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
if !state.is_empty() {
|
||
residuals[0] = state[0];
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
jacobian.add_entry(0, 0, 1.0);
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
1
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&[]
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_zero_flow_branch_residuals_and_jacobian_finite() {
|
||
// Story 3.5: System with one branch at zero flow must not produce NaN/Inf in residuals or Jacobian.
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(Box::new(ZeroFlowMock));
|
||
let n1 = sys.add_component(make_mock(1));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
sys.finalize().unwrap();
|
||
|
||
let state_len = sys.state_vector_len();
|
||
let mut state = vec![0.0; state_len];
|
||
state[0] = 0.0;
|
||
|
||
let total_eqs: usize = 1 + 1;
|
||
let mut residuals = vec![0.0; total_eqs];
|
||
let result = sys.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_ok());
|
||
for (i, &r) in residuals.iter().enumerate() {
|
||
assert!(r.is_finite(), "residual[{}] must be finite, got {}", i, r);
|
||
}
|
||
|
||
let mut jacobian = JacobianBuilder::new();
|
||
let result = sys.assemble_jacobian(&state, &mut jacobian);
|
||
assert!(result.is_ok());
|
||
|
||
let entries = jacobian.entries();
|
||
for (row, col, value) in entries {
|
||
assert!(
|
||
value.is_finite(),
|
||
"Jacobian ({}, {}) must be finite, got {}",
|
||
row,
|
||
col,
|
||
value
|
||
);
|
||
}
|
||
|
||
// Check for zero rows: each equation should have at least one non-zero derivative
|
||
let mut row_has_nonzero = vec![false; total_eqs];
|
||
for (row, _col, value) in entries {
|
||
if value.abs() > 1e-15 {
|
||
row_has_nonzero[*row] = true;
|
||
}
|
||
}
|
||
for (row, &has_nonzero) in row_has_nonzero.iter().enumerate() {
|
||
assert!(
|
||
has_nonzero,
|
||
"Jacobian row {} is all zeros (degenerate equation)",
|
||
row
|
||
);
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Constraint Management Tests
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_add_constraint() {
|
||
use crate::inverse::{ComponentOutput, Constraint, ConstraintId};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Register a component first
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", node);
|
||
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat_control"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
|
||
assert!(system.add_constraint(constraint).is_ok());
|
||
assert_eq!(system.constraint_count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_add_constraint_unregistered_component() {
|
||
use crate::inverse::{ComponentOutput, Constraint, ConstraintId};
|
||
|
||
let mut system = System::new();
|
||
// No component registered with name "evaporator"
|
||
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat_control"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
|
||
let result = system.add_constraint(constraint);
|
||
assert!(matches!(
|
||
result,
|
||
Err(ConstraintError::InvalidReference { .. })
|
||
));
|
||
assert_eq!(system.constraint_count(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_register_component_name() {
|
||
let mut system = System::new();
|
||
let n0 = system.add_component(make_mock(0));
|
||
let n1 = system.add_component(make_mock(0));
|
||
|
||
// Register first name - should return true (newly registered)
|
||
assert!(system.register_component_name("evaporator", n0));
|
||
|
||
// Register second name - should return true
|
||
assert!(system.register_component_name("condenser", n1));
|
||
|
||
// Re-register same name with different node - should return false
|
||
assert!(!system.register_component_name("evaporator", n1));
|
||
|
||
// Verify lookup works
|
||
assert_eq!(system.get_component_node("evaporator"), Some(n1)); // Updated to n1
|
||
assert_eq!(system.get_component_node("condenser"), Some(n1));
|
||
assert_eq!(system.get_component_node("nonexistent"), None);
|
||
|
||
// Verify iteration
|
||
let names: Vec<&str> = system.registered_component_names().collect();
|
||
assert_eq!(names.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_add_duplicate_constraint() {
|
||
use crate::inverse::{ComponentOutput, Constraint, ConstraintId};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Register components
|
||
let n0 = system.add_component(make_mock(0));
|
||
let n1 = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", n0);
|
||
system.register_component_name("condenser", n1);
|
||
|
||
let constraint1 = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
let constraint2 = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "condenser".to_string(),
|
||
},
|
||
3.0,
|
||
);
|
||
|
||
assert!(system.add_constraint(constraint1).is_ok());
|
||
let result = system.add_constraint(constraint2);
|
||
assert!(matches!(result, Err(ConstraintError::DuplicateId { .. })));
|
||
assert_eq!(system.constraint_count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_remove_constraint() {
|
||
use crate::inverse::{ComponentOutput, Constraint, ConstraintId};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Register component
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", node);
|
||
|
||
let id = ConstraintId::new("superheat");
|
||
let constraint = Constraint::new(
|
||
id.clone(),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
|
||
system.add_constraint(constraint).unwrap();
|
||
assert_eq!(system.constraint_count(), 1);
|
||
|
||
let removed = system.remove_constraint(&id);
|
||
assert!(removed.is_some());
|
||
assert_eq!(system.constraint_count(), 0);
|
||
|
||
// Removing non-existent constraint returns None
|
||
let removed_again = system.remove_constraint(&id);
|
||
assert!(removed_again.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_constraint() {
|
||
use crate::inverse::{ComponentOutput, Constraint, ConstraintId};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Register component
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("compressor", node);
|
||
|
||
let id = ConstraintId::new("pressure_control");
|
||
let constraint = Constraint::new(
|
||
id.clone(),
|
||
ComponentOutput::Pressure {
|
||
component_id: "compressor".to_string(),
|
||
},
|
||
300000.0,
|
||
);
|
||
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
let retrieved = system.get_constraint(&id);
|
||
assert!(retrieved.is_some());
|
||
assert_relative_eq!(retrieved.unwrap().target_value(), 300000.0);
|
||
|
||
let missing = system.get_constraint(&ConstraintId::new("nonexistent"));
|
||
assert!(missing.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_multiple_constraints() {
|
||
use crate::inverse::{ComponentOutput, Constraint, ConstraintId};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Register components
|
||
let n0 = system.add_component(make_mock(0));
|
||
let n1 = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", n0);
|
||
system.register_component_name("condenser", n1);
|
||
|
||
let c1 = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
let c2 = Constraint::new(
|
||
ConstraintId::new("subcooling"),
|
||
ComponentOutput::Subcooling {
|
||
component_id: "condenser".to_string(),
|
||
},
|
||
3.0,
|
||
);
|
||
let c3 = Constraint::new(
|
||
ConstraintId::new("capacity"),
|
||
ComponentOutput::HeatTransferRate {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
10000.0,
|
||
);
|
||
|
||
assert!(system.add_constraint(c1).is_ok());
|
||
assert!(system.add_constraint(c2).is_ok());
|
||
assert!(system.add_constraint(c3).is_ok());
|
||
|
||
assert_eq!(system.constraint_count(), 3);
|
||
assert_eq!(system.constraint_residual_count(), 3);
|
||
|
||
// Iterate over all constraints
|
||
let count = system.constraints().count();
|
||
assert_eq!(count, 3);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Bounded Variable Tests
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_add_bounded_variable() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId};
|
||
|
||
let mut system = System::new();
|
||
|
||
let valve =
|
||
BoundedVariable::new(BoundedVariableId::new("expansion_valve"), 0.5, 0.0, 1.0).unwrap();
|
||
|
||
assert!(system.add_bounded_variable(valve).is_ok());
|
||
assert_eq!(system.bounded_variable_count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_add_bounded_variable_duplicate_id() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId};
|
||
|
||
let mut system = System::new();
|
||
|
||
let valve1 = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
let valve2 = BoundedVariable::new(BoundedVariableId::new("valve"), 0.3, 0.0, 1.0).unwrap();
|
||
|
||
assert!(system.add_bounded_variable(valve1).is_ok());
|
||
let result = system.add_bounded_variable(valve2);
|
||
assert!(matches!(
|
||
result,
|
||
Err(crate::inverse::BoundedVariableError::DuplicateId { .. })
|
||
));
|
||
assert_eq!(system.bounded_variable_count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_add_bounded_variable_invalid_component() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableError, BoundedVariableId};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Variable references non-existent component
|
||
let valve = BoundedVariable::with_component(
|
||
BoundedVariableId::new("valve"),
|
||
"unknown_component",
|
||
0.5,
|
||
0.0,
|
||
1.0,
|
||
)
|
||
.unwrap();
|
||
|
||
let result = system.add_bounded_variable(valve);
|
||
assert!(matches!(
|
||
result,
|
||
Err(BoundedVariableError::InvalidComponent { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn test_add_bounded_variable_with_valid_component() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Register component first
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("expansion_valve", node);
|
||
|
||
let valve = BoundedVariable::with_component(
|
||
BoundedVariableId::new("valve"),
|
||
"expansion_valve",
|
||
0.5,
|
||
0.0,
|
||
1.0,
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(system.add_bounded_variable(valve).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_remove_bounded_variable() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId};
|
||
|
||
let mut system = System::new();
|
||
|
||
let id = BoundedVariableId::new("valve");
|
||
let valve = BoundedVariable::new(id.clone(), 0.5, 0.0, 1.0).unwrap();
|
||
|
||
system.add_bounded_variable(valve).unwrap();
|
||
assert_eq!(system.bounded_variable_count(), 1);
|
||
|
||
let removed = system.remove_bounded_variable(&id);
|
||
assert!(removed.is_some());
|
||
assert_eq!(system.bounded_variable_count(), 0);
|
||
|
||
// Removing non-existent returns None
|
||
let removed_again = system.remove_bounded_variable(&id);
|
||
assert!(removed_again.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_bounded_variable() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId};
|
||
|
||
let mut system = System::new();
|
||
|
||
let id = BoundedVariableId::new("vfd_speed");
|
||
let vfd = BoundedVariable::new(id.clone(), 0.8, 0.3, 1.0).unwrap();
|
||
|
||
system.add_bounded_variable(vfd).unwrap();
|
||
|
||
let retrieved = system.get_bounded_variable(&id);
|
||
assert!(retrieved.is_some());
|
||
approx::assert_relative_eq!(retrieved.unwrap().value(), 0.8);
|
||
|
||
let missing = system.get_bounded_variable(&BoundedVariableId::new("nonexistent"));
|
||
assert!(missing.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_bounded_variable_mut() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId};
|
||
|
||
let mut system = System::new();
|
||
|
||
let id = BoundedVariableId::new("valve");
|
||
let valve = BoundedVariable::new(id.clone(), 0.5, 0.0, 1.0).unwrap();
|
||
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
// Apply step through mutable reference
|
||
if let Some(v) = system.get_bounded_variable_mut(&id) {
|
||
v.apply_step(0.3);
|
||
}
|
||
|
||
let retrieved = system.get_bounded_variable(&id).unwrap();
|
||
approx::assert_relative_eq!(retrieved.value(), 0.8);
|
||
}
|
||
|
||
#[test]
|
||
fn test_saturated_variables() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId, SaturationType};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Variable at min bound
|
||
let v1 = BoundedVariable::new(BoundedVariableId::new("v1"), 0.0, 0.0, 1.0).unwrap();
|
||
// Variable at max bound
|
||
let v2 = BoundedVariable::new(BoundedVariableId::new("v2"), 1.0, 0.0, 1.0).unwrap();
|
||
// Variable in middle (not saturated)
|
||
let v3 = BoundedVariable::new(BoundedVariableId::new("v3"), 0.5, 0.0, 1.0).unwrap();
|
||
|
||
system.add_bounded_variable(v1).unwrap();
|
||
system.add_bounded_variable(v2).unwrap();
|
||
system.add_bounded_variable(v3).unwrap();
|
||
|
||
let saturated = system.saturated_variables();
|
||
assert_eq!(saturated.len(), 2);
|
||
|
||
let sat_types: Vec<_> = saturated.iter().map(|s| s.saturation_type).collect();
|
||
assert!(sat_types.contains(&SaturationType::LowerBound));
|
||
assert!(sat_types.contains(&SaturationType::UpperBound));
|
||
}
|
||
|
||
#[test]
|
||
fn test_bounded_variables_iterator() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId};
|
||
|
||
let mut system = System::new();
|
||
|
||
let v1 = BoundedVariable::new(BoundedVariableId::new("v1"), 0.5, 0.0, 1.0).unwrap();
|
||
let v2 = BoundedVariable::new(BoundedVariableId::new("v2"), 0.3, 0.0, 1.0).unwrap();
|
||
|
||
system.add_bounded_variable(v1).unwrap();
|
||
system.add_bounded_variable(v2).unwrap();
|
||
|
||
let count = system.bounded_variables().count();
|
||
assert_eq!(count, 2);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Inverse Control Tests (Story 5.3)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_link_constraint_to_control() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Register component
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", node);
|
||
|
||
// Add constraint and bounded variable
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
let valve = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
// Link them
|
||
let result = system.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("valve"),
|
||
);
|
||
assert!(result.is_ok());
|
||
assert_eq!(system.inverse_control_mapping_count(), 1);
|
||
|
||
// Verify bidirectional lookup
|
||
let control = system.get_control_for_constraint(&ConstraintId::new("superheat"));
|
||
assert!(control.is_some());
|
||
assert_eq!(control.unwrap().as_str(), "valve");
|
||
|
||
let constraint = system.get_constraint_for_control(&BoundedVariableId::new("valve"));
|
||
assert!(constraint.is_some());
|
||
assert_eq!(constraint.unwrap().as_str(), "superheat");
|
||
}
|
||
|
||
#[test]
|
||
fn test_link_constraint_not_found() {
|
||
use crate::inverse::{BoundedVariable, BoundedVariableId, ConstraintId, DoFError};
|
||
|
||
let mut system = System::new();
|
||
|
||
let valve = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
let result = system.link_constraint_to_control(
|
||
&ConstraintId::new("nonexistent"),
|
||
&BoundedVariableId::new("valve"),
|
||
);
|
||
assert!(matches!(result, Err(DoFError::ConstraintNotFound { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn test_link_control_not_found() {
|
||
use crate::inverse::{
|
||
BoundedVariableId, ComponentOutput, Constraint, ConstraintId, DoFError,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", node);
|
||
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
let result = system.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("nonexistent"),
|
||
);
|
||
assert!(matches!(
|
||
result,
|
||
Err(DoFError::BoundedVariableNotFound { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn test_link_duplicate_constraint() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId, DoFError,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", node);
|
||
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
let v1 = BoundedVariable::new(BoundedVariableId::new("v1"), 0.5, 0.0, 1.0).unwrap();
|
||
let v2 = BoundedVariable::new(BoundedVariableId::new("v2"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(v1).unwrap();
|
||
system.add_bounded_variable(v2).unwrap();
|
||
|
||
// First link should succeed
|
||
system
|
||
.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("v1"),
|
||
)
|
||
.unwrap();
|
||
|
||
// Second link for same constraint should fail
|
||
let result = system.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("v2"),
|
||
);
|
||
assert!(matches!(result, Err(DoFError::AlreadyLinked { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn test_link_duplicate_control() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId, DoFError,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", node);
|
||
|
||
let c1 = Constraint::new(
|
||
ConstraintId::new("c1"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
let c2 = Constraint::new(
|
||
ConstraintId::new("c2"),
|
||
ComponentOutput::Temperature {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
300.0,
|
||
);
|
||
system.add_constraint(c1).unwrap();
|
||
system.add_constraint(c2).unwrap();
|
||
|
||
let valve = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
// First link should succeed
|
||
system
|
||
.link_constraint_to_control(&ConstraintId::new("c1"), &BoundedVariableId::new("valve"))
|
||
.unwrap();
|
||
|
||
// Second link for same control should fail
|
||
let result = system
|
||
.link_constraint_to_control(&ConstraintId::new("c2"), &BoundedVariableId::new("valve"));
|
||
assert!(matches!(result, Err(DoFError::ControlAlreadyLinked { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn test_unlink_constraint() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
let node = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", node);
|
||
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
let valve = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
system
|
||
.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("valve"),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(system.inverse_control_mapping_count(), 1);
|
||
|
||
// Unlink
|
||
let removed = system.unlink_constraint(&ConstraintId::new("superheat"));
|
||
assert!(removed.is_some());
|
||
assert_eq!(removed.unwrap().as_str(), "valve");
|
||
assert_eq!(system.inverse_control_mapping_count(), 0);
|
||
|
||
// Unlinking again returns None
|
||
let removed_again = system.unlink_constraint(&ConstraintId::new("superheat"));
|
||
assert!(removed_again.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_control_variable_state_index() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Add two components and an edge
|
||
let n0 = system.add_component(make_mock(0));
|
||
let n1 = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", n0);
|
||
system.register_component_name("condenser", n1);
|
||
system.add_edge(n0, n1).unwrap();
|
||
system.finalize().unwrap();
|
||
|
||
// edge_count = 1, so base index = 2
|
||
assert_eq!(system.edge_count(), 1);
|
||
|
||
// Add constraint and bounded variable
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
let valve = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
// Link them
|
||
system
|
||
.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("valve"),
|
||
)
|
||
.unwrap();
|
||
|
||
// Control variable index should be at 2 * edge_count = 2
|
||
let idx = system.control_variable_state_index(&BoundedVariableId::new("valve"));
|
||
assert!(idx.is_some());
|
||
assert_eq!(idx.unwrap(), 2);
|
||
|
||
// Unlinked variable returns None
|
||
let v2 = BoundedVariable::new(BoundedVariableId::new("v2"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(v2).unwrap();
|
||
let idx2 = system.control_variable_state_index(&BoundedVariableId::new("v2"));
|
||
assert!(idx2.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_inverse_control_dof_balanced() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Add two components with 1 equation each and one edge
|
||
let n0 = system.add_component(make_mock(1));
|
||
let n1 = system.add_component(make_mock(1));
|
||
system.register_component_name("evaporator", n0);
|
||
system.add_edge(n0, n1).unwrap();
|
||
system.finalize().unwrap();
|
||
|
||
// n_edge_eqs = 2 (each mock component has 1 equation)
|
||
// n_edge_unknowns = 2 * 1 = 2
|
||
// Without constraints: 2 eqs = 2 unknowns (balanced)
|
||
|
||
// Add one constraint and one control
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
let valve = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
system
|
||
.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("valve"),
|
||
)
|
||
.unwrap();
|
||
|
||
// n_equations = 2 + 1 = 3
|
||
// n_unknowns = 2 + 1 = 3
|
||
// Balanced!
|
||
let result = system.validate_inverse_control_dof();
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_inverse_control_dof_over_constrained() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId, DoFError,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Add two components with 1 equation each and one edge
|
||
let n0 = system.add_component(make_mock(1));
|
||
let n1 = system.add_component(make_mock(1));
|
||
system.register_component_name("evaporator", n0);
|
||
system.register_component_name("condenser", n1);
|
||
system.add_edge(n0, n1).unwrap();
|
||
system.finalize().unwrap();
|
||
|
||
// Add two constraints but only one control
|
||
let c1 = Constraint::new(
|
||
ConstraintId::new("c1"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
let c2 = Constraint::new(
|
||
ConstraintId::new("c2"),
|
||
ComponentOutput::Temperature {
|
||
component_id: "condenser".to_string(),
|
||
},
|
||
300.0,
|
||
);
|
||
system.add_constraint(c1).unwrap();
|
||
system.add_constraint(c2).unwrap();
|
||
|
||
let valve = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
system
|
||
.link_constraint_to_control(&ConstraintId::new("c1"), &BoundedVariableId::new("valve"))
|
||
.unwrap();
|
||
|
||
// n_equations = 2 + 2 = 4
|
||
// n_unknowns = 2 + 1 = 3
|
||
// Over-constrained!
|
||
let result = system.validate_inverse_control_dof();
|
||
assert!(matches!(
|
||
result,
|
||
Err(DoFError::OverConstrainedSystem { .. })
|
||
));
|
||
if let Err(DoFError::OverConstrainedSystem {
|
||
constraint_count,
|
||
control_count,
|
||
equation_count,
|
||
unknown_count,
|
||
}) = result
|
||
{
|
||
assert_eq!(constraint_count, 2);
|
||
assert_eq!(control_count, 1);
|
||
assert_eq!(equation_count, 4);
|
||
assert_eq!(unknown_count, 3);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_inverse_control_dof_under_constrained() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Add two components with 1 equation each and one edge
|
||
let n0 = system.add_component(make_mock(1));
|
||
let n1 = system.add_component(make_mock(1));
|
||
system.register_component_name("evaporator", n0);
|
||
system.add_edge(n0, n1).unwrap();
|
||
system.finalize().unwrap();
|
||
|
||
// Add one constraint and one control
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
// Add two bounded variables but only link one
|
||
let v1 = BoundedVariable::new(BoundedVariableId::new("v1"), 0.5, 0.0, 1.0).unwrap();
|
||
let v2 = BoundedVariable::new(BoundedVariableId::new("v2"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(v1).unwrap();
|
||
system.add_bounded_variable(v2).unwrap();
|
||
|
||
system
|
||
.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("v1"),
|
||
)
|
||
.unwrap();
|
||
|
||
// n_equations = 2 + 1 = 3
|
||
// n_unknowns = 2 + 1 = 3 (only linked controls count)
|
||
// Balanced - unlinked bounded variables don't affect DoF
|
||
let result = system.validate_inverse_control_dof();
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_full_state_vector_len() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Add two components and one edge
|
||
let n0 = system.add_component(make_mock(0));
|
||
let n1 = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", n0);
|
||
system.add_edge(n0, n1).unwrap();
|
||
system.finalize().unwrap();
|
||
|
||
// Edge states: 2 * 1 = 2
|
||
assert_eq!(system.full_state_vector_len(), 2);
|
||
|
||
// Add constraint and control
|
||
let constraint = Constraint::new(
|
||
ConstraintId::new("superheat"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(constraint).unwrap();
|
||
|
||
let valve = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(valve).unwrap();
|
||
|
||
system
|
||
.link_constraint_to_control(
|
||
&ConstraintId::new("superheat"),
|
||
&BoundedVariableId::new("valve"),
|
||
)
|
||
.unwrap();
|
||
|
||
// Edge states: 2, control vars: 1, thermal couplings: 0
|
||
// Total: 2 + 1 + 0 = 3
|
||
assert_eq!(system.full_state_vector_len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn test_control_variable_indices() {
|
||
use crate::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
|
||
// Add two components and one edge
|
||
let n0 = system.add_component(make_mock(0));
|
||
let n1 = system.add_component(make_mock(0));
|
||
system.register_component_name("evaporator", n0);
|
||
system.register_component_name("condenser", n1);
|
||
system.add_edge(n0, n1).unwrap();
|
||
system.finalize().unwrap();
|
||
|
||
// Add two constraints and controls
|
||
let c1 = Constraint::new(
|
||
ConstraintId::new("c1"),
|
||
ComponentOutput::Superheat {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
5.0,
|
||
);
|
||
system.add_constraint(c1).unwrap();
|
||
|
||
let v1 = BoundedVariable::new(BoundedVariableId::new("v1"), 0.5, 0.0, 1.0).unwrap();
|
||
system.add_bounded_variable(v1).unwrap();
|
||
|
||
system
|
||
.link_constraint_to_control(&ConstraintId::new("c1"), &BoundedVariableId::new("v1"))
|
||
.unwrap();
|
||
|
||
let indices = system.control_variable_indices();
|
||
assert_eq!(indices.len(), 1);
|
||
assert_eq!(indices[0].1, 2); // 2 * edge_count = 2
|
||
}
|
||
|
||
struct BadMassFlowComponent {
|
||
ports: Vec<ConnectedPort>,
|
||
}
|
||
|
||
impl Component for BadMassFlowComponent {
|
||
fn compute_residuals(
|
||
&self,
|
||
_state: &StateSlice,
|
||
_residuals: &mut entropyk_components::ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
_jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
0
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&self.ports
|
||
}
|
||
|
||
fn port_mass_flows(
|
||
&self,
|
||
_state: &StateSlice,
|
||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||
Ok(vec![
|
||
entropyk_core::MassFlow::from_kg_per_s(1.0),
|
||
entropyk_core::MassFlow::from_kg_per_s(-0.5), // Intentionally unbalanced
|
||
])
|
||
}
|
||
}
|
||
|
||
/// Component with balanced mass flow (inlet = outlet)
|
||
struct BalancedMassFlowComponent {
|
||
ports: Vec<ConnectedPort>,
|
||
}
|
||
|
||
impl Component for BalancedMassFlowComponent {
|
||
fn compute_residuals(
|
||
&self,
|
||
_state: &StateSlice,
|
||
_residuals: &mut entropyk_components::ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
_jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
0
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&self.ports
|
||
}
|
||
|
||
fn port_mass_flows(
|
||
&self,
|
||
_state: &StateSlice,
|
||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||
// Balanced: inlet = 1.0 kg/s, outlet = -1.0 kg/s (sum = 0)
|
||
Ok(vec![
|
||
entropyk_core::MassFlow::from_kg_per_s(1.0),
|
||
entropyk_core::MassFlow::from_kg_per_s(-1.0),
|
||
])
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_mass_balance_passes_for_balanced_component() {
|
||
let mut system = System::new();
|
||
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(1.0),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(1.0),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let (c1, c2) = inlet.connect(outlet).unwrap();
|
||
|
||
let comp = Box::new(BalancedMassFlowComponent {
|
||
ports: vec![c1, c2],
|
||
});
|
||
|
||
let n0 = system.add_component(comp);
|
||
system.add_edge(n0, n0).unwrap(); // Self-edge to avoid isolated node
|
||
|
||
system.finalize().unwrap();
|
||
|
||
let state = vec![0.0; system.full_state_vector_len()];
|
||
let result = system.check_mass_balance(&state);
|
||
|
||
assert!(
|
||
result.is_ok(),
|
||
"Expected mass balance to pass for balanced component"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_mass_balance_violation() {
|
||
let mut system = System::new();
|
||
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(1.0),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(1.0),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let (c1, c2) = inlet.connect(outlet).unwrap();
|
||
|
||
let comp = Box::new(BadMassFlowComponent {
|
||
ports: vec![c1, c2], // Just to have ports
|
||
});
|
||
|
||
let n0 = system.add_component(comp);
|
||
system.add_edge(n0, n0).unwrap(); // Self-edge to avoid isolated node
|
||
|
||
system.finalize().unwrap();
|
||
|
||
// Ensure state is appropriately sized for finalize
|
||
let state = vec![0.0; system.full_state_vector_len()];
|
||
let result = system.check_mass_balance(&state);
|
||
|
||
assert!(result.is_err());
|
||
|
||
// Verify error contains mass error information
|
||
if let Err(crate::SolverError::Validation {
|
||
mass_error,
|
||
energy_error,
|
||
}) = result
|
||
{
|
||
assert!(mass_error > 0.0, "Mass error should be positive");
|
||
assert_eq!(
|
||
energy_error, 0.0,
|
||
"Energy error should be zero for mass-only validation"
|
||
);
|
||
} else {
|
||
panic!("Expected Validation error, got {:?}", result);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_mass_balance_tolerance_constant() {
|
||
// Verify the tolerance constant is accessible and has expected value
|
||
assert_eq!(System::MASS_BALANCE_TOLERANCE_KG_S, 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn test_generate_canonical_bytes() {
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
|
||
let bytes1 = sys.generate_canonical_bytes();
|
||
let bytes2 = sys.generate_canonical_bytes();
|
||
|
||
// Exact same graph state should produce same bytes
|
||
assert_eq!(bytes1, bytes2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_input_hash_deterministic() {
|
||
let mut sys1 = System::new();
|
||
let n0_1 = sys1.add_component(make_mock(0));
|
||
let n1_1 = sys1.add_component(make_mock(0));
|
||
sys1.add_edge(n0_1, n1_1).unwrap();
|
||
|
||
let mut sys2 = System::new();
|
||
let n0_2 = sys2.add_component(make_mock(0));
|
||
let n1_2 = sys2.add_component(make_mock(0));
|
||
sys2.add_edge(n0_2, n1_2).unwrap();
|
||
|
||
// Two identically constructed systems should have same hash
|
||
assert_eq!(sys1.input_hash(), sys2.input_hash());
|
||
|
||
// Now mutate one system by adding an edge
|
||
sys1.add_edge(n1_1, n0_1).unwrap();
|
||
|
||
// Hash should be different now
|
||
assert_ne!(sys1.input_hash(), sys2.input_hash());
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Story 9.6: Energy Validation Logging Improvement Tests
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Story 9.6: Energy Validation Logging Improvement Tests
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Test that check_energy_balance emits warnings for components without energy methods.
|
||
/// This test verifies the logging improvement from Story 9.6.
|
||
#[test]
|
||
fn test_energy_balance_warns_for_skipped_components() {
|
||
use tracing_subscriber::layer::SubscriberExt;
|
||
use tracing_subscriber::util::SubscriberInitExt;
|
||
|
||
// Create a system with mock components that don't implement energy_transfers()
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
sys.finalize().unwrap();
|
||
|
||
let state = vec![0.0; sys.state_vector_len()];
|
||
|
||
// Capture log output using tracing_subscriber
|
||
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||
let buffer_clone = log_buffer.clone();
|
||
let layer = tracing_subscriber::fmt::layer()
|
||
.with_writer(move || {
|
||
use std::io::Write;
|
||
struct BufWriter {
|
||
buf: std::sync::Arc<std::sync::Mutex<String>>,
|
||
}
|
||
impl Write for BufWriter {
|
||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||
let mut buf = self.buf.lock().unwrap();
|
||
buf.push_str(&String::from_utf8_lossy(data));
|
||
Ok(data.len())
|
||
}
|
||
fn flush(&mut self) -> std::io::Result<()> {
|
||
Ok(())
|
||
}
|
||
}
|
||
BufWriter {
|
||
buf: buffer_clone.clone(),
|
||
}
|
||
})
|
||
.without_time();
|
||
|
||
let _guard = tracing_subscriber::registry().with(layer).set_default();
|
||
|
||
// check_energy_balance should succeed (no violations) but will emit warnings
|
||
// for components that lack energy_transfers() and port_enthalpies()
|
||
let result = sys.check_energy_balance(&state);
|
||
assert!(
|
||
result.is_ok(),
|
||
"check_energy_balance should succeed even with skipped components"
|
||
);
|
||
|
||
// Verify warning was emitted
|
||
let log_output = log_buffer.lock().unwrap();
|
||
assert!(
|
||
log_output.contains("SKIPPED in energy balance validation"),
|
||
"Expected warning message not found in logs. Actual output: {}",
|
||
*log_output
|
||
);
|
||
}
|
||
|
||
/// Test that check_energy_balance includes component type in warning message.
|
||
#[test]
|
||
fn test_energy_balance_includes_component_type_in_warning() {
|
||
use tracing_subscriber::layer::SubscriberExt;
|
||
use tracing_subscriber::util::SubscriberInitExt;
|
||
|
||
// Create a system with mock components (need at least 2 nodes with edges to avoid isolated node error)
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
sys.finalize().unwrap();
|
||
|
||
let state = vec![0.0; sys.state_vector_len()];
|
||
|
||
// Capture log output using tracing_subscriber
|
||
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||
let buffer_clone = log_buffer.clone();
|
||
let layer = tracing_subscriber::fmt::layer()
|
||
.with_writer(move || {
|
||
use std::io::Write;
|
||
struct BufWriter {
|
||
buf: std::sync::Arc<std::sync::Mutex<String>>,
|
||
}
|
||
impl Write for BufWriter {
|
||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||
let mut buf = self.buf.lock().unwrap();
|
||
buf.push_str(&String::from_utf8_lossy(data));
|
||
Ok(data.len())
|
||
}
|
||
fn flush(&mut self) -> std::io::Result<()> {
|
||
Ok(())
|
||
}
|
||
}
|
||
BufWriter {
|
||
buf: buffer_clone.clone(),
|
||
}
|
||
})
|
||
.without_time();
|
||
|
||
let _guard = tracing_subscriber::registry().with(layer).set_default();
|
||
|
||
let result = sys.check_energy_balance(&state);
|
||
assert!(result.is_ok());
|
||
|
||
// Verify warning message includes component type information
|
||
// Note: type_name_of_val on a trait object returns the trait name ("Component"),
|
||
// not the concrete type. This is a known Rust limitation.
|
||
let log_output = log_buffer.lock().unwrap();
|
||
assert!(
|
||
log_output.contains("type: Component"),
|
||
"Expected component type information not found in logs. Actual output: {}",
|
||
*log_output
|
||
);
|
||
}
|
||
|
||
/// Test that check_energy_balance emits a summary warning with skipped component count.
|
||
#[test]
|
||
fn test_energy_balance_summary_warning() {
|
||
use tracing_subscriber::layer::SubscriberExt;
|
||
use tracing_subscriber::util::SubscriberInitExt;
|
||
|
||
// Create a system with mock components
|
||
let mut sys = System::new();
|
||
let n0 = sys.add_component(make_mock(0));
|
||
let n1 = sys.add_component(make_mock(0));
|
||
sys.add_edge(n0, n1).unwrap();
|
||
sys.add_edge(n1, n0).unwrap();
|
||
sys.finalize().unwrap();
|
||
|
||
let state = vec![0.0; sys.state_vector_len()];
|
||
|
||
// Capture log output
|
||
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||
let buffer_clone = log_buffer.clone();
|
||
let layer = tracing_subscriber::fmt::layer()
|
||
.with_writer(move || {
|
||
use std::io::Write;
|
||
struct BufWriter {
|
||
buf: std::sync::Arc<std::sync::Mutex<String>>,
|
||
}
|
||
impl Write for BufWriter {
|
||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||
let mut buf = self.buf.lock().unwrap();
|
||
buf.push_str(&String::from_utf8_lossy(data));
|
||
Ok(data.len())
|
||
}
|
||
fn flush(&mut self) -> std::io::Result<()> {
|
||
Ok(())
|
||
}
|
||
}
|
||
BufWriter {
|
||
buf: buffer_clone.clone(),
|
||
}
|
||
})
|
||
.without_time();
|
||
|
||
let _guard = tracing_subscriber::registry().with(layer).set_default();
|
||
|
||
let result = sys.check_energy_balance(&state);
|
||
assert!(result.is_ok());
|
||
|
||
// Verify summary warning was emitted
|
||
let log_output = log_buffer.lock().unwrap();
|
||
assert!(
|
||
log_output.contains("Energy balance validation incomplete"),
|
||
"Expected summary warning not found in logs. Actual output: {}",
|
||
*log_output
|
||
);
|
||
assert!(
|
||
log_output.contains("component(s) skipped"),
|
||
"Expected 'component(s) skipped' not found in logs. Actual output: {}",
|
||
*log_output
|
||
);
|
||
}
|
||
}
|