STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_robust_ddp_robustness.py
Go to the documentation of this file.
1"""Unit tests for robust DDP controller robustness module."""
2
3import unittest
4import numpy as np
5
7 update_bounds,
8 tube_propagate,
9 get_w_bar_array,
10 set_w_bar_array,
11)
12from engine.control.robust_ddp.data_models import ControllerConfig, ControllerState
14 DynamicsParams,
15 N_STATE,
16 IDX_P_COPV,
17 IDX_P_REG,
18 IDX_P_U_F,
19 IDX_P_U_O,
20 IDX_P_D_F,
21 IDX_P_D_O,
22)
23from engine.control.robust_ddp.engine_wrapper import EngineEstimate
24
25
26class TestRobustness(unittest.TestCase):
27 """Test robustness bounds and tube propagation."""
28
29 def setUp(self):
30 """Set up test fixtures."""
32 rho=0.9, # High retention (slow adaptation)
33 eta=0.1, # 10% inflation margin
34 dt=0.01, # 10 ms time step
35 )
36 self.params = DynamicsParams.from_config(self.config)
37
38 # Test state
39 self.x_prev = np.array([
40 30e6, # P_copv
41 24e6, # P_reg
42 3e6, # P_u_F
43 3.5e6, # P_u_O
44 2.5e6, # P_d_F
45 3e6, # P_d_O
46 0.01, # V_u_F
47 0.01, # V_u_O
48 ], dtype=np.float64)
49
50 self.u_prev = np.array([0.5, 0.5], dtype=np.float64)
51 self.mdot_F = 0.5
52 self.mdot_O = 1.0
53
55 """Test basic bounds update."""
56 state = ControllerState()
57
58 # Measured state (slightly different from predicted)
59 x_meas = self.x_prev.copy()
60 x_meas[IDX_P_COPV] += 1e5 # 0.1 MPa difference
61
62 # Update bounds
63 update_bounds(
64 state, self.x_prev, x_meas, self.u_prev, self.config,
65 mdot_F=self.mdot_F, mdot_O=self.mdot_O, dt=self.config.dt
66 )
67
68 # Check that w_bar_array was created
69 self.assertIsNotNone(state.w_bar_array)
70 self.assertEqual(len(state.w_bar_array), N_STATE)
71
72 # Check that bounds are non-negative
73 self.assertTrue(np.all(state.w_bar_array >= 0))
74
75 # Check that COPV bound is positive (we had a residual)
76 self.assertGreater(state.w_bar_array[IDX_P_COPV], 0)
77
79 """Test that bounds increase when residual spikes."""
80 state = ControllerState()
81
82 # Initial update with small residual
83 x_meas_small = self.x_prev.copy()
84 x_meas_small[IDX_P_COPV] += 1e4 # 0.01 MPa difference
85
86 update_bounds(
87 state, self.x_prev, x_meas_small, self.u_prev, self.config,
88 mdot_F=self.mdot_F, mdot_O=self.mdot_O, dt=self.config.dt
89 )
90
91 w_bar_initial = state.w_bar_array[IDX_P_COPV].copy()
92
93 # Update with large residual spike
94 x_meas_large = self.x_prev.copy()
95 x_meas_large[IDX_P_COPV] += 1e6 # 1 MPa difference (large spike)
96
97 update_bounds(
98 state, self.x_prev, x_meas_large, self.u_prev, self.config,
99 mdot_F=self.mdot_F, mdot_O=self.mdot_O, dt=self.config.dt
100 )
101
102 w_bar_after_spike = state.w_bar_array[IDX_P_COPV]
103
104 # Bounds should increase after spike
105 self.assertGreater(w_bar_after_spike, w_bar_initial,
106 f"Bounds should increase after spike: {w_bar_after_spike} > {w_bar_initial}")
107
109 """Test exponential moving average behavior."""
110 state = ControllerState()
111
112 # Multiple updates with constant residual
113 residual_magnitude = 1e5 # 0.1 MPa
114 x_meas = self.x_prev.copy()
115 x_meas[IDX_P_COPV] += residual_magnitude
116
117 # First update
118 update_bounds(
119 state, self.x_prev, x_meas, self.u_prev, self.config,
120 mdot_F=self.mdot_F, mdot_O=self.mdot_O, dt=self.config.dt
121 )
122 w_bar_1 = state.w_bar_array[IDX_P_COPV]
123
124 # Second update (same residual)
125 update_bounds(
126 state, self.x_prev, x_meas, self.u_prev, self.config,
127 mdot_F=self.mdot_F, mdot_O=self.mdot_O, dt=self.config.dt
128 )
129 w_bar_2 = state.w_bar_array[IDX_P_COPV]
130
131 # With rho=0.9, second update should be:
132 # w_bar_2 = 0.9 * w_bar_1 + 0.1 * residual_magnitude
133 # Then inflated: w_bar_2 *= 1.1
134 expected_w_bar_2 = (0.9 * w_bar_1 + 0.1 * residual_magnitude) * 1.1
135
136 self.assertAlmostEqual(w_bar_2, expected_w_bar_2, delta=1e3,
137 msg="EMA update should follow formula")
138
140 """Test that bounds are inflated by eta."""
141 state = ControllerState()
142
143 # Update with known residual
144 residual_magnitude = 1e5 # 0.1 MPa
145 x_meas = self.x_prev.copy()
146 x_meas[IDX_P_COPV] += residual_magnitude
147
148 update_bounds(
149 state, self.x_prev, x_meas, self.u_prev, self.config,
150 mdot_F=self.mdot_F, mdot_O=self.mdot_O, dt=self.config.dt
151 )
152
153 # After first update with rho=0.9, eta=0.1:
154 # w_bar = (1 - rho) * abs(residual) * (1 + eta)
155 # w_bar = 0.1 * 1e5 * 1.1 = 1.1e4
156 expected_w_bar = (1 - self.config.rho) * residual_magnitude * (1 + self.config.eta)
157
158 self.assertAlmostEqual(
159 state.w_bar_array[IDX_P_COPV], expected_w_bar,
160 delta=1e2,
161 msg=f"Bounds should be inflated: {state.w_bar_array[IDX_P_COPV]} ≈ {expected_w_bar}"
162 )
163
165 """Test disturbance bias beta update."""
166 state = ControllerState()
167 state.beta = 0.0
168
169 # Update with residual
170 x_meas = self.x_prev.copy()
171 x_meas[IDX_P_COPV] += 1e5
172
173 update_bounds(
174 state, self.x_prev, x_meas, self.u_prev, self.config,
175 mdot_F=self.mdot_F, mdot_O=self.mdot_O, dt=self.config.dt
176 )
177
178 # Beta should be updated (EWMA of mean residual)
179 self.assertNotEqual(state.beta, 0.0)
180 self.assertTrue(np.isfinite(state.beta))
181
183 """Test basic tube propagation."""
184 # Create uncertainty tube
185 x_nom = self.x_prev.copy()
186 w_bar = np.array([1e5, 1e5, 1e5, 1e5, 1e5, 1e5, 1e-4, 1e-4], dtype=np.float64)
187
188 x_lo = x_nom - w_bar
189 x_hi = x_nom + w_bar
190
191 # Propagate
192 x_lo_next, x_hi_next = tube_propagate(
193 x_lo, x_hi, self.u_prev, w_bar, self.config.dt,
194 self.params, self.mdot_F, self.mdot_O
195 )
196
197 # Check shapes
198 self.assertEqual(x_lo_next.shape, (N_STATE,))
199 self.assertEqual(x_hi_next.shape, (N_STATE,))
200
201 # Check that lo <= hi
202 self.assertTrue(np.all(x_lo_next <= x_hi_next),
203 "Lower bound must be <= upper bound")
204
205 # Check non-negativity
206 self.assertTrue(np.all(x_lo_next >= 0),
207 "Lower bound must be non-negative")
208
210 """Test that tube propagation widens uncertainty."""
211 # Create tight uncertainty tube
212 x_nom = self.x_prev.copy()
213 w_bar_small = np.full(N_STATE, 1e4, dtype=np.float64) # Small bounds
214
215 x_lo = x_nom - w_bar_small
216 x_hi = x_nom + w_bar_small
217
218 # Propagate
219 x_lo_next, x_hi_next = tube_propagate(
220 x_lo, x_hi, self.u_prev, w_bar_small, self.config.dt,
221 self.params, self.mdot_F, self.mdot_O
222 )
223
224 # Compute tube widths
225 width_before = np.sum(x_hi - x_lo)
226 width_after = np.sum(x_hi_next - x_lo_next)
227
228 # Tube should widen (or at least not shrink significantly)
229 # Due to w_bar addition, width should increase
230 self.assertGreaterEqual(width_after, width_before * 0.9, # Allow some numerical error
231 f"Tube should widen: {width_after} >= {width_before * 0.9}")
232
234 """Test tube propagation with large uncertainty bounds."""
235 x_nom = self.x_prev.copy()
236 w_bar_large = np.full(N_STATE, 1e6, dtype=np.float64) # Large bounds (1 MPa)
237
238 x_lo = x_nom - w_bar_large
239 x_hi = x_nom + w_bar_large
240
241 # Propagate
242 x_lo_next, x_hi_next = tube_propagate(
243 x_lo, x_hi, self.u_prev, w_bar_large, self.config.dt,
244 self.params, self.mdot_F, self.mdot_O
245 )
246
247 # Check that tube remains valid
248 self.assertTrue(np.all(x_lo_next <= x_hi_next))
249 self.assertTrue(np.all(x_lo_next >= 0))
250 self.assertTrue(np.all(np.isfinite(x_lo_next)))
251 self.assertTrue(np.all(np.isfinite(x_hi_next)))
252
254 """Test tube propagation input validation."""
255 x_lo = self.x_prev - 1e5
256 x_hi = self.x_prev + 1e5
257 w_bar = np.full(N_STATE, 1e5, dtype=np.float64)
258
259 # Valid call
260 x_lo_next, x_hi_next = tube_propagate(
261 x_lo, x_hi, self.u_prev, w_bar, self.config.dt,
262 self.params, self.mdot_F, self.mdot_O
263 )
264 self.assertIsNotNone(x_lo_next)
265
266 # Invalid: wrong shape
267 with self.assertRaises(ValueError):
268 tube_propagate(
269 x_lo[:4], x_hi, self.u_prev, w_bar, self.config.dt,
270 self.params, self.mdot_F, self.mdot_O
271 )
272
273 # Invalid: x_lo > x_hi
274 with self.assertRaises(ValueError):
275 tube_propagate(
276 x_hi, x_lo, self.u_prev, w_bar, self.config.dt,
277 self.params, self.mdot_F, self.mdot_O
278 )
279
281 """Test getting w_bar as array."""
282 state = ControllerState()
283
284 # Test with array
285 w_bar_test = np.array([1e5, 2e5, 3e5, 4e5, 5e5, 6e5, 1e-4, 2e-4], dtype=np.float64)
286 set_w_bar_array(state, w_bar_test)
287
288 w_bar_retrieved = get_w_bar_array(state)
289 np.testing.assert_array_equal(w_bar_retrieved, w_bar_test)
290
291 # Test with dict (legacy)
292 state2 = ControllerState()
293 state2.w_bar = {
294 "P_copv": 1e5,
295 "P_reg": 2e5,
296 "P_u_F": 3e5,
297 "P_u_O": 4e5,
298 "P_d_F": 5e5,
299 "P_d_O": 6e5,
300 "V_u_F": 1e-4,
301 "V_u_O": 2e-4,
302 }
303
304 w_bar_from_dict = get_w_bar_array(state2)
305 np.testing.assert_array_almost_equal(w_bar_from_dict, w_bar_test, decimal=2)
306
308 """Test setting w_bar from array."""
309 state = ControllerState()
310
311 w_bar_test = np.array([1e5, 2e5, 3e5, 4e5, 5e5, 6e5, 1e-4, 2e-4], dtype=np.float64)
312 set_w_bar_array(state, w_bar_test)
313
314 # Check array was set
315 self.assertIsNotNone(state.w_bar_array)
316 np.testing.assert_array_equal(state.w_bar_array, w_bar_test)
317
318 # Check dict was updated
319 self.assertEqual(state.w_bar["P_copv"], 1e5)
320 self.assertEqual(state.w_bar["P_reg"], 2e5)
321
322 # Invalid shape
323 with self.assertRaises(ValueError):
324 set_w_bar_array(state, np.array([1, 2, 3]))
325
327 """Test update_bounds with engine wrapper."""
328 from unittest.mock import Mock
329
330 # Create mock engine wrapper
331 mock_wrapper = Mock()
332 mock_wrapper.estimate_from_pressures.return_value = EngineEstimate(
333 P_ch=2e6,
334 F=1000.0,
335 mdot_F=0.5,
336 mdot_O=1.0,
337 MR=2.0,
338 injector_dp_F=0.5e6,
339 injector_dp_O=1.0e6,
340 )
341
342 state = ControllerState()
343 x_meas = self.x_prev.copy()
344 x_meas[IDX_P_COPV] += 1e5
345
346 # Update with engine wrapper
347 update_bounds(
348 state, self.x_prev, x_meas, self.u_prev, self.config,
349 engine_wrapper=mock_wrapper, dt=self.config.dt
350 )
351
352 # Check that wrapper was called
353 mock_wrapper.estimate_from_pressures.assert_called_once()
354
355 # Check bounds were updated
356 self.assertIsNotNone(state.w_bar_array)
357 self.assertGreater(state.w_bar_array[IDX_P_COPV], 0)
358
360 """Test that update_bounds requires mass flow information."""
361 state = ControllerState()
362 x_meas = self.x_prev.copy()
363
364 # Should raise error if neither engine_wrapper nor mdot provided
365 with self.assertRaises(ValueError):
366 update_bounds(
367 state, self.x_prev, x_meas, self.u_prev, self.config,
368 dt=self.config.dt
369 )
370
372 """Test that bounds converge with repeated updates."""
373 state = ControllerState()
374
375 # Constant residual over multiple updates
376 residual_magnitude = 1e5
377 x_meas = self.x_prev.copy()
378 x_meas[IDX_P_COPV] += residual_magnitude
379
380 # Multiple updates
381 w_bar_values = []
382 for _ in range(10):
383 update_bounds(
384 state, self.x_prev, x_meas, self.u_prev, self.config,
385 mdot_F=self.mdot_F, mdot_O=self.mdot_O, dt=self.config.dt
386 )
387 w_bar_values.append(state.w_bar_array[IDX_P_COPV])
388
389 # Bounds should converge (not grow unbounded)
390 # With constant residual, bounds should approach: residual * (1 + eta) / (1 - rho)
391 # But with EMA, it's: (1 - rho) * residual * (1 + eta) / (1 - rho * (1 + eta))
392 # For simplicity, just check it's finite and reasonable
393 self.assertTrue(np.isfinite(w_bar_values[-1]))
394 self.assertLess(w_bar_values[-1], residual_magnitude * 10) # Should be reasonable
395
396
397if __name__ == '__main__':
398 unittest.main()
399
void setUp(void)
Definition test_main.cpp:17