9.3 KiB
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
-
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 < maxenforced
-
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
-
Convergence with Saturation Detection
- Given a converged solution
- When the solution is at a bound (min or max)
- Then
ControlSaturationstatus is returned (not error) - And the status includes which variable is saturated and at which bound
-
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
-
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
- Create
BoundedVariabletype incrates/solver/src/inverse/bounded.rsBoundedVariablestruct with value, min, max boundsBoundedVariableIdnewtype for type-safe identifiersBoundedVariableErrorenum for validation failures
- Implement step clipping logic
clip_step(current: f64, delta: f64, min: f64, max: f64) -> f64- Handle edge cases (NaN, Inf, equal bounds)
- Implement saturation detection
is_saturated(&self) -> Option<SaturationInfo>methodSaturationInfostruct with variable_id, bound_type (Lower/Upper), bound_value
- Add
ControlSaturationto error/status types- Add
ControlSaturationvariant toConstraintError(or new enum if needed) - Include saturated variable info in the error
- Add
- Integrate bounded variables with System
add_bounded_variable(&mut self, var: BoundedVariable) -> Result<(), BoundedVariableError>bounded_variables: HashMap<BoundedVariableId, BoundedVariable>storage- Validation: component_id must exist in registry (like constraints)
- Write unit tests
- Test step clipping at bounds
- Test saturation detection
- Test invalid bounds (min >= max)
- Test integration with existing constraint types
- Update module exports
- Export from
mod.rs - Update
lib.rsif needed
- Export from
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,ComponentOutputtypesSystem.constraints: HashMap<ConstraintId, Constraint>storageSystem.component_names: HashSet<String>registry for validation- Constraint residual:
measured_value - target_value
Patterns to follow from Story 5.1:
- Type-safe newtype identifiers (
BoundedVariableIdlikeConstraintId) thiserrorfor 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:
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:
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>whereConvergedStateincludes optionalsaturation_info - Or use a separate
ControlStatusenum if that fits existing patterns better
Existing Code Patterns
From constraint.rs (Story 5.1):
// 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):
// 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
f64for values in public API - consider newtype if consistency with Constraint pattern is desired - DON'T panic on invalid bounds - return
Resultwith clear error - DON'T use
unwrap()orexpect()- follow zero-panic policy - DON'T use
println!- usetracingfor 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.mdStory 5.2] Bounded Control Variables acceptance criteria - [Source:
epics.mdFR23] "Bounded Constraints (0.0 ≤ Valve ≤ 1.0)" - [Source:
architecture.md] Inverse Control pattern atcrates/solver/src/inverse/ - [Source:
architecture.md] Error handling viaThermoError(or appropriate error type) - [Source: Story 5.1 implementation]
constraint.rsfor 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.rsmodule with complete bounded variable types - ✅
BoundedVariableIdnewtype with From traits for ergonomic construction (matchesConstraintIdpattern) - ✅
BoundedVariablestruct with value, min, max bounds and optional component_id - ✅
BoundedVariableErrorenum with thiserror for comprehensive error handling - ✅
SaturationTypeenum (LowerBound, UpperBound) with Display impl - ✅
SaturationInfostruct with variable_id, saturation_type, bound_value, constraint_target - ✅
clip_step()standalone function for solver loop use - ✅
is_saturated()method returnsOption<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)