# 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<SaturationInfo>` 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<BoundedVariableId, BoundedVariable>` 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<ConstraintId, Constraint>` storage
- `System.component_names: HashSet<String>` 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<f64>,  // 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<ConvergedState, SolverError>` 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<Self, ConstraintError> { ... }
}
```

**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<SaturationInfo>` (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)
