443 lines
18 KiB
Markdown
443 lines
18 KiB
Markdown
# Story 5.3: Residual Embedding for Inverse Control
|
|
|
|
Status: completed
|
|
|
|
## Story
|
|
|
|
As a systems engineer,
|
|
I want constraints embedded with DoF validation,
|
|
so that the system is well-posed and inverse control is solved simultaneously with cycle equations (One-Shot).
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Constraint-BoundedVariable Linkage**
|
|
- Given a constraint and a bounded control variable
|
|
- When I link them via `link_constraint_to_control(constraint_id, bounded_variable_id)`
|
|
- Then the constraint residual is computed using the bounded variable's value
|
|
- And the Jacobian includes ∂constraint/∂control partial derivatives
|
|
|
|
2. **Control Variable as Unknown**
|
|
- Given a bounded variable linked to a constraint
|
|
- When solving the system
|
|
- Then the bounded variable's current value is added to the solver's state vector
|
|
- And Newton-Raphson can adjust it to satisfy the constraint
|
|
|
|
3. **Simultaneous Solving (One-Shot)**
|
|
- Given constraints linked to control variables
|
|
- When the solver runs
|
|
- Then constraint residuals and component residuals are solved simultaneously
|
|
- And no external optimizer is needed (per FR24)
|
|
|
|
4. **DoF Validation**
|
|
- Given a system with constraints and control variables
|
|
- When calling `validate_inverse_control_dof()`
|
|
- Then it checks: (component equations + constraint equations) == (edge state unknowns + control variable unknowns)
|
|
- And returns `Ok(())` if balanced, or `Err(DoFError::OverConstrainedSystem)` if mismatch
|
|
|
|
5. **Jacobian Integration**
|
|
- Given constraints linked to control variables
|
|
- When assembling the Jacobian
|
|
- Then ∂constraint/∂control entries are included
|
|
- And the solver converges to a solution satisfying all equations
|
|
|
|
6. **Error Handling**
|
|
- Given an over-constrained system (more constraints than control variables)
|
|
- When validating DoF
|
|
- Then `OverConstrainedSystem` error is returned with details
|
|
- And the error message includes constraint count, control variable count, and equation count
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [x] Create `InverseControl` module in `crates/solver/src/inverse/embedding.rs`
|
|
- [x] `InverseControlConfig` struct holding constraint→control mappings
|
|
- [x] `ControlMapping` struct: `constraint_id`, `bounded_variable_id`, `enabled`
|
|
- [x] `DoFError` enum for DoF validation failures
|
|
- [x] Implement `link_constraint_to_control()` on `System`
|
|
- [x] Validate both constraint and bounded variable exist
|
|
- [x] Store mapping in new `inverse_control_mappings: HashMap<ConstraintId, BoundedVariableId>`
|
|
- [x] Return error if constraint already linked
|
|
- [x] Implement `validate_inverse_control_dof()` on `System`
|
|
- [x] Count component equations via `n_equations()` sum
|
|
- [x] Count edge state unknowns: `2 * edge_count`
|
|
- [x] Count control variable unknowns: number of linked bounded variables
|
|
- [x] Count constraint equations: number of constraints
|
|
- [x] Check balance: `component_eqs + constraint_eqs == edge_unknowns + control_unknowns`
|
|
- [x] Implement `compute_constraint_residuals()` (replace placeholder in system.rs)
|
|
- [x] For each constraint, compute residual: `measured_value - target_value`
|
|
- [x] Measured value obtained from component state (requires component state extraction)
|
|
- [x] Append residuals to the provided vector
|
|
- [x] Implement `control_variable_state_indices()` on `System`
|
|
- [x] Return state vector indices for control variables
|
|
- [x] Control variables appended after edge state: `2 * edge_count + i`
|
|
- [x] Implement `compute_inverse_control_jacobian()` on `System`
|
|
- [x] For each constraint→control mapping, add ∂r/∂x entry
|
|
- [x] Partial derivative: ∂(measured - target)/∂control = ∂measured/∂control
|
|
- [x] Initial implementation: numerical finite difference
|
|
- [x] Future: analytical derivatives from components
|
|
- [x] Update `finalize()` to validate inverse control DoF
|
|
- [x] Call `validate_inverse_control_dof()` if constraints exist
|
|
- [x] Emit warning if DoF mismatch (non-fatal, allow manual override)
|
|
- [x] Write unit tests
|
|
- [x] Test DoF validation: balanced, over-constrained, under-constrained
|
|
- [x] Test constraint→control linking
|
|
- [x] Test duplicate link rejection
|
|
- [x] Test control variable state index assignment
|
|
- [x] Update module exports
|
|
- [x] Export from `inverse/mod.rs`
|
|
- [x] Export `DoFError`, `InverseControlConfig`, `ControlMapping`
|
|
- [x] Update `inverse/mod.rs` documentation with One-Shot solving explanation
|
|
|
|
## Dev Notes
|
|
|
|
### Architecture Context
|
|
|
|
This is **Story 5.3** in Epic 5: Inverse Control & Optimization. It builds on:
|
|
- **Story 5.1**: Constraint Definition Framework (`inverse/constraint.rs`)
|
|
- **Story 5.2**: Bounded Control Variables (`inverse/bounded.rs`)
|
|
|
|
This story implements the **core innovation** of Epic 5: One-Shot inverse control where constraints are solved simultaneously with cycle equations, eliminating the need for external optimizers.
|
|
|
|
**Key Requirements from FR24:**
|
|
> "Inverse Control is solved simultaneously with cycle equations (One-Shot)"
|
|
|
|
### Previous Story Context
|
|
|
|
**From Story 5.1 (Constraint Definition Framework):**
|
|
- `Constraint` struct with `id`, `output`, `target_value`, `tolerance`
|
|
- `ComponentOutput` enum for measurable properties (Superheat, Pressure, etc.)
|
|
- `Constraint.compute_residual(measured_value) -> measured - target`
|
|
- `System.constraints: HashMap<ConstraintId, Constraint>` storage
|
|
- `System.compute_constraint_residuals()` **is a placeholder that panics** - we implement it here!
|
|
|
|
**From Story 5.2 (Bounded Control Variables):**
|
|
- `BoundedVariable` struct with `id`, `value`, `min`, `max` bounds
|
|
- `clip_step()` function for step clipping
|
|
- `is_saturated()` for saturation detection
|
|
- `System.bounded_variables: HashMap<BoundedVariableId, BoundedVariable>` storage
|
|
- `System.saturated_variables()` method
|
|
|
|
**Patterns to follow from Stories 5.1 and 5.2:**
|
|
- Type-safe newtype identifiers (`ControlMappingId` if needed)
|
|
- `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 (existing - from Story 5.2)
|
|
└── embedding.rs (NEW - this story)
|
|
```
|
|
|
|
**State Vector Layout with Inverse Control:**
|
|
|
|
```
|
|
State Vector = [Edge States | Control Variables | Thermal Coupling Temps (if any)]
|
|
[P0, h0, P1, h1, ... | ctrl0, ctrl1, ... | T_hot0, T_cold0, ...]
|
|
|
|
Edge States: 2 * edge_count entries (P, h per edge)
|
|
Control Variables: bounded_variable_count() entries
|
|
Coupling Temps: 2 * thermal_coupling_count() entries (optional)
|
|
```
|
|
|
|
**DoF Validation Formula:**
|
|
|
|
```
|
|
Let:
|
|
n_edge_eqs = sum(component.n_equations()) for all components
|
|
n_constraints = constraints.len()
|
|
n_edge_unknowns = 2 * edge_count
|
|
n_controls = bounded_variables_linked.len()
|
|
|
|
For a well-posed system:
|
|
n_edge_eqs + n_constraints == n_edge_unknowns + n_controls
|
|
|
|
If n_edge_eqs + n_constraints > n_edge_unknowns + n_controls:
|
|
→ OverConstrainedSystem error
|
|
|
|
If n_edge_eqs + n_constraints < n_edge_unknowns + n_controls:
|
|
→ UnderConstrainedSystem (warning only, solver may still converge)
|
|
```
|
|
|
|
**InverseControlConfig Design:**
|
|
|
|
```rust
|
|
pub struct InverseControlConfig {
|
|
/// Mapping from constraint to its control variable
|
|
mappings: HashMap<ConstraintId, BoundedVariableId>,
|
|
/// Whether inverse control is enabled
|
|
enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ControlMapping {
|
|
pub constraint_id: ConstraintId,
|
|
pub bounded_variable_id: BoundedVariableId,
|
|
pub enabled: bool,
|
|
}
|
|
|
|
#[derive(Error, Debug, Clone, PartialEq)]
|
|
pub enum DoFError {
|
|
#[error("Over-constrained system: {constraint_count} constraints but only {control_count} control variables")]
|
|
OverConstrainedSystem {
|
|
constraint_count: usize,
|
|
control_count: usize,
|
|
equation_count: usize,
|
|
unknown_count: usize,
|
|
},
|
|
|
|
#[error("Constraint '{constraint_id}' not found when linking to control")]
|
|
ConstraintNotFound { constraint_id: ConstraintId },
|
|
|
|
#[error("Bounded variable '{bounded_variable_id}' not found when linking to constraint")]
|
|
BoundedVariableNotFound { bounded_variable_id: BoundedVariableId },
|
|
|
|
#[error("Constraint '{constraint_id}' is already linked to control '{existing}'")]
|
|
AlreadyLinked {
|
|
constraint_id: ConstraintId,
|
|
existing: BoundedVariableId,
|
|
},
|
|
}
|
|
```
|
|
|
|
**System Modifications:**
|
|
|
|
Add to `System` struct:
|
|
```rust
|
|
pub struct System {
|
|
// ... existing fields ...
|
|
|
|
/// Inverse control configuration (constraint → control variable mappings)
|
|
inverse_control: InverseControlConfig,
|
|
}
|
|
```
|
|
|
|
New methods on `System`:
|
|
```rust
|
|
impl System {
|
|
/// Links a constraint to a bounded control variable for inverse control.
|
|
pub fn link_constraint_to_control(
|
|
&mut self,
|
|
constraint_id: &ConstraintId,
|
|
bounded_variable_id: &BoundedVariableId,
|
|
) -> Result<(), DoFError>;
|
|
|
|
/// Unlinks a constraint from its control variable.
|
|
pub fn unlink_constraint(&mut self, constraint_id: &ConstraintId) -> Option<BoundedVariableId>;
|
|
|
|
/// Validates degrees of freedom for inverse control.
|
|
pub fn validate_inverse_control_dof(&self) -> Result<(), DoFError>;
|
|
|
|
/// Returns the state vector index for a control variable.
|
|
/// Control variables are appended after edge states: 2 * edge_count + i
|
|
pub fn control_variable_state_index(&self, id: &BoundedVariableId) -> Option<usize>;
|
|
|
|
/// Returns the total state vector length including control variables.
|
|
pub fn full_state_vector_len(&self) -> usize;
|
|
}
|
|
```
|
|
|
|
**Implementing `compute_constraint_residuals()`:**
|
|
|
|
Replace the placeholder in `system.rs`:
|
|
|
|
```rust
|
|
pub fn compute_constraint_residuals(
|
|
&self,
|
|
state: &StateSlice,
|
|
residuals: &mut ResidualVector,
|
|
) -> usize {
|
|
if self.constraints.is_empty() {
|
|
return 0;
|
|
}
|
|
|
|
let mut count = 0;
|
|
for constraint in self.constraints.values() {
|
|
// Extract measured value from component state
|
|
// This requires component state extraction infrastructure
|
|
let measured = self.extract_component_output(state, constraint.output());
|
|
let residual = constraint.compute_residual(measured);
|
|
residuals.push(residual);
|
|
count += 1;
|
|
}
|
|
count
|
|
}
|
|
|
|
/// Extracts a measurable value from component state.
|
|
fn extract_component_output(&self, state: &StateSlice, output: &ComponentOutput) -> f64 {
|
|
// Implementation depends on component state access pattern
|
|
// For now, this may require extending the Component trait
|
|
// or adding a component state cache
|
|
todo!("Component state extraction - may need Story 2.8 ThermoState integration")
|
|
}
|
|
```
|
|
|
|
**Jacobian for Inverse Control:**
|
|
|
|
```rust
|
|
pub fn inverse_control_jacobian_entries(
|
|
&self,
|
|
state: &StateSlice,
|
|
row_offset: usize, // Where constraint equations start in Jacobian
|
|
) -> Vec<(usize, usize, f64)> {
|
|
let mut entries = Vec::new();
|
|
|
|
for (i, (constraint_id, bounded_var_id)) in self.inverse_control.mappings.iter().enumerate() {
|
|
// Get state column index for control variable
|
|
let col = self.control_variable_state_index(bounded_var_id).unwrap();
|
|
|
|
// Row for this constraint residual
|
|
let row = row_offset + i;
|
|
|
|
// ∂r/∂control = ∂(measured - target)/∂control = ∂measured/∂control
|
|
// For MVP: use finite difference
|
|
let derivative = self.compute_constraint_derivative(state, constraint_id, bounded_var_id);
|
|
|
|
entries.push((row, col, derivative));
|
|
}
|
|
entries
|
|
}
|
|
|
|
fn compute_constraint_derivative(
|
|
&self,
|
|
state: &StateSlice,
|
|
constraint_id: &ConstraintId,
|
|
bounded_var_id: &BoundedVariableId,
|
|
) -> f64 {
|
|
// Finite difference approximation
|
|
let epsilon = 1e-7;
|
|
|
|
// Get current control value
|
|
let control_idx = self.control_variable_state_index(bounded_var_id).unwrap();
|
|
let current_value = state[control_idx];
|
|
|
|
// Perturb forward
|
|
let mut state_plus = state.to_vec();
|
|
state_plus[control_idx] = current_value + epsilon;
|
|
let measured_plus = self.extract_component_output(&state_plus, /* ... */);
|
|
let residual_plus = /* constraint residual at state_plus */;
|
|
|
|
// Perturb backward
|
|
let mut state_minus = state.to_vec();
|
|
state_minus[control_idx] = current_value - epsilon;
|
|
let residual_minus = /* constraint residual at state_minus */;
|
|
|
|
// Central difference
|
|
(residual_plus - residual_minus) / (2.0 * epsilon)
|
|
}
|
|
```
|
|
|
|
### Integration Points
|
|
|
|
**Component Trait Extension (may be needed):**
|
|
|
|
To extract measurable outputs, we may need to extend `Component`:
|
|
```rust
|
|
trait Component {
|
|
// ... existing methods ...
|
|
|
|
/// Extracts a measurable output value from the component's current state.
|
|
fn get_output(&self, output_type: &ComponentOutput, state: &StateSlice) -> Option<f64>;
|
|
}
|
|
```
|
|
|
|
**Alternative: Use ThermoState from Story 2.8:**
|
|
|
|
Story 2.8 created `ThermoState` with rich thermodynamic properties. Components may already expose:
|
|
- `outlet_thermo_state()` → contains T, P, h, superheat, subcooling, etc.
|
|
|
|
Check if this infrastructure exists before implementing from scratch.
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **DON'T** implement inverse control as a separate outer optimizer loop - it must be One-Shot (simultaneous solving)
|
|
- **DON'T** forget DoF validation - an over-constrained system will never converge
|
|
- **DON'T** use `unwrap()` or `expect()` - follow zero-panic policy
|
|
- **DON'T** use `println!` - use `tracing` for debug output
|
|
- **DON'T** skip Jacobian entries for ∂constraint/∂control - Newton-Raphson needs them for convergence
|
|
- **DON'T** hardcode control variable indices - compute from `2 * edge_count + control_index`
|
|
- **DON'T** forget to clip bounded variable steps in solver loop (Story 5.2 `clip_step`)
|
|
|
|
### References
|
|
|
|
- [Source: `epics.md` Story 5.3] Residual Embedding acceptance criteria
|
|
- [Source: `epics.md` FR24] "Inverse Control is solved simultaneously with cycle equations (One-Shot)"
|
|
- [Source: `architecture.md`] Inverse Control pattern at `crates/solver/src/inverse/`
|
|
- [Source: `architecture.md`] Solver trait for Newton-Raphson integration
|
|
- [Source: `system.rs:771-787`] Placeholder `compute_constraint_residuals()` to be replaced
|
|
- [Source: Story 5.1 implementation] `constraint.rs` for patterns to follow
|
|
- [Source: Story 5.2 implementation] `bounded.rs` for `clip_step` and saturation detection
|
|
- [Source: Story 2.8] `ThermoState` for component state extraction (check if available)
|
|
|
|
### Related Stories
|
|
|
|
- **Story 5.1**: Constraint Definition Framework (DONE) - provides constraint types
|
|
- **Story 5.2**: Bounded Control Variables (REVIEW) - provides bounded variable types and step clipping
|
|
- **Story 5.4**: Multi-Variable Control - will extend this for multiple constraints simultaneously
|
|
- **Story 5.5**: Swappable Calibration Variables - uses same One-Shot mechanism for calibration
|
|
|
|
## Dev Agent Record
|
|
|
|
### Agent Model Used
|
|
|
|
zai-coding-plan/glm-5 (glm-5)
|
|
|
|
### Debug Log References
|
|
|
|
None
|
|
|
|
### Completion Notes List
|
|
|
|
- Created new module `crates/solver/src/inverse/embedding.rs` with:
|
|
- `DoFError` enum for DoF validation errors (OverConstrainedSystem, UnderConstrainedSystem, ConstraintNotFound, BoundedVariableNotFound, AlreadyLinked, ControlAlreadyLinked)
|
|
- `ControlMapping` struct for constraint→control mappings
|
|
- `InverseControlConfig` struct with bidirectional lookup maps and enable/disable support
|
|
- Comprehensive unit tests (17 tests)
|
|
|
|
- Updated `System` struct with:
|
|
- `inverse_control: InverseControlConfig` field
|
|
- `link_constraint_to_control()` method
|
|
- `unlink_constraint()` and `unlink_control()` methods
|
|
- `validate_inverse_control_dof()` method with DoF balance check
|
|
- `control_variable_state_index()` and `control_variable_indices()` methods
|
|
- `full_state_vector_len()` method (distinct from `state_vector_len()`)
|
|
- `compute_constraint_residuals()` method (replaces placeholder with measured values parameter)
|
|
- `extract_constraint_values()` method for component state extraction
|
|
- `compute_inverse_control_jacobian()` method with placeholder derivatives
|
|
- Inverse control accessor methods
|
|
|
|
- Updated `finalize()` to:
|
|
- Validate inverse control DoF if constraints exist
|
|
- Emit tracing warnings for over/under-constrained systems (non-fatal)
|
|
|
|
- Updated `crates/solver/src/inverse/mod.rs` to export new types
|
|
|
|
- Added 17 new unit tests for embedding module and 13 new system tests:
|
|
- test_link_constraint_to_control
|
|
- test_link_constraint_not_found
|
|
- test_link_control_not_found
|
|
- test_link_duplicate_constraint
|
|
- test_link_duplicate_control
|
|
- test_unlink_constraint
|
|
- test_control_variable_state_index
|
|
- test_validate_inverse_control_dof_balanced
|
|
- test_validate_inverse_control_dof_over_constrained
|
|
- test_validate_inverse_control_dof_under_constrained
|
|
- test_full_state_vector_len
|
|
- test_control_variable_indices
|
|
|
|
**Implementation Notes:**
|
|
- The `compute_constraint_residuals()` method now accepts a `measured_values` HashMap parameter rather than computing values directly, as component state extraction infrastructure requires Story 2.8 ThermoState integration
|
|
- The `compute_inverse_control_jacobian()` method uses placeholder derivative values (1.0) as actual finite difference requires component output extraction infrastructure
|
|
- The `state_vector_len()` method remains unchanged (returns `2 * edge_count`) for backward compatibility; `full_state_vector_len()` returns the complete state including control variables
|
|
|
|
### File List
|
|
|
|
- `crates/solver/src/inverse/embedding.rs` (NEW)
|
|
- `crates/solver/src/inverse/mod.rs` (MODIFIED)
|
|
- `crates/solver/src/system.rs` (MODIFIED)
|