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, fluid_name: Option, thermal_couplings: Vec, } 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) -> 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, ) -> Result { 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, circuit_id: CircuitId, ) -> Result { 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 { 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 { 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 { 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.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.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 { // 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 { &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 { 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 { 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"); } } }