Files
Entropyk/crates/entropyk/src/builder.rs
Sepehr ab5dc7e568 chore: remove BMAD framework files and IDE configuration artifacts
Clean up unused BMAD workflow, agent, and command files across all IDE
configurations (.agent, .clinerules, .cursor, .gemini, .github, .kilocode,
.opencode) and internal module files (_bmad/bmb, _bmad/bmm).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-25 15:01:09 +02:00

1185 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use thiserror::Error;
use entropyk_core::{CircuitId, ThermalConductance};
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId};
use entropyk_solver::{AddEdgeError, ThermalCoupling, TopologyError};
use crate::ThermoError;
/// Error type for system builder operations.
#[derive(Error, Debug, Clone)]
pub enum SystemBuilderError {
/// A component with the given name already exists in the builder.
#[error("Component '{0}' already exists")]
ComponentExists(String),
/// The specified component name was not found in the builder.
#[error("Component '{0}' not found")]
ComponentNotFound(String),
/// Failed to create an edge between two components.
#[error("Failed to create edge from '{from}' to '{to}': {reason}")]
EdgeFailed {
/// Name of the source component.
from: String,
/// Name of the target component.
to: String,
/// Reason for the failure.
reason: String,
},
/// Self-loop edge is not allowed: a component cannot connect to itself.
#[error("Self-loop edge not allowed: component '{component}' cannot connect to itself")]
SelfLoopEdge {
/// Name of the component.
component: String,
},
/// Cross-circuit edge is not allowed: flow edges connect only nodes within the same circuit.
#[error("Cross-circuit edge not allowed: '{from}' and '{to}' are in different circuits")]
CrossCircuitEdge {
/// Name of the source component.
from: String,
/// Name of the target component.
to: String,
},
/// Too many circuits: circuit id exceeds the allowed range (0..=4).
#[error("Too many circuits: requested circuit {requested}, maximum is 5 (0..=4)")]
TooManyCircuits {
/// The requested circuit ID that exceeded the limit.
requested: u16,
},
/// Port name not found on the specified component.
#[error("Port '{port_name}' not found on component '{component}'")]
PortNotFound {
/// The component name.
component: String,
/// The port name that was not found.
port_name: String,
},
/// Port validation failed (fluid, pressure, or enthalpy mismatch).
#[error("Port validation failed from '{from}' to '{to}': {reason}")]
PortValidationFailed {
/// Source component name.
from: String,
/// Target component name.
to: String,
/// Reason for validation failure.
reason: String,
},
/// The system must be finalized before this operation.
#[error("System must be finalized before solving")]
NotFinalized,
/// Cannot build a system with no components.
#[error("Cannot build an empty system")]
EmptySystem,
/// Constraint or inverse control operation failed (duplicate id, invalid reference, etc.).
#[error("Constraint/inverse control: {reason}")]
ConstraintFailed {
/// Reason from the solver layer.
reason: String,
},
/// Bounded variable operation failed (e.g. invalid bounds min >= max).
#[error("Bounded variable: {reason}")]
BoundedVariableFailed {
/// Reason from the solver layer.
reason: String,
},
/// Inverse control DoF or linking failed (constraint/control not found, already linked, over/under-constrained).
#[error("Inverse control DoF/link: {reason}")]
InverseControlDoF {
/// Reason from the solver layer.
reason: String,
},
/// Failed to add a component to the system (internal topology error).
#[error("Failed to add component '{name}': {reason}")]
ComponentAddFailed {
/// Component name.
name: String,
/// Reason from the solver layer.
reason: String,
},
/// Thermal coupling failed (invalid circuit, missing components, or solver rejection).
#[error("Thermal coupling failed: {reason}")]
ThermalCouplingFailed {
/// Reason from the solver layer.
reason: String,
},
/// Attempted to couple a circuit to itself.
#[error("Cannot couple circuit {circuit} to itself — use into_inner() + add_thermal_coupling() for economizer scenarios")]
SameCircuitCoupling {
/// The circuit that was coupled to itself.
circuit: u16,
},
/// Attempted to couple to a circuit with no components.
#[error("Circuit {circuit} has no components — cannot create thermal coupling")]
EmptyCircuitCoupling {
/// The circuit that has no components.
circuit: u16,
},
}
/// A builder for creating thermodynamic systems with a fluent API.
///
/// The `SystemBuilder` provides an ergonomic way to construct thermodynamic
/// systems by adding components and edges with human-readable names.
///
/// # Example
///
/// ```
/// use entropyk::SystemBuilder;
///
/// let builder = SystemBuilder::new();
/// assert_eq!(builder.component_count(), 0);
/// ```
///
/// For real components, see the crate-level documentation.
pub struct SystemBuilder {
system: entropyk_solver::System,
component_names: HashMap<String, petgraph::graph::NodeIndex>,
fluid_name: Option<String>,
thermal_couplings: Vec<ThermalCoupling>,
}
impl SystemBuilder {
/// Creates a new empty system builder.
pub fn new() -> Self {
Self {
system: entropyk_solver::System::new(),
component_names: HashMap::new(),
fluid_name: None,
thermal_couplings: Vec::new(),
}
}
/// Sets the default fluid for the system.
///
/// This stores the fluid name for reference. The actual fluid assignment
/// to components is handled at the component/port level.
///
/// # Arguments
///
/// * `fluid` - The fluid name (e.g., "R134a", "R410A", "CO2")
#[inline]
pub fn with_fluid(mut self, fluid: impl Into<String>) -> Self {
self.fluid_name = Some(fluid.into());
self
}
/// Adds a named component to the system (circuit 0).
///
/// The name is used for later reference when creating edges.
/// Returns an error if a component with the same name already exists.
/// For multi-circuit systems, use [`component_in_circuit`](Self::component_in_circuit).
///
/// # Arguments
///
/// * `name` - A unique identifier for this component
/// * `component` - The component to add
#[inline]
pub fn component(
self,
name: &str,
component: Box<dyn entropyk_components::Component>,
) -> Result<Self, SystemBuilderError> {
self.component_in_circuit(name, component, CircuitId::ZERO)
}
/// Adds a named component to a specific circuit.
///
/// The name is used for later reference when creating edges. Circuit id must be in 0..=4 (max 5 circuits).
/// Returns an error if the name already exists or the circuit id is invalid.
///
/// # Arguments
///
/// * `name` - A unique identifier for this component
/// * `component` - The component to add
/// * `circuit_id` - The circuit to add the component to (0..=4)
///
/// # Errors
///
/// Returns `SystemBuilderError::TooManyCircuits` if `circuit_id` is outside 0..=4.
#[inline]
pub fn component_in_circuit(
mut self,
name: &str,
component: Box<dyn entropyk_components::Component>,
circuit_id: CircuitId,
) -> Result<Self, SystemBuilderError> {
if self.component_names.contains_key(name) {
return Err(SystemBuilderError::ComponentExists(name.to_string()));
}
let idx = self
.system
.add_component_to_circuit(component, circuit_id)
.map_err(|e| match e {
TopologyError::TooManyCircuits { requested } => {
SystemBuilderError::TooManyCircuits { requested }
}
other => SystemBuilderError::ComponentAddFailed {
name: name.to_string(),
reason: other.to_string(),
},
})?;
self.component_names.insert(name.to_string(), idx);
if !self.system.register_component_name(name, idx) {
self.component_names.remove(name);
return Err(SystemBuilderError::ComponentExists(format!(
"duplicate component name '{name}' already registered in solver"
)));
}
Ok(self)
}
/// Creates an edge between two named components.
///
/// The edge represents a fluid connection from the source component's
/// outlet to the target component's inlet.
///
/// # Arguments
///
/// * `from` - Name of the source component
/// * `to` - Name of the target component
///
/// # Errors
///
/// Returns an error if either component name is not found, if the edge
/// would connect two different circuits (cross-circuit edges are not allowed),
/// or if `from` and `to` refer to the same component (self-loops are not allowed).
#[inline]
pub fn edge(mut self, from: &str, to: &str) -> Result<Self, SystemBuilderError> {
let from_idx = self
.component_names
.get(from)
.ok_or_else(|| SystemBuilderError::ComponentNotFound(from.to_string()))?;
let to_idx = self
.component_names
.get(to)
.ok_or_else(|| SystemBuilderError::ComponentNotFound(to.to_string()))?;
if from_idx == to_idx {
return Err(SystemBuilderError::SelfLoopEdge {
component: from.to_string(),
});
}
let src_circuit = self.system.node_circuit(*from_idx);
let tgt_circuit = self.system.node_circuit(*to_idx);
if src_circuit != tgt_circuit {
return Err(SystemBuilderError::CrossCircuitEdge {
from: from.to_string(),
to: to.to_string(),
});
}
self.system
.add_edge(*from_idx, *to_idx)
.map_err(|e| SystemBuilderError::EdgeFailed {
from: from.to_string(),
to: to.to_string(),
reason: e.to_string(),
})?;
Ok(self)
}
/// Creates an edge between two named components with port validation.
///
/// Validates port compatibility (fluid, pressure, enthalpy continuity) using
/// the [`Component::resolve_port_name`](entropyk_components::Component::resolve_port_name)
/// method to map port names to indices, supporting both explicit
/// [`port_names()`](entropyk_components::Component::port_names) overrides and
/// convention-based fallback.
///
/// # Arguments
///
/// * `from` - Name of the source component
/// * `from_port` - Port name on source component (e.g., `"discharge"`, `"outlet"`)
/// * `to` - Name of the target component
/// * `to_port` - Port name on target component (e.g., `"inlet"`, `"suction"`)
///
/// # Errors
///
/// Returns an error if:
/// - Either component name is not found
/// - Port name is not recognized for the component
/// - Port validation fails (fluid, pressure, or enthalpy mismatch)
/// - Components are in different circuits
///
/// # Example
///
/// ```ignore
/// let system = SystemBuilder::new()
/// .component("comp", compressor)?
/// .component("cond", condenser)?
/// .edge_with_ports("comp", "discharge", "cond", "refrigerant_in")?
/// .build()?;
/// ```
#[inline]
pub fn edge_with_ports(
mut self,
from: &str,
from_port: &str,
to: &str,
to_port: &str,
) -> Result<Self, SystemBuilderError> {
let from_idx = self
.component_names
.get(from)
.ok_or_else(|| SystemBuilderError::ComponentNotFound(from.to_string()))?;
let to_idx = self
.component_names
.get(to)
.ok_or_else(|| SystemBuilderError::ComponentNotFound(to.to_string()))?;
if from_idx == to_idx {
return Err(SystemBuilderError::SelfLoopEdge {
component: from.to_string(),
});
}
let src_circuit = self.system.node_circuit(*from_idx);
let tgt_circuit = self.system.node_circuit(*to_idx);
if src_circuit != tgt_circuit {
return Err(SystemBuilderError::CrossCircuitEdge {
from: from.to_string(),
to: to.to_string(),
});
}
let from_component = self.system.component(*from_idx);
let from_port_idx = from_component
.resolve_port_name(from_port)
.map_err(|reason| SystemBuilderError::PortNotFound {
component: from.to_string(),
port_name: format!("{from_port}: {reason}"),
})?;
let to_component = self.system.component(*to_idx);
let to_port_idx = to_component.resolve_port_name(to_port).map_err(|reason| {
SystemBuilderError::PortNotFound {
component: to.to_string(),
port_name: format!("{to_port}: {reason}"),
}
})?;
self.system
.add_edge_with_ports(*from_idx, from_port_idx, *to_idx, to_port_idx)
.map_err(|e| match e {
AddEdgeError::Connection(conn_err) => SystemBuilderError::PortValidationFailed {
from: from.to_string(),
to: to.to_string(),
reason: conn_err.to_string(),
},
AddEdgeError::Topology(topo_err) => SystemBuilderError::EdgeFailed {
from: from.to_string(),
to: to.to_string(),
reason: topo_err.to_string(),
},
})?;
Ok(self)
}
/// Adds an output constraint for inverse control (e.g. superheat = 5K at a component).
///
/// The constraint's `component_id` (in [`ComponentOutput`]) should match a component name
/// added via [`component`](Self::component) or [`component_in_circuit`](Self::component_in_circuit).
/// Call [`link_constraint_to_control`](Self::link_constraint_to_control) to link this constraint
/// to a bounded control variable. After [`build`](Self::build), call
/// `system.validate_inverse_control_dof()` before solving to ensure the system is well-posed.
///
/// # Errors
///
/// Returns `SystemBuilderError::ConstraintFailed` if the solver rejects the constraint
/// (e.g. duplicate constraint id).
#[inline]
pub fn with_constraint(mut self, constraint: Constraint) -> Result<Self, SystemBuilderError> {
let component_id = constraint.output().component_id().to_string();
self.system.add_constraint(constraint).map_err(|e| {
let hint = if !self.component_names.contains_key(&component_id) {
format!("{} — component '{}' has not been added to the builder yet (call `component()` first)", e, component_id)
} else {
e.to_string()
};
SystemBuilderError::ConstraintFailed { reason: hint }
})?;
Ok(self)
}
/// Adds a bounded control variable for inverse control (e.g. valve position 0.01.0).
///
/// Link this variable to a constraint via [`link_constraint_to_control`](Self::link_constraint_to_control).
/// After [`build`](Self::build), call `system.validate_inverse_control_dof()` before solving.
///
/// # Errors
///
/// Returns `SystemBuilderError::BoundedVariableFailed` if bounds are invalid (e.g. min >= max).
#[inline]
pub fn with_bounded_variable(
mut self,
variable: BoundedVariable,
) -> Result<Self, SystemBuilderError> {
self.system.add_bounded_variable(variable).map_err(|e| {
SystemBuilderError::BoundedVariableFailed {
reason: e.to_string(),
}
})?;
Ok(self)
}
/// Links a constraint to a bounded control variable for one-shot inverse solving.
///
/// Both the constraint and the bounded variable must have been added previously with
/// [`with_constraint`](Self::with_constraint) and [`with_bounded_variable`](Self::with_bounded_variable).
/// Each constraint should be linked to exactly one control variable (and vice versa) for
/// a well-posed system. Call `system.validate_inverse_control_dof()` after [`build`](Self::build)
/// to check before solving.
///
/// # Errors
///
/// Returns `SystemBuilderError::InverseControlDoF` if the constraint or control is not found,
/// already linked, or the system is over/under-constrained.
#[inline]
pub fn link_constraint_to_control(
mut self,
constraint_id: &ConstraintId,
bounded_variable_id: &BoundedVariableId,
) -> Result<Self, SystemBuilderError> {
self.system
.link_constraint_to_control(constraint_id, bounded_variable_id)
.map_err(|e| SystemBuilderError::InverseControlDoF {
reason: e.to_string(),
})?;
Ok(self)
}
/// Adds a thermal coupling between two circuits (e.g. condenserevaporator HX link).
///
/// Both circuits must already contain at least one component (added via
/// [`component_in_circuit`](Self::component_in_circuit)). The UA value is
/// specified in kW/K and automatically converted to W/K internally.
///
/// The coupling is stored in the builder and applied to the system during
/// [`build`](Self::build).
///
/// # Arguments
///
/// * `circuit_a` - First circuit ID (hot side)
/// * `circuit_b` - Second circuit ID (cold side)
/// * `ua_kw_per_k` - Thermal conductance in kW/K (must be positive)
///
/// # Errors
///
/// - `SystemBuilderError::SameCircuitCoupling` if `circuit_a == circuit_b`.
/// - `SystemBuilderError::EmptyCircuitCoupling` if either circuit has no components.
/// - `SystemBuilderError::ThermalCouplingFailed` if UA is not positive.
pub fn thermal_coupling(
mut self,
circuit_a: u16,
circuit_b: u16,
ua_kw_per_k: f64,
) -> Result<Self, SystemBuilderError> {
// AC3: Reject same-circuit coupling
if circuit_a == circuit_b {
return Err(SystemBuilderError::SameCircuitCoupling {
circuit: circuit_a,
});
}
// AC5: Validate UA > 0
if ua_kw_per_k <= 0.0 {
return Err(SystemBuilderError::ThermalCouplingFailed {
reason: "UA must be positive".to_string(),
});
}
// AC2: Validate both circuits have components
if self
.system
.circuit_nodes(CircuitId(circuit_a))
.next()
.is_none()
{
return Err(SystemBuilderError::EmptyCircuitCoupling {
circuit: circuit_a,
});
}
if self
.system
.circuit_nodes(CircuitId(circuit_b))
.next()
.is_none()
{
return Err(SystemBuilderError::EmptyCircuitCoupling {
circuit: circuit_b,
});
}
// AC1 & AC5: Create coupling with kW→W conversion
let ua = ThermalConductance::from_kilowatts_per_kelvin(ua_kw_per_k);
let coupling =
ThermalCoupling::new(CircuitId(circuit_a), CircuitId(circuit_b), ua);
// Defer to build time
self.thermal_couplings.push(coupling);
Ok(self)
}
/// Gets the underlying system without finalizing.
///
/// This is useful when you need to perform additional operations
/// on the system before finalizing.
pub fn into_inner(self) -> entropyk_solver::System {
self.system
}
/// Gets a reference to the component name to index mapping.
pub fn component_names(&self) -> &HashMap<String, petgraph::graph::NodeIndex> {
&self.component_names
}
/// Returns the number of components added so far.
pub fn component_count(&self) -> usize {
self.component_names.len()
}
/// Returns the number of edges created so far.
pub fn edge_count(&self) -> usize {
self.system.edge_count()
}
/// Builds and finalizes the system.
///
/// This method consumes the builder and returns a finalized [`entropyk_solver::System`]
/// ready for solving.
///
/// # Errors
///
/// Returns an error if:
/// - The system is empty (no components)
/// - Finalization fails (e.g., invalid topology)
/// - Constraint/control DoF validation fails — call
/// [`validate_inverse_control_dof()`](entropyk_solver::System::validate_inverse_control_dof)
/// after building to check that constraint and control degrees of freedom are balanced.
pub fn build(self) -> Result<entropyk_solver::System, ThermoError> {
if self.component_names.is_empty() {
return Err(ThermoError::Builder(SystemBuilderError::EmptySystem));
}
let mut system = self.system;
system.finalize()?;
// Apply deferred thermal couplings
for coupling in self.thermal_couplings {
system.add_thermal_coupling(coupling).map_err(|e| {
ThermoError::Builder(SystemBuilderError::ThermalCouplingFailed {
reason: e.to_string(),
})
})?;
}
Ok(system)
}
/// Builds the system without finalizing.
///
/// Use this when you need to perform additional operations
/// that require an unfinalized system.
pub fn build_unfinalized(self) -> Result<entropyk_solver::System, SystemBuilderError> {
if self.component_names.is_empty() {
return Err(SystemBuilderError::EmptySystem);
}
Ok(self.system)
}
}
impl Default for SystemBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use entropyk_components::ComponentError;
struct MockComponent {
n_eqs: usize,
}
impl entropyk_components::Component for MockComponent {
fn compute_residuals(
&self,
_state: &[f64],
_residuals: &mut entropyk_components::ResidualVector,
) -> Result<(), ComponentError> {
Ok(())
}
fn jacobian_entries(
&self,
_state: &[f64],
_jacobian: &mut entropyk_components::JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
self.n_eqs
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
#[test]
fn test_builder_creates_system() {
let builder = SystemBuilder::new();
assert_eq!(builder.component_count(), 0);
assert_eq!(builder.edge_count(), 0);
}
#[test]
fn test_add_component() {
let builder = SystemBuilder::new()
.component("comp1", Box::new(MockComponent { n_eqs: 2 }))
.unwrap();
assert_eq!(builder.component_count(), 1);
}
#[test]
fn test_duplicate_component_error() {
let result = SystemBuilder::new()
.component("comp", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.component("comp", Box::new(MockComponent { n_eqs: 1 }));
assert!(result.is_err());
if let Err(SystemBuilderError::ComponentExists(name)) = result {
assert_eq!(name, "comp");
} else {
panic!("Expected ComponentExists error");
}
}
#[test]
fn test_add_edge() {
let builder = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.component("b", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.edge("a", "b")
.unwrap();
assert_eq!(builder.edge_count(), 1);
}
#[test]
fn test_edge_missing_component() {
let result = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.edge("a", "nonexistent");
assert!(result.is_err());
if let Err(SystemBuilderError::ComponentNotFound(name)) = result {
assert_eq!(name, "nonexistent");
} else {
panic!("Expected ComponentNotFound error");
}
}
#[test]
fn test_build_empty_system() {
let result = SystemBuilder::new().build();
assert!(result.is_err());
}
#[test]
fn test_default() {
let builder = SystemBuilder::default();
assert_eq!(builder.component_count(), 0);
}
#[test]
fn test_component_in_circuit_two_circuits_build() {
use entropyk_core::CircuitId;
let system = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("c", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.component_in_circuit("d", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.edge("a", "b")
.unwrap()
.edge("c", "d")
.unwrap()
.build()
.expect("build should succeed");
assert_eq!(system.circuit_count(), 2);
assert_eq!(system.circuit_nodes(CircuitId::ZERO).count(), 2);
assert_eq!(system.circuit_nodes(CircuitId(1)).count(), 2);
assert_eq!(system.node_count(), 4);
assert_eq!(system.edge_count(), 2);
}
#[test]
fn test_edge_cross_circuit_returns_error() {
use entropyk_core::CircuitId;
let result = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.edge("a", "b");
assert!(result.is_err());
if let Err(SystemBuilderError::CrossCircuitEdge { from, to }) = result {
assert_eq!(from, "a");
assert_eq!(to, "b");
} else {
panic!("Expected CrossCircuitEdge error");
}
}
#[test]
fn test_invalid_circuit_id_returns_error() {
use entropyk_core::CircuitId;
let result = SystemBuilder::new().component_in_circuit(
"a",
Box::new(MockComponent { n_eqs: 1 }),
CircuitId(5),
);
assert!(result.is_err());
if let Err(SystemBuilderError::TooManyCircuits { requested }) = result {
assert_eq!(requested, 5);
} else {
panic!("Expected TooManyCircuits error");
}
}
#[test]
fn test_edge_with_ports_missing_component() {
let result = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.edge_with_ports("nonexistent", "outlet", "a", "inlet");
assert!(result.is_err());
if let Err(SystemBuilderError::ComponentNotFound(name)) = result {
assert_eq!(name, "nonexistent");
} else {
panic!("Expected ComponentNotFound error");
}
}
#[test]
fn test_edge_with_ports_missing_target() {
let result = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.edge_with_ports("a", "outlet", "nonexistent", "inlet");
assert!(result.is_err());
if let Err(SystemBuilderError::ComponentNotFound(name)) = result {
assert_eq!(name, "nonexistent");
} else {
panic!("Expected ComponentNotFound error");
}
}
#[test]
fn test_edge_with_ports_cross_circuit_error() {
use entropyk_core::CircuitId;
let result = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.edge_with_ports("a", "outlet", "b", "inlet");
assert!(result.is_err());
if let Err(SystemBuilderError::CrossCircuitEdge { from, to }) = result {
assert_eq!(from, "a");
assert_eq!(to, "b");
} else {
panic!("Expected CrossCircuitEdge error");
}
}
#[test]
fn test_edge_with_ports_convention_based_names() {
let builder = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.component("b", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.edge_with_ports("a", "outlet", "b", "inlet")
.expect("edge_with_ports should resolve convention-based port names");
assert_eq!(builder.edge_count(), 1);
}
#[test]
fn test_edge_with_ports_unknown_port_name() {
let result = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.component("b", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.edge_with_ports("a", "totally_invalid_port", "b", "inlet");
assert!(result.is_err());
if let Err(SystemBuilderError::PortNotFound {
component,
port_name,
}) = result
{
assert_eq!(component, "a");
assert!(port_name.starts_with("totally_invalid_port"), "port_name should start with the port name, got: {port_name}");
} else {
panic!("Expected PortNotFound error");
}
}
#[test]
fn test_with_constraint_success() {
use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
let constraint = Constraint::new(
ConstraintId::new("sh"),
ComponentOutput::Superheat {
component_id: "evap".to_string(),
},
5.0,
);
let builder = SystemBuilder::new()
.component("evap", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.with_constraint(constraint)
.expect("with_constraint should succeed");
assert_eq!(builder.component_count(), 1);
}
#[test]
fn test_with_constraint_duplicate_id_error() {
use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
let c1 = Constraint::new(
ConstraintId::new("sh"),
ComponentOutput::Superheat {
component_id: "evap".to_string(),
},
5.0,
);
let c2 = Constraint::new(
ConstraintId::new("sh"),
ComponentOutput::Subcooling {
component_id: "cond".to_string(),
},
3.0,
);
let result = SystemBuilder::new()
.component("evap", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.with_constraint(c1)
.unwrap()
.with_constraint(c2);
assert!(result.is_err());
if let Err(SystemBuilderError::ConstraintFailed { reason }) = result {
assert!(reason.contains("Duplicate") || reason.contains("sh"));
} else {
panic!("Expected ConstraintFailed error");
}
}
#[test]
fn test_with_bounded_variable_success() {
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
let builder = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.with_bounded_variable(var)
.expect("with_bounded_variable should succeed");
assert_eq!(builder.component_count(), 1);
}
#[test]
fn test_with_bounded_variable_duplicate_id_error() {
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
let var1 = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
let var2 = BoundedVariable::new(BoundedVariableId::new("valve"), 0.6, 0.0, 1.0).unwrap();
let result = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.with_bounded_variable(var1)
.unwrap()
.with_bounded_variable(var2);
assert!(result.is_err());
if let Err(SystemBuilderError::BoundedVariableFailed { reason }) = result {
assert!(reason.contains("Duplicate") || reason.contains("valve"));
} else {
panic!("Expected BoundedVariableFailed error");
}
}
#[test]
fn test_link_constraint_to_control_success() {
use entropyk_solver::inverse::{
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
};
let constraint = Constraint::new(
ConstraintId::new("sh"),
ComponentOutput::Superheat {
component_id: "evap".to_string(),
},
5.0,
);
let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
let builder = SystemBuilder::new()
.component("evap", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.with_constraint(constraint)
.unwrap()
.with_bounded_variable(var)
.unwrap()
.link_constraint_to_control(&ConstraintId::new("sh"), &BoundedVariableId::new("valve"))
.expect("link_constraint_to_control should succeed");
assert_eq!(builder.component_count(), 1);
}
#[test]
fn test_link_constraint_to_control_nonexistent_constraint_error() {
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, ConstraintId};
let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap();
let result = SystemBuilder::new()
.component("a", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
.with_bounded_variable(var)
.unwrap()
.link_constraint_to_control(
&ConstraintId::new("nonexistent_constraint"),
&BoundedVariableId::new("valve"),
);
assert!(result.is_err());
if let Err(SystemBuilderError::InverseControlDoF { reason }) = result {
assert!(reason.contains("not found") || reason.contains("Nonexistent"));
} else {
panic!("Expected InverseControlDoF error");
}
}
#[test]
fn test_link_constraint_to_control_nonexistent_bounded_variable_error() {
use entropyk_solver::inverse::{
BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
};
let constraint = Constraint::new(
ConstraintId::new("sh"),
ComponentOutput::Superheat {
component_id: "evap".to_string(),
},
5.0,
);
let result = SystemBuilder::new()
.component("evap", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.with_constraint(constraint)
.unwrap()
.link_constraint_to_control(
&ConstraintId::new("sh"),
&BoundedVariableId::new("nonexistent_control"),
);
assert!(result.is_err());
if let Err(SystemBuilderError::InverseControlDoF { reason }) = result {
assert!(reason.contains("not found") || reason.contains("Nonexistent"));
} else {
panic!("Expected InverseControlDoF error");
}
}
#[test]
fn test_thermal_coupling_success() {
// AC1: thermal_coupling(circuit_a, circuit_b, ua_kw_per_k) with valid circuits
let builder = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.thermal_coupling(0, 1, 5.0)
.expect("thermal_coupling should succeed");
assert_eq!(builder.component_count(), 2);
}
#[test]
fn test_thermal_coupling_same_circuit_rejected() {
// AC3: SameCircuitCoupling when circuit_a == circuit_b
let result = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.thermal_coupling(0, 0, 5.0);
assert!(result.is_err());
if let Err(SystemBuilderError::SameCircuitCoupling { circuit }) = result {
assert_eq!(circuit, 0);
} else {
panic!("Expected SameCircuitCoupling error");
}
}
#[test]
fn test_thermal_coupling_empty_circuit_rejected() {
// AC2: EmptyCircuitCoupling when circuit has no components
let result = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.thermal_coupling(0, 2, 5.0);
assert!(result.is_err());
if let Err(SystemBuilderError::EmptyCircuitCoupling { circuit }) = result {
assert_eq!(circuit, 2);
} else {
panic!("Expected EmptyCircuitCoupling error");
}
}
#[test]
fn test_thermal_coupling_negative_ua_rejected() {
let result = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.thermal_coupling(0, 1, -1.0);
assert!(result.is_err());
if let Err(SystemBuilderError::ThermalCouplingFailed { reason }) = result {
assert!(reason.contains("positive"));
} else {
panic!("Expected ThermalCouplingFailed error for negative UA");
}
}
#[test]
fn test_thermal_coupling_built_system_has_coupling() {
// AC4: Built system contains the coupling
let system = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("c", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.component_in_circuit("d", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.edge("a", "b")
.unwrap()
.edge("c", "d")
.unwrap()
.thermal_coupling(0, 1, 5.0)
.unwrap()
.build()
.expect("build should succeed");
assert_eq!(system.thermal_coupling_count(), 1);
let coupling = system.get_thermal_coupling(0).expect("coupling should exist");
// AC4 & AC5: 5.0 kW/K = 5000 W/K
approx::assert_relative_eq!(
coupling.ua.to_watts_per_kelvin(),
5000.0,
epsilon = 1e-10
);
}
#[test]
fn test_thermal_coupling_kw_to_w_conversion() {
// AC5: kW→W conversion (2.5 kW/K → 2500 W/K)
let system = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("b", Box::new(MockComponent { n_eqs: 1 }), CircuitId::ZERO)
.unwrap()
.component_in_circuit("c", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.component_in_circuit("d", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.edge("a", "b")
.unwrap()
.edge("c", "d")
.unwrap()
.thermal_coupling(0, 1, 2.5)
.unwrap()
.build()
.expect("build should succeed");
let coupling = system.get_thermal_coupling(0).expect("coupling should exist");
approx::assert_relative_eq!(
coupling.ua.to_watts_per_kelvin(),
2500.0,
epsilon = 1e-10
);
}
#[test]
fn test_thermal_coupling_empty_first_circuit_rejected() {
// AC2: First circuit empty
let result = SystemBuilder::new()
.component_in_circuit("a", Box::new(MockComponent { n_eqs: 1 }), CircuitId(1))
.unwrap()
.thermal_coupling(0, 1, 5.0);
assert!(result.is_err());
if let Err(SystemBuilderError::EmptyCircuitCoupling { circuit }) = result {
assert_eq!(circuit, 0);
} else {
panic!("Expected EmptyCircuitCoupling error for circuit 0");
}
}
}