18 KiB
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
-
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
-
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
-
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)
-
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, orErr(DoFError::OverConstrainedSystem)if mismatch
-
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
-
Error Handling
- Given an over-constrained system (more constraints than control variables)
- When validating DoF
- Then
OverConstrainedSystemerror is returned with details - And the error message includes constraint count, control variable count, and equation count
Tasks / Subtasks
- Create
InverseControlmodule incrates/solver/src/inverse/embedding.rsInverseControlConfigstruct holding constraint→control mappingsControlMappingstruct:constraint_id,bounded_variable_id,enabledDoFErrorenum for DoF validation failures
- Implement
link_constraint_to_control()onSystem- Validate both constraint and bounded variable exist
- Store mapping in new
inverse_control_mappings: HashMap<ConstraintId, BoundedVariableId> - Return error if constraint already linked
- Implement
validate_inverse_control_dof()onSystem- Count component equations via
n_equations()sum - Count edge state unknowns:
2 * edge_count - Count control variable unknowns: number of linked bounded variables
- Count constraint equations: number of constraints
- Check balance:
component_eqs + constraint_eqs == edge_unknowns + control_unknowns
- Count component equations via
- Implement
compute_constraint_residuals()(replace placeholder in system.rs)- For each constraint, compute residual:
measured_value - target_value - Measured value obtained from component state (requires component state extraction)
- Append residuals to the provided vector
- For each constraint, compute residual:
- Implement
control_variable_state_indices()onSystem- Return state vector indices for control variables
- Control variables appended after edge state:
2 * edge_count + i
- Implement
compute_inverse_control_jacobian()onSystem- For each constraint→control mapping, add ∂r/∂x entry
- Partial derivative: ∂(measured - target)/∂control = ∂measured/∂control
- Initial implementation: numerical finite difference
- Future: analytical derivatives from components
- Update
finalize()to validate inverse control DoF- Call
validate_inverse_control_dof()if constraints exist - Emit warning if DoF mismatch (non-fatal, allow manual override)
- Call
- Write unit tests
- Test DoF validation: balanced, over-constrained, under-constrained
- Test constraint→control linking
- Test duplicate link rejection
- Test control variable state index assignment
- Update module exports
- Export from
inverse/mod.rs - Export
DoFError,InverseControlConfig,ControlMapping
- Export from
- Update
inverse/mod.rsdocumentation 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):
Constraintstruct withid,output,target_value,toleranceComponentOutputenum for measurable properties (Superheat, Pressure, etc.)Constraint.compute_residual(measured_value) -> measured - targetSystem.constraints: HashMap<ConstraintId, Constraint>storageSystem.compute_constraint_residuals()is a placeholder that panics - we implement it here!
From Story 5.2 (Bounded Control Variables):
BoundedVariablestruct withid,value,min,maxboundsclip_step()function for step clippingis_saturated()for saturation detectionSystem.bounded_variables: HashMap<BoundedVariableId, BoundedVariable>storageSystem.saturated_variables()method
Patterns to follow from Stories 5.1 and 5.2:
- Type-safe newtype identifiers (
ControlMappingIdif needed) 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 (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:
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:
pub struct System {
// ... existing fields ...
/// Inverse control configuration (constraint → control variable mappings)
inverse_control: InverseControlConfig,
}
New methods on System:
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:
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:
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:
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()orexpect()- follow zero-panic policy - DON'T use
println!- usetracingfor 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.mdStory 5.3] Residual Embedding acceptance criteria - [Source:
epics.mdFR24] "Inverse Control is solved simultaneously with cycle equations (One-Shot)" - [Source:
architecture.md] Inverse Control pattern atcrates/solver/src/inverse/ - [Source:
architecture.md] Solver trait for Newton-Raphson integration - [Source:
system.rs:771-787] Placeholdercompute_constraint_residuals()to be replaced - [Source: Story 5.1 implementation]
constraint.rsfor patterns to follow - [Source: Story 5.2 implementation]
bounded.rsforclip_stepand saturation detection - [Source: Story 2.8]
ThermoStatefor 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.rswith:DoFErrorenum for DoF validation errors (OverConstrainedSystem, UnderConstrainedSystem, ConstraintNotFound, BoundedVariableNotFound, AlreadyLinked, ControlAlreadyLinked)ControlMappingstruct for constraint→control mappingsInverseControlConfigstruct with bidirectional lookup maps and enable/disable support- Comprehensive unit tests (17 tests)
-
Updated
Systemstruct with:inverse_control: InverseControlConfigfieldlink_constraint_to_control()methodunlink_constraint()andunlink_control()methodsvalidate_inverse_control_dof()method with DoF balance checkcontrol_variable_state_index()andcontrol_variable_indices()methodsfull_state_vector_len()method (distinct fromstate_vector_len())compute_constraint_residuals()method (replaces placeholder with measured values parameter)extract_constraint_values()method for component state extractioncompute_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.rsto 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 ameasured_valuesHashMap 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 (returns2 * 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)