Entropyk/_bmad-output/implementation-artifacts/5-2-bounded-control-variables.md

252 lines
9.3 KiB
Markdown

# 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)