//! Bounded control variables for inverse control. //! //! This module provides types for control variables with physical bounds: //! - [`BoundedVariable`]: A control variable constrained to [min, max] range //! - [`BoundedVariableId`]: Type-safe bounded variable identifier //! - [`BoundedVariableError`]: Errors during bounded variable operations //! - [`SaturationInfo`]: Information about variable saturation at bounds //! //! # Mathematical Foundation //! //! Box constraints ensure Newton steps stay physically possible: //! //! $$x_{new} = \text{clip}(x_{current} + \Delta x, x_{min}, x_{max})$$ //! //! where clipping limits the step to stay within bounds. //! //! # Example //! //! ```rust,ignore //! use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId}; //! //! // Valve position: 0.0 (closed) to 1.0 (fully open) //! let valve = BoundedVariable::new( //! BoundedVariableId::new("expansion_valve"), //! 0.5, // initial position: 50% open //! 0.0, // min: fully closed //! 1.0, // max: fully open //! ).unwrap(); //! //! // Step clipping keeps value in bounds //! let clipped = valve.clip_step(0.8); // tries to move to 1.3 //! assert_eq!(clipped, 1.0); // clipped to max //! ``` use std::fmt; use thiserror::Error; // ───────────────────────────────────────────────────────────────────────────── // BoundedVariableId - Type-safe identifier // ───────────────────────────────────────────────────────────────────────────── /// Type-safe identifier for a bounded control variable. /// /// Uses a string internally but provides type safety and clear intent. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct BoundedVariableId(String); impl BoundedVariableId { /// Creates a new bounded variable identifier. /// /// # Arguments /// /// * `id` - Unique identifier string for the bounded variable /// /// # Example /// /// ```rust /// use entropyk_solver::inverse::BoundedVariableId; /// /// let id = BoundedVariableId::new("valve_position"); /// ``` pub fn new(id: impl Into) -> Self { BoundedVariableId(id.into()) } /// Returns the identifier as a string slice. pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for BoundedVariableId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl From<&str> for BoundedVariableId { fn from(s: &str) -> Self { BoundedVariableId::new(s) } } impl From for BoundedVariableId { fn from(s: String) -> Self { BoundedVariableId(s) } } // ───────────────────────────────────────────────────────────────────────────── // BoundedVariableError - Error handling // ───────────────────────────────────────────────────────────────────────────── /// Errors that can occur during bounded variable operations. #[derive(Error, Debug, Clone, PartialEq)] pub enum BoundedVariableError { /// The bounds are invalid (min >= max). #[error("Invalid bounds: min ({min}) must be less than max ({max})")] InvalidBounds { /// The minimum bound min: f64, /// The maximum bound max: f64, }, /// A bounded variable with this ID already exists. #[error("Duplicate bounded variable id: '{id}'")] DuplicateId { /// The duplicate identifier id: BoundedVariableId, }, /// The initial value is outside the bounds. #[error("Initial value {value} is outside bounds [{min}, {max}]")] ValueOutOfBounds { /// The out-of-bounds value value: f64, /// The minimum bound min: f64, /// The maximum bound max: f64, }, /// The referenced component does not exist in the system. #[error("Invalid component reference: '{component_id}' not found")] InvalidComponent { /// The invalid component identifier component_id: String, }, /// Invalid configuration. #[error("Invalid bounded variable configuration: {reason}")] InvalidConfiguration { /// Reason for the validation failure reason: String, }, } // ───────────────────────────────────────────────────────────────────────────── // SaturationType and SaturationInfo - Saturation detection // ───────────────────────────────────────────────────────────────────────────── /// Type of bound saturation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SaturationType { /// Variable is at the lower bound (value == min) LowerBound, /// Variable is at the upper bound (value == max) UpperBound, } impl fmt::Display for SaturationType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { SaturationType::LowerBound => write!(f, "lower bound"), SaturationType::UpperBound => write!(f, "upper bound"), } } } /// Information about a saturated bounded variable. /// /// When a control variable reaches a bound during solving, this struct /// captures the details for diagnostic purposes. #[derive(Debug, Clone, PartialEq)] pub struct SaturationInfo { /// The saturated variable's identifier pub variable_id: BoundedVariableId, /// Which bound is active pub saturation_type: SaturationType, /// The bound value (min or max) pub bound_value: f64, /// The constraint target that couldn't be achieved (if applicable) pub constraint_target: Option, } impl SaturationInfo { /// Creates a new SaturationInfo. pub fn new( variable_id: BoundedVariableId, saturation_type: SaturationType, bound_value: f64, ) -> Self { SaturationInfo { variable_id, saturation_type, bound_value, constraint_target: None, } } /// Adds constraint target information. pub fn with_constraint_target(mut self, target: f64) -> Self { self.constraint_target = Some(target); self } } // ───────────────────────────────────────────────────────────────────────────── // BoundedVariable - Core bounded variable type // ───────────────────────────────────────────────────────────────────────────── /// A control variable with physical bounds for inverse control. /// /// Bounded variables ensure that Newton-Raphson steps stay within /// physically meaningful ranges (e.g., valve position 0.0 to 1.0). /// /// # Bounds Validation /// /// Bounds must satisfy `min < max`. The initial value must be within /// bounds: `min <= value <= max`. /// /// # Step Clipping /// /// When applying a Newton step Δx, the new value is clipped: /// /// $$x_{new} = \text{clamp}(x + \Delta x, x_{min}, x_{max})$$ /// /// # Saturation Detection /// /// After convergence, check if the variable is saturated (at a bound) /// using [`is_saturated`](Self::is_saturated). /// /// # Example /// /// ```rust,ignore /// // VFD speed: 30% to 100% (minimum speed for compressor) /// let vfd = BoundedVariable::new( /// BoundedVariableId::new("compressor_vfd"), /// 0.8, // initial: 80% speed /// 0.3, // min: 30% /// 1.0, // max: 100% /// )?; /// /// // Apply Newton step /// let new_value = vfd.clip_step(0.3); // tries to go to 1.1 /// assert_eq!(new_value, 1.0); // clipped to max /// ``` #[derive(Debug, Clone, PartialEq)] pub struct BoundedVariable { /// Unique identifier for this bounded variable id: BoundedVariableId, /// Current value of the variable value: f64, /// Lower bound (inclusive) min: f64, /// Upper bound (inclusive) max: f64, /// Optional component this variable controls component_id: Option, } impl BoundedVariable { /// Tolerance for saturation detection (relative to bound range). const SATURATION_TOL: f64 = 1e-9; /// Creates a new bounded variable. /// /// # Arguments /// /// * `id` - Unique identifier for this variable /// * `value` - Initial value /// * `min` - Lower bound (inclusive) /// * `max` - Upper bound (inclusive) /// /// # Errors /// /// Returns `BoundedVariableError::InvalidBounds` if `min >= max`. /// Returns `BoundedVariableError::ValueOutOfBounds` if `value` is outside bounds. /// /// # Example /// /// ```rust,ignore /// let valve = BoundedVariable::new( /// BoundedVariableId::new("valve"), /// 0.5, 0.0, 1.0 /// )?; /// ``` pub fn new( id: BoundedVariableId, value: f64, min: f64, max: f64, ) -> Result { if min >= max { return Err(BoundedVariableError::InvalidBounds { min, max }); } if value < min || value > max { return Err(BoundedVariableError::ValueOutOfBounds { value, min, max }); } Ok(BoundedVariable { id, value, min, max, component_id: None, }) } /// Creates a new bounded variable associated with a component. /// /// # Arguments /// /// * `id` - Unique identifier for this variable /// * `component_id` - Component this variable controls /// * `value` - Initial value /// * `min` - Lower bound (inclusive) /// * `max` - Upper bound (inclusive) pub fn with_component( id: BoundedVariableId, component_id: impl Into, value: f64, min: f64, max: f64, ) -> Result { let mut var = Self::new(id, value, min, max)?; var.component_id = Some(component_id.into()); Ok(var) } /// Returns the variable identifier. pub fn id(&self) -> &BoundedVariableId { &self.id } /// Returns the current value. pub fn value(&self) -> f64 { self.value } /// Returns the lower bound. pub fn min(&self) -> f64 { self.min } /// Returns the upper bound. pub fn max(&self) -> f64 { self.max } /// Returns the component ID if associated with a component. pub fn component_id(&self) -> Option<&str> { self.component_id.as_deref() } /// Sets the current value. /// /// # Errors /// /// Returns `BoundedVariableError::ValueOutOfBounds` if the value /// is outside the bounds. pub fn set_value(&mut self, value: f64) -> Result<(), BoundedVariableError> { let range = self.max - self.min; let tol = range * Self::SATURATION_TOL; if value < self.min - tol || value > self.max + tol { return Err(BoundedVariableError::ValueOutOfBounds { value, min: self.min, max: self.max, }); } self.value = value.clamp(self.min, self.max); Ok(()) } /// Clips a proposed step to stay within bounds. /// /// Given a delta from the current position, returns the clipped value /// that stays within [min, max]. /// /// # Arguments /// /// * `delta` - Proposed change from current value /// /// # Returns /// /// The clipped value: `clamp(current + delta, min, max)` /// /// # Example /// /// ```rust,ignore /// let var = BoundedVariable::new(id, 0.5, 0.0, 1.0)?; /// /// // Step that would exceed max /// assert_eq!(var.clip_step(0.6), 1.0); /// /// // Step that would exceed min /// assert_eq!(var.clip_step(-0.7), 0.0); /// /// // Step within bounds /// assert_eq!(var.clip_step(0.3), 0.8); /// ``` pub fn clip_step(&self, delta: f64) -> f64 { clip_step(self.value, delta, self.min, self.max) } /// Applies a clipped step and updates the internal value. /// /// # Arguments /// /// * `delta` - Proposed change from current value /// /// # Returns /// /// The new (clipped) value. pub fn apply_step(&mut self, delta: f64) -> f64 { self.value = self.clip_step(delta); self.value } /// Checks if the variable is at a bound (saturated). /// /// # Returns /// /// `Some(SaturationInfo)` if at a bound, `None` if not saturated. /// /// # Example /// /// ```rust,ignore /// let var = BoundedVariable::new(id, 1.0, 0.0, 1.0)?; /// let sat = var.is_saturated(); /// assert!(sat.is_some()); /// assert_eq!(sat.unwrap().saturation_type, SaturationType::UpperBound); /// ``` pub fn is_saturated(&self) -> Option { let range = self.max - self.min; let tol = range * Self::SATURATION_TOL; if (self.value - self.min).abs() <= tol { Some(SaturationInfo::new( self.id.clone(), SaturationType::LowerBound, self.min, )) } else if (self.value - self.max).abs() <= tol { Some(SaturationInfo::new( self.id.clone(), SaturationType::UpperBound, self.max, )) } else { None } } /// Checks if a value is within the bounds. pub fn is_within_bounds(&self, value: f64) -> bool { value >= self.min && value <= self.max } /// Returns the distance to the nearest bound. /// /// Returns 0.0 if at a bound, positive otherwise. pub fn distance_to_bound(&self) -> f64 { let dist_to_min = self.value - self.min; let dist_to_max = self.max - self.value; dist_to_min.min(dist_to_max) } } // ───────────────────────────────────────────────────────────────────────────── // Step Clipping Function // ───────────────────────────────────────────────────────────────────────────── /// Clips a step to stay within bounds. /// /// This is a standalone function for use in solver loops where you have /// raw values rather than a `BoundedVariable` struct. /// /// # Arguments /// /// * `current` - Current value /// * `delta` - Proposed change /// * `min` - Lower bound /// * `max` - Upper bound /// /// # Returns /// /// The clipped value: `clamp(current + delta, min, max)` /// /// # Edge Cases /// /// - NaN delta returns the clamped current value (NaN propagates to proposed, then clamped) /// - Infinite delta clips to the appropriate bound /// - If min == max, returns min (degenerate case) /// /// # Example /// /// ```rust /// use entropyk_solver::inverse::clip_step; /// /// // Normal clipping /// assert_eq!(clip_step(0.5, 0.6, 0.0, 1.0), 1.0); /// assert_eq!(clip_step(0.5, -0.7, 0.0, 1.0), 0.0); /// /// // Within bounds /// assert_eq!(clip_step(0.5, 0.3, 0.0, 1.0), 0.8); /// ``` pub fn clip_step(current: f64, delta: f64, min: f64, max: f64) -> f64 { let proposed = current + delta; // Handle NaN: if proposed is NaN, clamp will return min // This is intentional behavior - NaN steps should not corrupt state if proposed.is_nan() { // Return current if it's valid, otherwise min return if current.is_nan() { min } else { current.clamp(min, max) }; } proposed.clamp(min, max) } // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn test_bounded_variable_id_creation() { let id = BoundedVariableId::new("valve_position"); assert_eq!(id.as_str(), "valve_position"); assert_eq!(id.to_string(), "valve_position"); } #[test] fn test_bounded_variable_id_from_impls() { let id1 = BoundedVariableId::from("valve"); assert_eq!(id1.as_str(), "valve"); let id2 = BoundedVariableId::from("valve".to_string()); assert_eq!(id2.as_str(), "valve"); } #[test] fn test_bounded_variable_creation() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); assert_eq!(var.value(), 0.5); assert_eq!(var.min(), 0.0); assert_eq!(var.max(), 1.0); assert!(var.component_id().is_none()); } #[test] fn test_bounded_variable_with_component() { let var = BoundedVariable::with_component( BoundedVariableId::new("valve"), "expansion_valve", 0.5, 0.0, 1.0, ) .unwrap(); assert_eq!(var.component_id(), Some("expansion_valve")); } #[test] fn test_invalid_bounds_min_eq_max() { let result = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 1.0, 1.0); assert!(matches!( result, Err(BoundedVariableError::InvalidBounds { min: 1.0, max: 1.0 }) )); } #[test] fn test_invalid_bounds_min_gt_max() { let result = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 1.0, 0.0); assert!(matches!( result, Err(BoundedVariableError::InvalidBounds { min: 1.0, max: 0.0 }) )); } #[test] fn test_value_out_of_bounds_below() { let result = BoundedVariable::new(BoundedVariableId::new("valve"), -0.1, 0.0, 1.0); assert!(matches!( result, Err(BoundedVariableError::ValueOutOfBounds { value: -0.1, min: 0.0, max: 1.0 }) )); } #[test] fn test_value_out_of_bounds_above() { let result = BoundedVariable::new(BoundedVariableId::new("valve"), 1.1, 0.0, 1.0); assert!(matches!( result, Err(BoundedVariableError::ValueOutOfBounds { value: 1.1, min: 0.0, max: 1.0 }) )); } #[test] fn test_clip_step_within_bounds() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); assert_eq!(var.clip_step(0.3), 0.8); assert_eq!(var.clip_step(-0.3), 0.2); } #[test] fn test_clip_step_exceeds_max() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); assert_eq!(var.clip_step(0.6), 1.0); assert_eq!(var.clip_step(1.0), 1.0); } #[test] fn test_clip_step_exceeds_min() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); assert_eq!(var.clip_step(-0.6), 0.0); assert_eq!(var.clip_step(-1.0), 0.0); } #[test] fn test_clip_step_standalone() { // Normal cases approx::assert_relative_eq!(clip_step(0.5, 0.6, 0.0, 1.0), 1.0); approx::assert_relative_eq!(clip_step(0.5, -0.7, 0.0, 1.0), 0.0); approx::assert_relative_eq!(clip_step(0.5, 0.3, 0.0, 1.0), 0.8); // At bounds approx::assert_relative_eq!(clip_step(0.0, -0.1, 0.0, 1.0), 0.0); approx::assert_relative_eq!(clip_step(1.0, 0.1, 0.0, 1.0), 1.0); } #[test] fn test_clip_step_nan_handling() { // NaN delta should not propagate; return clamped current let result = clip_step(0.5, f64::NAN, 0.0, 1.0); approx::assert_relative_eq!(result, 0.5); } #[test] fn test_clip_step_infinity() { // Positive infinity clips to max approx::assert_relative_eq!(clip_step(0.5, f64::INFINITY, 0.0, 1.0), 1.0); // Negative infinity clips to min approx::assert_relative_eq!(clip_step(0.5, f64::NEG_INFINITY, 0.0, 1.0), 0.0); } #[test] fn test_saturation_at_min() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.0, 0.0, 1.0).unwrap(); let sat = var.is_saturated(); assert!(sat.is_some()); let info = sat.unwrap(); assert_eq!(info.saturation_type, SaturationType::LowerBound); approx::assert_relative_eq!(info.bound_value, 0.0); } #[test] fn test_saturation_at_max() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 1.0, 0.0, 1.0).unwrap(); let sat = var.is_saturated(); assert!(sat.is_some()); let info = sat.unwrap(); assert_eq!(info.saturation_type, SaturationType::UpperBound); approx::assert_relative_eq!(info.bound_value, 1.0); } #[test] fn test_no_saturation_in_middle() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); assert!(var.is_saturated().is_none()); } #[test] fn test_saturation_info_with_constraint_target() { let info = SaturationInfo::new( BoundedVariableId::new("valve"), SaturationType::UpperBound, 1.0, ) .with_constraint_target(1.5); assert_eq!(info.constraint_target, Some(1.5)); } #[test] fn test_set_value_valid() { let mut var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); var.set_value(0.7).unwrap(); approx::assert_relative_eq!(var.value(), 0.7); var.set_value(0.0).unwrap(); approx::assert_relative_eq!(var.value(), 0.0); var.set_value(1.0).unwrap(); approx::assert_relative_eq!(var.value(), 1.0); } #[test] fn test_set_value_invalid() { let mut var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); assert!(var.set_value(-0.1).is_err()); assert!(var.set_value(1.1).is_err()); // Value should remain unchanged approx::assert_relative_eq!(var.value(), 0.5); } #[test] fn test_apply_step() { let mut var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); let new_val = var.apply_step(0.6); approx::assert_relative_eq!(new_val, 1.0); approx::assert_relative_eq!(var.value(), 1.0); let new_val = var.apply_step(-0.3); approx::assert_relative_eq!(new_val, 0.7); approx::assert_relative_eq!(var.value(), 0.7); } #[test] fn test_is_within_bounds() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); assert!(var.is_within_bounds(0.0)); assert!(var.is_within_bounds(0.5)); assert!(var.is_within_bounds(1.0)); assert!(!var.is_within_bounds(-0.1)); assert!(!var.is_within_bounds(1.1)); } #[test] fn test_distance_to_bound() { let var = BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(); approx::assert_relative_eq!(var.distance_to_bound(), 0.5); let var_at_min = BoundedVariable::new(BoundedVariableId::new("valve"), 0.0, 0.0, 1.0).unwrap(); approx::assert_relative_eq!(var_at_min.distance_to_bound(), 0.0); let var_at_max = BoundedVariable::new(BoundedVariableId::new("valve"), 1.0, 0.0, 1.0).unwrap(); approx::assert_relative_eq!(var_at_max.distance_to_bound(), 0.0); } #[test] fn test_saturation_type_display() { assert_eq!(format!("{}", SaturationType::LowerBound), "lower bound"); assert_eq!(format!("{}", SaturationType::UpperBound), "upper bound"); } #[test] fn test_error_display() { let err = BoundedVariableError::InvalidBounds { min: 0.0, max: 0.0 }; assert!(err.to_string().contains("0")); let err = BoundedVariableError::DuplicateId { id: BoundedVariableId::new("dup"), }; assert!(err.to_string().contains("dup")); let err = BoundedVariableError::InvalidComponent { component_id: "unknown".to_string(), }; assert!(err.to_string().contains("unknown")); } }