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>
1185 lines
41 KiB
Rust
1185 lines
41 KiB
Rust
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.0–1.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. condenser–evaporator 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");
|
||
}
|
||
}
|
||
}
|