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

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

  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

  • Create BoundedVariable type in crates/solver/src/inverse/bounded.rs
    • BoundedVariable struct with value, min, max bounds
    • BoundedVariableId newtype for type-safe identifiers
    • BoundedVariableError enum 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> method
    • SaturationInfo struct with variable_id, bound_type (Lower/Upper), bound_value
  • Add ControlSaturation to error/status types
    • Add ControlSaturation variant to ConstraintError (or new enum if needed)
    • Include saturated variable info in the error
  • 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.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:

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

// 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 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
  • 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)