269 lines
9.0 KiB
Markdown
269 lines
9.0 KiB
Markdown
# Story 5.6: Control Variable Step Clipping in Solver
|
|
|
|
Status: review
|
|
|
|
## Story
|
|
|
|
As a control engineer,
|
|
I want bounded control variables to be clipped at each Newton iteration,
|
|
so that the solver never proposes physically impossible values (e.g., valve > 100%, frequency < min).
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Step Clipping During Newton Iteration**
|
|
- Given a bounded variable with bounds [min, max]
|
|
- When the solver computes a Newton step Δx
|
|
- Then the new value is clipped: `x_new = clamp(x + Δx, min, max)`
|
|
- And the variable never goes outside bounds during ANY iteration
|
|
|
|
2. **State Vector Integration**
|
|
- Given control variables in the state vector at indices [2*edge_count, ...]
|
|
- When the solver updates the state vector
|
|
- Then bounded variables are clipped
|
|
- And regular edge states (P, h) are NOT clipped
|
|
|
|
3. **Saturation Detection After Convergence**
|
|
- Given a converged solution
|
|
- When one or more bounded variables are at bounds
|
|
- Then `ConvergenceStatus::ControlSaturation` is returned
|
|
- And `saturated_variables()` returns the list of saturated variables
|
|
|
|
4. **Performance**
|
|
- Given the clipping logic
|
|
- When solving a system
|
|
- Then no measurable performance degradation (< 1% overhead)
|
|
|
|
5. **Backward Compatibility**
|
|
- Given existing code that doesn't use bounded variables
|
|
- When solving
|
|
- Then behavior is unchanged (no clipping applied)
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [x] Add `get_bounded_variable_by_state_index()` method to System
|
|
- [x] Efficient lookup from state index to bounded variable
|
|
- [x] Return `Option<&BoundedVariable>`
|
|
- [x] Modify Newton-Raphson step application in `solver.rs`
|
|
- [x] Identify which state indices are bounded variables
|
|
- [x] Apply `clip_step()` only to bounded variable indices
|
|
- [x] Regular edge states pass through unchanged
|
|
- [x] Update line search to respect bounds
|
|
- [x] When testing step lengths, ensure bounded vars stay in bounds
|
|
- [x] Or skip line search for bounded variables
|
|
- [x] Add unit tests
|
|
- [x] Test clipping at max bound
|
|
- [x] Test clipping at min bound
|
|
- [x] Test multiple bounded variables
|
|
- [x] Test that edge states are NOT clipped
|
|
- [x] Test saturation detection after convergence
|
|
- [x] Update documentation
|
|
- [x] Document clipping behavior in solver.rs
|
|
- [x] Update inverse control module docs
|
|
|
|
## Dev Notes
|
|
|
|
### Problem Statement
|
|
|
|
Currently in `solver.rs` (line ~921), the Newton step is applied without clipping:
|
|
|
|
```rust
|
|
// Current code (BUG):
|
|
for (s, &d) in state.iter_mut().zip(delta.iter()) {
|
|
*s += d; // No clipping for bounded variables!
|
|
}
|
|
```
|
|
|
|
This means:
|
|
- Valve position could become 1.5 (> 100%)
|
|
- VFD frequency could become 0.1 (< 30% minimum)
|
|
- The solver may diverge or converge to impossible solutions
|
|
|
|
### Solution
|
|
|
|
Add clipping logic that:
|
|
1. Checks if state index corresponds to a bounded variable
|
|
2. If yes, applies `clamp(min, max)`
|
|
3. If no, applies the step normally
|
|
|
|
```rust
|
|
// Fixed code:
|
|
for (i, s) in state.iter_mut().enumerate() {
|
|
let delta_i = delta[i];
|
|
|
|
// Check if this index is a bounded variable
|
|
if let Some((min, max)) = system.get_bounds_for_state_index(i) {
|
|
*s = (*s + delta_i).clamp(min, max);
|
|
} else {
|
|
*s = *s + delta_i;
|
|
}
|
|
}
|
|
```
|
|
|
|
### State Vector Layout
|
|
|
|
```
|
|
State Vector Layout:
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ Index 0..2*N_edges-1 │ Edge states (P, h) - NOT clipped │
|
|
├──────────────────────────────────────────────────────────────────┤
|
|
│ Index 2*N_edges.. │ Control variables - CLIPPED to bounds │
|
|
├──────────────────────────────────────────────────────────────────┤
|
|
│ After controls │ Thermal coupling temps - NOT clipped │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Implementation Strategy
|
|
|
|
**Option A: Check on every iteration (simple)**
|
|
```rust
|
|
for (i, s) in state.iter_mut().enumerate() {
|
|
let proposed = *s + delta[i];
|
|
*s = system.clip_state_index(i, proposed);
|
|
}
|
|
```
|
|
|
|
**Option B: Pre-compute clipping mask (faster)**
|
|
```rust
|
|
// Before Newton loop:
|
|
let clipping_mask: Vec<Option<(f64, f64)>> = (0..state.len())
|
|
.map(|i| system.get_bounds_for_state_index(i))
|
|
.collect();
|
|
|
|
// In Newton loop:
|
|
for (i, s) in state.iter_mut().enumerate() {
|
|
let proposed = *s + delta[i];
|
|
*s = match &clipping_mask[i] {
|
|
Some((min, max)) => proposed.clamp(*min, *max),
|
|
None => proposed,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Recommendation:** Option B - Pre-compute the mask once after `finalize()` for minimal overhead.
|
|
|
|
### Code Locations
|
|
|
|
| File | Location | Change |
|
|
|------|----------|--------|
|
|
| `system.rs` | New method | `get_bounds_for_state_index()` |
|
|
| `solver.rs` | Line ~920-925 | Add clipping in Newton step |
|
|
| `solver.rs` | Line ~900-918 | Consider bounds in line search |
|
|
|
|
### Existing Code to Leverage
|
|
|
|
From `bounded.rs`:
|
|
```rust
|
|
pub fn clip_step(current: f64, delta: f64, min: f64, max: f64) -> f64 {
|
|
let proposed = current + delta;
|
|
proposed.clamp(min, max)
|
|
}
|
|
```
|
|
|
|
From `system.rs`:
|
|
```rust
|
|
pub fn control_variable_state_index(&self, id: &BoundedVariableId) -> Option<usize>
|
|
pub fn saturated_variables(&self) -> Vec<SaturationInfo>
|
|
```
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **DON'T** clip edge states (P, h) - they can be any physical value
|
|
- **DON'T** allocate in the Newton loop - pre-compute the mask
|
|
- **DON'T** use `unwrap()` - the mask contains `Option<(f64, f64)>`
|
|
- **DON'T** forget line search - bounds must be respected there too
|
|
|
|
### Testing Strategy
|
|
|
|
```rust
|
|
#[test]
|
|
fn test_bounded_variable_clipped_at_max() {
|
|
// Valve at 95%, Newton wants +10% → should clip to 100%
|
|
let mut system = System::new();
|
|
// ... setup ...
|
|
|
|
let valve = BoundedVariable::new(
|
|
BoundedVariableId::new("valve"),
|
|
0.95, 0.0, 1.0
|
|
).unwrap();
|
|
system.add_bounded_variable(valve);
|
|
|
|
let result = solver.solve(&system).unwrap();
|
|
|
|
let final_valve = system.get_bounded_variable(&BoundedVariableId::new("valve")).unwrap();
|
|
assert!(final_valve.value() <= 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bounded_variable_clipped_at_min() {
|
|
// VFD at 35%, Newton wants -10% → should clip to 30%
|
|
let mut system = System::new();
|
|
// ... setup ...
|
|
|
|
let vfd = BoundedVariable::new(
|
|
BoundedVariableId::new("vfd"),
|
|
0.35, 0.30, 1.0 // min = 30%
|
|
).unwrap();
|
|
system.add_bounded_variable(vfd);
|
|
|
|
let result = solver.solve(&system).unwrap();
|
|
|
|
let final_vfd = system.get_bounded_variable(&BoundedVariableId::new("vfd")).unwrap();
|
|
assert!(final_vfd.value() >= 0.30);
|
|
}
|
|
|
|
#[test]
|
|
fn test_edge_states_not_clipped() {
|
|
// Edge pressures can go negative in Newton step (temporary)
|
|
// They should NOT be clipped
|
|
// ... verify edge states can exceed any bounds ...
|
|
}
|
|
|
|
#[test]
|
|
fn test_saturation_detected_after_convergence() {
|
|
// If solution requires valve > 100%, detect saturation
|
|
// ... verify ConvergenceStatus::ControlSaturation ...
|
|
}
|
|
```
|
|
|
|
### References
|
|
|
|
- [Source: `bounded.rs:502-517`] `clip_step()` function
|
|
- [Source: `solver.rs:920-925`] Newton step application (current bug)
|
|
- [Source: `system.rs:1305-1320`] `control_variable_state_index()`
|
|
- [Source: Story 5.2] BoundedVariable implementation
|
|
- [Source: FR23] "solving respecting Bounded Constraints"
|
|
- [Source: Story 5.2 AC] "variable never outside bounds during iterations"
|
|
|
|
### Related Stories
|
|
|
|
- **Story 5.2**: Bounded Control Variables (DONE) - provides `clip_step()`, `BoundedVariable`
|
|
- **Story 5.3**: Residual Embedding (DONE) - adds controls to state vector
|
|
- **Story 5.7**: Prioritized Constraints (FUTURE) - protection vs performance
|
|
- **Story 5.8**: Coupling Functions (FUTURE) - SDT_max = f(SST)
|
|
|
|
## Dev Agent Record
|
|
|
|
### Agent Model Used
|
|
|
|
Antigravity
|
|
|
|
### Debug Log References
|
|
|
|
- Iteration loop uses `apply_newton_step` to enforce bounds cleanly, avoiding heap allocations during Newton loop.
|
|
- Line search is updated to test step lengths while still honoring the bound limits.
|
|
|
|
### Completion Notes List
|
|
|
|
- Implemented `get_bounded_variable_by_state_index` and `get_bounds_for_state_index` in `System`.
|
|
- Precomputed `clipping_mask` in Newton solver before loops to cache bound lookup without allocations.
|
|
- Overhauled Newton step updates via `apply_newton_step` to properly clamp variables defined in the `clipping_mask`.
|
|
- Ensured Armijo line search evaluates bounded states correctly without crashing.
|
|
- Verified physical edge variables (P, h) continue unhindered by bounds.
|
|
- Added comprehensive tests for behavior including saturation detection.
|
|
|
|
### File List
|
|
|
|
- `crates/solver/src/system.rs`
|
|
- `crates/solver/src/solver.rs`
|
|
- `crates/solver/tests/newton_raphson.rs`
|