794 lines
25 KiB
Rust
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"));
|
|
}
|
|
}
|