# Story 5.2: Bounded Control Variables Status: completed ## Story As a control engineer, I want Box Constraints or Step Clipping for control variables, so that Newton steps stay physically possible and solver respects physical bounds. ## Acceptance Criteria 1. **Bounded Variable Definition** - Given a control variable (e.g., valve position, VFD speed) - When I define bounds [min, max] - Then the variable is constrained to stay within bounds - And bounds are validated: `min < max` enforced 2. **Step Clipping** - Given Newton step Δx that would exceed bounds - When computing the update - Then the step is scaled/clipped to stay within bounds - And the variable never goes outside bounds during iterations 3. **Convergence with Saturation Detection** - Given a converged solution - When the solution is at a bound (min or max) - Then `ControlSaturation` status is returned (not error) - And the status includes which variable is saturated and at which bound 4. **Infeasible Constraint Detection** - Given a constraint that requires value outside bounds - When solver converges to bound - Then clear diagnostic is provided: which constraint, which bound - And user knows constraint cannot be satisfied 5. **Integration with Constraints** - Given bounded control variables from this story - And constraints from Story 5.1 - When combined - Then bounded variables can be used as control inputs for constraints ## Tasks / Subtasks - [x] Create `BoundedVariable` type in `crates/solver/src/inverse/bounded.rs` - [x] `BoundedVariable` struct with value, min, max bounds - [x] `BoundedVariableId` newtype for type-safe identifiers - [x] `BoundedVariableError` enum for validation failures - [x] Implement step clipping logic - [x] `clip_step(current: f64, delta: f64, min: f64, max: f64) -> f64` - [x] Handle edge cases (NaN, Inf, equal bounds) - [x] Implement saturation detection - [x] `is_saturated(&self) -> Option` method - [x] `SaturationInfo` struct with variable_id, bound_type (Lower/Upper), bound_value - [x] Add `ControlSaturation` to error/status types - [x] Add `ControlSaturation` variant to `ConstraintError` (or new enum if needed) - [x] Include saturated variable info in the error - [x] Integrate bounded variables with System - [x] `add_bounded_variable(&mut self, var: BoundedVariable) -> Result<(), BoundedVariableError>` - [x] `bounded_variables: HashMap` storage - [x] Validation: component_id must exist in registry (like constraints) - [x] Write unit tests - [x] Test step clipping at bounds - [x] Test saturation detection - [x] Test invalid bounds (min >= max) - [x] Test integration with existing constraint types - [x] Update module exports - [x] Export from `mod.rs` - [x] Update `lib.rs` if needed ## Dev Notes ### Architecture Context This is **Story 5.2** in Epic 5: Inverse Control & Optimization. It builds on Story 5.1 (Constraint Definition Framework) and enables FR23. **Key Requirements from FR23:** > "System calculates necessary inputs (e.g., valve opening) respecting Bounded Constraints (0.0 ≤ Valve ≤ 1.0). If solution is out of bounds, solver returns 'Saturation' or 'ControlLimitReached' error" ### Previous Story Context (Story 5.1) Story 5.1 created the constraint framework in `crates/solver/src/inverse/`: - `Constraint`, `ConstraintId`, `ConstraintError`, `ComponentOutput` types - `System.constraints: HashMap` storage - `System.component_names: HashSet` registry for validation - Constraint residual: `measured_value - target_value` **Patterns to follow from Story 5.1:** - Type-safe newtype identifiers (`BoundedVariableId` like `ConstraintId`) - `thiserror` for error types - Validation against component registry before adding - KaTeX documentation in rustdoc ### Technical Requirements **Module Location:** ``` crates/solver/src/inverse/ ├── mod.rs (existing - update exports) ├── constraint.rs (existing - from Story 5.1) └── bounded.rs (NEW - this story) ``` **BoundedVariable Design:** ```rust pub struct BoundedVariable { id: BoundedVariableId, current_value: f64, min: f64, max: f64, } pub struct BoundedVariableId(String); // Follow ConstraintId pattern pub enum BoundedVariableError { InvalidBounds { min: f64, max: f64 }, DuplicateId { id: BoundedVariableId }, ValueOutOfBounds { value: f64, min: f64, max: f64 }, InvalidComponent { component_id: String }, } pub enum SaturationType { LowerBound, UpperBound, } pub struct SaturationInfo { variable_id: BoundedVariableId, saturation_type: SaturationType, bound_value: f64, constraint_target: Option, // What we were trying to achieve } ``` **Step Clipping Algorithm:** ```rust pub fn clip_step(current: f64, delta: f64, min: f64, max: f64) -> f64 { let proposed = current + delta; proposed.clamp(min, max) } ``` Note: Consider more sophisticated approaches later (line search, trust region) but clipping is MVP. **ControlSaturation vs Error:** Per FR23, saturation is NOT an error - it's a status: - Converged at bound = success with saturation info - Use `Result` where `ConvergedState` includes optional `saturation_info` - Or use a separate `ControlStatus` enum if that fits existing patterns better ### Existing Code Patterns **From `constraint.rs` (Story 5.1):** ```rust // Use thiserror for errors #[derive(Error, Debug, Clone, PartialEq)] pub enum ConstraintError { ... } // Newtype pattern for IDs #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ConstraintId(String); // Builder-style methods impl Constraint { pub fn with_tolerance(...) -> Result { ... } } ``` **From `system.rs` (Story 5.1):** ```rust // HashMap storage with validation pub fn add_constraint(&mut self, constraint: Constraint) -> Result<(), ConstraintError> { // 1. Validate component_id exists in registry // 2. Check for duplicate id // 3. Insert into HashMap } ``` ### Project Structure Notes - New file: `crates/solver/src/inverse/bounded.rs` - Modify: `crates/solver/src/inverse/mod.rs` (add exports) - Modify: `crates/solver/src/system.rs` (add bounded_variables storage) ### Anti-Patterns to Avoid - **DON'T** use bare `f64` for values in public API - consider newtype if consistency with Constraint pattern is desired - **DON'T** panic on invalid bounds - return `Result` with clear error - **DON'T** use `unwrap()` or `expect()` - follow zero-panic policy - **DON'T** use `println!` - use `tracing` for debug output - **DON'T** make saturation an error - it's a valid solver outcome (status) - **DON'T** forget to validate component_id against registry (AC5 from Story 5.1 code review) ### References - [Source: `epics.md` Story 5.2] Bounded Control Variables acceptance criteria - [Source: `epics.md` FR23] "Bounded Constraints (0.0 ≤ Valve ≤ 1.0)" - [Source: `architecture.md`] Inverse Control pattern at `crates/solver/src/inverse/` - [Source: `architecture.md`] Error handling via `ThermoError` (or appropriate error type) - [Source: Story 5.1 implementation] `constraint.rs` for patterns to follow - [Source: Story 5.1 code review] Component registry validation requirement ### Related Stories - **Story 5.1**: Constraint Definition Framework (DONE) - provides constraint types - **Story 5.3**: Residual Embedding for Inverse Control - will combine bounded variables with constraints in solver ## Dev Agent Record ### Agent Model Used Claude 3.5 Sonnet via opencode (Antigravity) ### Debug Log References N/A ### Completion Notes List - ✅ Created `bounded.rs` module with complete bounded variable types - ✅ `BoundedVariableId` newtype with From traits for ergonomic construction (matches `ConstraintId` pattern) - ✅ `BoundedVariable` struct with value, min, max bounds and optional component_id - ✅ `BoundedVariableError` enum with thiserror for comprehensive error handling - ✅ `SaturationType` enum (LowerBound, UpperBound) with Display impl - ✅ `SaturationInfo` struct with variable_id, saturation_type, bound_value, constraint_target - ✅ `clip_step()` standalone function for solver loop use - ✅ `is_saturated()` method returns `Option` (not error - per FR23) - ✅ Edge case handling: NaN returns clamped current value, Inf clips to bounds - ✅ Integrated into System struct with HashMap storage - ✅ Component registry validation (matches Story 5.1 code review requirement) - ✅ `saturated_variables()` method to check all variables at once - ✅ Module-level rustdoc with KaTeX formulas for mathematical notation - ✅ 25 unit tests in bounded.rs + 9 integration tests in system.rs = 34 new tests - ✅ All 195 solver tests passing ### File List - `crates/solver/src/inverse/bounded.rs` (new - bounded variable types with 25 tests) - `crates/solver/src/inverse/mod.rs` (modified - added bounded module and exports) - `crates/solver/src/system.rs` (modified - added bounded_variables field, 9 new tests) ## Change Log - 2026-02-21: Implemented Story 5.2 - Bounded Control Variables - Created bounded.rs module following Story 5.1 patterns - Integrated bounded variables into System with component registry validation - All 195 solver tests passing - Framework ready for Story 5.3 (Residual Embedding for Inverse Control)