2026-02-21 10:43:55 +01:00

794 lines
25 KiB
Rust

//! 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<String>) -> 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<String> 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<f64>,
}
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<String>,
}
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<Self, BoundedVariableError> {
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<String>,
value: f64,
min: f64,
max: f64,
) -> Result<Self, BoundedVariableError> {
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<SaturationInfo> {
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"));
}
}