STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
robustness.py
Go to the documentation of this file.
1"""Robustness bounds and tube propagation for robust DDP controller.
2
3Maintains residual bounds w_bar for each state component and propagates
4uncertainty tubes for robust control.
5"""
6
7from __future__ import annotations
8
9from typing import Tuple, Optional
10import numpy as np
11
12from .data_models import ControllerConfig, ControllerState
13from .dynamics import step, DynamicsParams, N_STATE
14from .engine_wrapper import EngineWrapper
15
16
18 state: ControllerState,
19 x_prev: np.ndarray,
20 x_meas: np.ndarray,
21 u_prev: np.ndarray,
22 cfg: ControllerConfig,
23 engine_wrapper: Optional[EngineWrapper] = None,
24 mdot_F: Optional[float] = None,
25 mdot_O: Optional[float] = None,
26 dt: Optional[float] = None,
27) -> None:
28 """
29 Update residual bounds w_bar and disturbance bias beta.
30
31 Computes residual between measured and predicted state, then updates
32 bounds using exponential moving average with inflation.
33
34 Parameters:
35 -----------
36 state : ControllerState
37 Controller state (mutated in place)
38 x_prev : np.ndarray, shape (N_STATE,)
39 Previous state x[k-1]
40 x_meas : np.ndarray, shape (N_STATE,)
41 Measured state x_meas[k]
42 u_prev : np.ndarray, shape (N_CONTROL,)
43 Previous control u[k-1]
44 cfg : ControllerConfig
45 Controller configuration (contains rho, eta)
46 engine_wrapper : EngineWrapper, optional
47 Engine wrapper for computing mass flows (if not provided, uses mdot_F/mdot_O)
48 mdot_F : float, optional
49 Fuel mass flow [kg/s] (required if engine_wrapper not provided)
50 mdot_O : float, optional
51 Oxidizer mass flow [kg/s] (required if engine_wrapper not provided)
52 dt : float, optional
53 Time step [s] (defaults to cfg.dt)
54
55 Notes:
56 ------
57 Mutates state.w_bar and state.beta in place.
58
59 Algorithm:
60 1. Compute predicted state: x_pred = f(x_prev, u_prev)
61 2. Compute residual: residual = x_meas - x_pred
62 3. Update bounds: w_bar = rho * w_bar + (1 - rho) * abs(residual)
63 4. Inflate: w_bar *= eta
64 5. Update bias (optional): beta = rho_beta * beta + (1 - rho_beta) * residual
65 """
66 if dt is None:
67 dt = cfg.dt
68
69 # Get mass flows
70 if engine_wrapper is not None:
71 # Estimate from current tank/ullage pressures
72 # Engine performance depends on tank pressures (P_u), not feed pressures (P_d)
73 from .dynamics import IDX_P_U_F, IDX_P_U_O
74 eng_est = engine_wrapper.estimate_from_pressures(
75 x_meas[IDX_P_U_F], # P_u_F (tank/ullage pressure)
76 x_meas[IDX_P_U_O], # P_u_O (tank/ullage pressure)
77 )
78 mdot_F = eng_est.mdot_F
79 mdot_O = eng_est.mdot_O
80
81 if mdot_F is None or mdot_O is None:
82 raise ValueError("Must provide either engine_wrapper or both mdot_F and mdot_O")
83
84 # Create dynamics parameters
85 from .dynamics import DynamicsParams
86 params = DynamicsParams.from_config(cfg)
87
88 # Compute predicted state: x_pred = f(x_prev, u_prev)
89 x_pred = step(x_prev, u_prev, dt, params, mdot_F, mdot_O)
90
91 # Compute residual: residual = x_meas - x_pred
92 residual = x_meas - x_pred
93
94 # Initialize w_bar as array if not already (for backward compatibility)
95 if not hasattr(state, 'w_bar_array') or state.w_bar_array is None:
96 # Initialize from dict or zeros
97 if isinstance(state.w_bar, dict):
98 # Convert from dict format (legacy)
99 state.w_bar_array = np.zeros(N_STATE, dtype=np.float64)
100 else:
101 state.w_bar_array = np.zeros(N_STATE, dtype=np.float64)
102
103 # Update bounds: w_bar = rho * w_bar + (1 - rho) * abs(residual)
104 state.w_bar_array = cfg.rho * state.w_bar_array + (1 - cfg.rho) * np.abs(residual)
105
106 # Inflate: w_bar *= (1 + eta)
107 # eta is robustness margin, so multiply by (1 + eta) to add margin
108 state.w_bar_array *= (1.0 + cfg.eta)
109
110 # Ensure non-negative
111 state.w_bar_array = np.maximum(state.w_bar_array, 0.0)
112
113 # Update disturbance bias beta (optional, as EWMA of residuals)
114 # Use same rho for bias update (could use different parameter)
115 rho_beta = cfg.rho # Could be separate parameter
116 state.beta = rho_beta * state.beta + (1 - rho_beta) * np.mean(residual)
117
118 # Also update dict format for backward compatibility
119 # Store per-state-component bounds in dict with state names
120 state_names = [
121 "P_copv", "P_reg", "P_u_F", "P_u_O",
122 "P_d_F", "P_d_O", "V_u_F", "V_u_O"
123 ]
124 state.w_bar = {name: float(state.w_bar_array[i]) for i, name in enumerate(state_names)}
125
126
128 x_lo: np.ndarray,
129 x_hi: np.ndarray,
130 u: np.ndarray,
131 w_bar: np.ndarray,
132 dt: float,
133 params: DynamicsParams,
134 mdot_F: float,
135 mdot_O: float,
136) -> Tuple[np.ndarray, np.ndarray]:
137 """
138 Propagate uncertainty tube through dynamics.
139
140 Computes lower and upper bounds of state uncertainty after one step:
141 - x_lo_next = f(x_lo, u) - w_bar
142 - x_hi_next = f(x_hi, u) + w_bar
143
144 Parameters:
145 -----------
146 x_lo : np.ndarray, shape (N_STATE,)
147 Lower bound of state uncertainty
148 x_hi : np.ndarray, shape (N_STATE,)
149 Upper bound of state uncertainty
150 u : np.ndarray, shape (N_CONTROL,)
151 Control input
152 w_bar : np.ndarray, shape (N_STATE,)
153 Residual bounds (uncertainty per state component)
154 dt : float
155 Time step [s]
156 params : DynamicsParams
157 Dynamics parameters
158 mdot_F : float
159 Fuel mass flow [kg/s]
160 mdot_O : float
161 Oxidizer mass flow [kg/s]
162
163 Returns:
164 --------
165 x_lo_next : np.ndarray, shape (N_STATE,)
166 Lower bound of next state uncertainty
167 x_hi_next : np.ndarray, shape (N_STATE,)
168 Upper bound of next state uncertainty
169
170 Notes:
171 ------
172 This implements a simple interval propagation. For more sophisticated
173 methods (e.g., zonotopes, polytopes), see advanced robust control literature.
174 """
175 # Validate inputs
176 if x_lo.shape != (N_STATE,) or x_hi.shape != (N_STATE,):
177 raise ValueError(f"State bounds must have shape ({N_STATE},), got {x_lo.shape} and {x_hi.shape}")
178 if w_bar.shape != (N_STATE,):
179 raise ValueError(f"w_bar must have shape ({N_STATE},), got {w_bar.shape}")
180
181 # Validate x_lo <= x_hi
182 if not np.all(x_lo <= x_hi):
183 raise ValueError("x_lo must be <= x_hi element-wise")
184
185 # Propagate lower bound: x_lo_next = f(x_lo, u) - w_bar
186 x_lo_next = step(x_lo, u, dt, params, mdot_F, mdot_O) - w_bar
187
188 # Propagate upper bound: x_hi_next = f(x_hi, u) + w_bar
189 x_hi_next = step(x_hi, u, dt, params, mdot_F, mdot_O) + w_bar
190
191 # Ensure x_lo_next <= x_hi_next (may be violated due to nonlinearity)
192 # Clamp to maintain valid interval
193 x_lo_next = np.minimum(x_lo_next, x_hi_next)
194 x_hi_next = np.maximum(x_lo_next, x_hi_next)
195
196 # Ensure non-negative for pressures and volumes
197 x_lo_next = np.maximum(x_lo_next, 0.0)
198
199 return x_lo_next, x_hi_next
200
201
202def get_w_bar_array(state: ControllerState) -> np.ndarray:
203 """
204 Get w_bar as numpy array (per-state-component bounds).
205
206 Parameters:
207 -----------
208 state : ControllerState
209 Controller state
210
211 Returns:
212 --------
213 w_bar : np.ndarray, shape (N_STATE,)
214 Residual bounds for each state component
215 """
216 if hasattr(state, 'w_bar_array') and state.w_bar_array is not None:
217 return state.w_bar_array.copy()
218
219 # Convert from dict format if needed
220 if isinstance(state.w_bar, dict):
221 state_names = [
222 "P_copv", "P_reg", "P_u_F", "P_u_O",
223 "P_d_F", "P_d_O", "V_u_F", "V_u_O"
224 ]
225 w_bar_array = np.zeros(N_STATE, dtype=np.float64)
226 for i, name in enumerate(state_names):
227 w_bar_array[i] = state.w_bar.get(name, 0.0)
228 return w_bar_array
229
230 # Default: zeros
231 return np.zeros(N_STATE, dtype=np.float64)
232
233
234def set_w_bar_array(state: ControllerState, w_bar: np.ndarray) -> None:
235 """
236 Set w_bar from numpy array.
237
238 Parameters:
239 -----------
240 state : ControllerState
241 Controller state (mutated in place)
242 w_bar : np.ndarray, shape (N_STATE,)
243 Residual bounds for each state component
244 """
245 if w_bar.shape != (N_STATE,):
246 raise ValueError(f"w_bar must have shape ({N_STATE},), got {w_bar.shape}")
247
248 state.w_bar_array = w_bar.copy()
249
250 # Also update dict format for backward compatibility
251 state_names = [
252 "P_copv", "P_reg", "P_u_F", "P_u_O",
253 "P_d_F", "P_d_O", "V_u_F", "V_u_O"
254 ]
255 state.w_bar = {name: float(w_bar[i]) for i, name in enumerate(state_names)}
256
Tuple[np.ndarray, np.ndarray] tube_propagate(np.ndarray x_lo, np.ndarray x_hi, np.ndarray u, np.ndarray w_bar, float dt, DynamicsParams params, float mdot_F, float mdot_O)
np.ndarray get_w_bar_array(ControllerState state)
None update_bounds(ControllerState state, np.ndarray x_prev, np.ndarray x_meas, np.ndarray u_prev, ControllerConfig cfg, Optional[EngineWrapper] engine_wrapper=None, Optional[float] mdot_F=None, Optional[float] mdot_O=None, Optional[float] dt=None)
Definition robustness.py:27
None set_w_bar_array(ControllerState state, np.ndarray w_bar)