STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_robust_ddp_ddp_solver.py
Go to the documentation of this file.
1"""Unit tests for DDP solver."""
2
3import unittest
4import numpy as np
5from unittest.mock import Mock, MagicMock
6
8 solve_ddp,
9 DDPSolution,
10 forward_rollout,
11 running_cost,
12 backward_pass,
13 forward_line_search,
14)
15from engine.control.robust_ddp.data_models import ControllerConfig
16from engine.control.robust_ddp.dynamics import DynamicsParams, step, N_STATE, N_CONTROL
17from engine.control.robust_ddp.engine_wrapper import EngineEstimate, EngineWrapper
18
19
20class TestDDPSolver(unittest.TestCase):
21 """Test DDP solver on toy linear system."""
22
23 def setUp(self):
24 """Set up test fixtures."""
25 # Create minimal config
27 N=10,
28 dt=0.01,
29 qF=1.0,
30 qMR=1.0,
31 qGas=0.1,
32 qSwitch=0.01,
33 max_iterations=5,
34 convergence_tol=1e-4,
35 )
36
37 # Create dynamics params
38 self.params = DynamicsParams.from_config(self.cfg)
39
40 # Create mock engine wrapper for toy system
41 self.engine_wrapper = Mock(spec=EngineWrapper)
42
43 # Mock engine estimate (simplified for toy system)
44 def mock_estimate(P_d_F, P_d_O):
45 # Simple linear model: F = a*P_d_F + b*P_d_O, MR = P_d_O / P_d_F
46 F = 1000.0 * (P_d_F + P_d_O) / 1e6 # Simplified
47 MR = P_d_O / max(P_d_F, 1e3) if P_d_F > 0 else 2.0
48 mdot_F = 0.5 * P_d_F / 1e6
49 mdot_O = 1.0 * P_d_O / 1e6
50
51 return EngineEstimate(
52 P_ch=0.5 * (P_d_F + P_d_O),
53 F=F,
54 mdot_F=mdot_F,
55 mdot_O=mdot_O,
56 MR=MR,
57 injector_dp_F=P_d_F - 0.5 * (P_d_F + P_d_O),
58 injector_dp_O=P_d_O - 0.5 * (P_d_F + P_d_O),
59 )
60
61 self.engine_wrapper.estimate_from_pressures = Mock(side_effect=mock_estimate)
62
64 """Test running cost computation."""
65 x = np.array([30e6, 24e6, 3e6, 3.5e6, 2.5e6, 3e6, 0.01, 0.01])
66 u = np.array([0.5, 0.5])
67 eng_est = EngineEstimate(
68 P_ch=2.75e6,
69 F=5000.0,
70 mdot_F=1.25,
71 mdot_O=3.0,
72 MR=2.4,
73 injector_dp_F=0.125e6,
74 injector_dp_O=0.125e6,
75 )
76 F_ref = 5000.0
77 MR_ref = 2.4
78 constraints = {}
79
80 cost = running_cost(x, u, eng_est, F_ref, MR_ref, self.cfg, constraints)
81
82 # Cost should be non-negative
83 self.assertGreaterEqual(cost, 0.0)
84 # With matching references, cost should be small (just gas consumption)
85 self.assertLess(cost, 100.0)
86
88 """Test running cost with tracking errors."""
89 x = np.array([30e6, 24e6, 3e6, 3.5e6, 2.5e6, 3e6, 0.01, 0.01])
90 u = np.array([0.5, 0.5])
91 eng_est = EngineEstimate(
92 P_ch=2.75e6,
93 F=4000.0, # 1000 N error
94 mdot_F=1.25,
95 mdot_O=3.0,
96 MR=3.0, # 0.6 error from ref
97 injector_dp_F=0.125e6,
98 injector_dp_O=0.125e6,
99 )
100 F_ref = 5000.0
101 MR_ref = 2.4
102 constraints = {}
103
104 cost = running_cost(x, u, eng_est, F_ref, MR_ref, self.cfg, constraints)
105
106 # Cost should include tracking errors
107 self.assertGreater(cost, 1000.0) # Should be significant
108
110 """Test forward rollout."""
111 x0 = np.array([30e6, 24e6, 3e6, 3.5e6, 2.5e6, 3e6, 0.01, 0.01])
112 u_seq = np.ones((self.cfg.N, N_CONTROL)) * 0.5
113 F_ref = np.ones(self.cfg.N) * 5000.0
114 MR_ref = np.ones(self.cfg.N) * 2.4
115
116 x_seq, eng_estimates, cost, violations = forward_rollout(
117 x0, u_seq, F_ref, MR_ref, self.cfg, self.params,
118 self.engine_wrapper, self.cfg.dt
119 )
120
121 # Check shapes
122 self.assertEqual(x_seq.shape, (self.cfg.N + 1, N_STATE))
123 self.assertEqual(len(eng_estimates), self.cfg.N)
124 self.assertEqual(len(violations), self.cfg.N)
125
126 # Check initial state
127 np.testing.assert_array_equal(x_seq[0], x0)
128
129 # Cost should be finite
130 self.assertTrue(np.isfinite(cost))
131 self.assertGreaterEqual(cost, 0.0)
132
134 """Test DDP on a simplified toy linear system."""
135 # Use a very simple setup with small horizon
136 cfg_simple = ControllerConfig(
137 N=5,
138 dt=0.01,
139 qF=1.0,
140 qMR=1.0,
141 qGas=0.01,
142 qSwitch=0.001,
143 max_iterations=3,
144 convergence_tol=1e-3,
145 )
146
147 x0 = np.array([30e6, 24e6, 3e6, 3.5e6, 2.5e6, 3e6, 0.01, 0.01])
148 u_seq_init = np.ones((cfg_simple.N, N_CONTROL)) * 0.5
149 F_ref = np.ones(cfg_simple.N) * 5000.0
150 MR_ref = np.ones(cfg_simple.N) * 2.4
151
152 params = DynamicsParams.from_config(cfg_simple)
153
154 # Solve DDP
155 solution = solve_ddp(
156 x0=x0,
157 u_seq_init=u_seq_init,
158 F_ref=F_ref,
159 MR_ref=MR_ref,
160 cfg=cfg_simple,
161 dynamics_params=params,
162 engine_wrapper=self.engine_wrapper,
163 w_bar=np.zeros(N_STATE),
164 use_robustification=False,
165 )
166
167 # Check solution structure
168 self.assertIsInstance(solution, DDPSolution)
169 self.assertEqual(solution.u_seq.shape, (cfg_simple.N, N_CONTROL))
170 self.assertEqual(solution.x_seq.shape, (cfg_simple.N + 1, N_STATE))
171 self.assertEqual(len(solution.eng_estimates), cfg_simple.N)
172
173 # Check control bounds
174 self.assertTrue(np.all(solution.u_seq >= 0.0))
175 self.assertTrue(np.all(solution.u_seq <= 1.0))
176
177 # Check objective is finite
178 self.assertTrue(np.isfinite(solution.objective))
179 self.assertGreaterEqual(solution.objective, 0.0)
180
181 # Check iterations
182 self.assertGreater(solution.iterations, 0)
183 self.assertLessEqual(solution.iterations, cfg_simple.max_iterations)
184
186 """Test that DDP decreases cost over iterations."""
187 cfg_simple = ControllerConfig(
188 N=5,
189 dt=0.01,
190 qF=1.0,
191 qMR=1.0,
192 qGas=0.01,
193 qSwitch=0.001,
194 max_iterations=5,
195 convergence_tol=1e-4,
196 )
197
198 x0 = np.array([30e6, 24e6, 3e6, 3.5e6, 2.5e6, 3e6, 0.01, 0.01])
199 u_seq_init = np.ones((cfg_simple.N, N_CONTROL)) * 0.3 # Start with low control
200 F_ref = np.ones(cfg_simple.N) * 5000.0
201 MR_ref = np.ones(cfg_simple.N) * 2.4
202
203 params = DynamicsParams.from_config(cfg_simple)
204
205 # Compute initial cost
206 x_seq_init, _, cost_init, _ = forward_rollout(
207 x0, u_seq_init, F_ref, MR_ref, cfg_simple, params,
208 self.engine_wrapper, cfg_simple.dt
209 )
210
211 # Solve DDP
212 solution = solve_ddp(
213 x0=x0,
214 u_seq_init=u_seq_init,
215 F_ref=F_ref,
216 MR_ref=MR_ref,
217 cfg=cfg_simple,
218 dynamics_params=params,
219 engine_wrapper=self.engine_wrapper,
220 w_bar=np.zeros(N_STATE),
221 use_robustification=False,
222 )
223
224 # DDP should improve or at least not worsen significantly
225 # (may not always decrease due to regularization, but should be reasonable)
226 self.assertLessEqual(solution.objective, cost_init * 1.5) # Allow some tolerance
227
229 """Test that DDP respects control bounds [0, 1]."""
230 cfg_simple = ControllerConfig(
231 N=5,
232 dt=0.01,
233 qF=1.0,
234 qMR=1.0,
235 qGas=0.01,
236 qSwitch=0.001,
237 max_iterations=3,
238 convergence_tol=1e-3,
239 )
240
241 x0 = np.array([30e6, 24e6, 3e6, 3.5e6, 2.5e6, 3e6, 0.01, 0.01])
242 # Start with controls that might go out of bounds
243 u_seq_init = np.array([
244 [0.0, 0.0],
245 [0.5, 0.5],
246 [1.0, 1.0],
247 [0.8, 0.8],
248 [0.2, 0.2],
249 ])
250 F_ref = np.ones(cfg_simple.N) * 5000.0
251 MR_ref = np.ones(cfg_simple.N) * 2.4
252
253 params = DynamicsParams.from_config(cfg_simple)
254
255 # Solve DDP
256 solution = solve_ddp(
257 x0=x0,
258 u_seq_init=u_seq_init,
259 F_ref=F_ref,
260 MR_ref=MR_ref,
261 cfg=cfg_simple,
262 dynamics_params=params,
263 engine_wrapper=self.engine_wrapper,
264 w_bar=np.zeros(N_STATE),
265 use_robustification=False,
266 )
267
268 # All controls must be in [0, 1]
269 self.assertTrue(np.all(solution.u_seq >= 0.0))
270 self.assertTrue(np.all(solution.u_seq <= 1.0))
271
272 # Check state bounds (pressures should be non-negative)
273 self.assertTrue(np.all(solution.x_seq >= 0.0))
274
276 """Test backward pass produces correct structure."""
277 cfg_simple = ControllerConfig(
278 N=3,
279 dt=0.01,
280 qF=1.0,
281 qMR=1.0,
282 qGas=0.01,
283 qSwitch=0.001,
284 )
285
286 x0 = np.array([30e6, 24e6, 3e6, 3.5e6, 2.5e6, 3e6, 0.01, 0.01])
287 u_seq = np.ones((cfg_simple.N, N_CONTROL)) * 0.5
288 F_ref = np.ones(cfg_simple.N) * 5000.0
289 MR_ref = np.ones(cfg_simple.N) * 2.4
290
291 params = DynamicsParams.from_config(cfg_simple)
292
293 # Forward rollout
294 x_seq, eng_estimates, _, _ = forward_rollout(
295 x0, u_seq, F_ref, MR_ref, cfg_simple, params,
296 self.engine_wrapper, cfg_simple.dt
297 )
298
299 # Backward pass
300 k_seq, K_seq, Vx, Vxx = backward_pass(
301 x_seq, u_seq, eng_estimates, F_ref, MR_ref, cfg_simple, params,
302 self.engine_wrapper, cfg_simple.dt, reg=1e-3,
303 w_bar=np.zeros(N_STATE), use_robustification=False, gamma_robust=1.0
304 )
305
306 # Check shapes
307 self.assertEqual(k_seq.shape, (cfg_simple.N, N_CONTROL))
308 self.assertEqual(K_seq.shape, (cfg_simple.N, N_CONTROL, N_STATE))
309 self.assertEqual(Vx.shape, (N_STATE,))
310 self.assertEqual(Vxx.shape, (N_STATE, N_STATE))
311
312 # Vxx should be symmetric
313 np.testing.assert_array_almost_equal(Vxx, Vxx.T, decimal=10)
314
315 # Vxx should be positive semi-definite (eigenvalues >= 0)
316 eigenvals = np.linalg.eigvals(Vxx)
317 self.assertTrue(np.all(eigenvals >= -1e-10)) # Allow small numerical errors
318
320 """Test forward line search."""
321 cfg_simple = ControllerConfig(
322 N=3,
323 dt=0.01,
324 qF=1.0,
325 qMR=1.0,
326 qGas=0.01,
327 qSwitch=0.001,
328 )
329
330 x0 = np.array([30e6, 24e6, 3e6, 3.5e6, 2.5e6, 3e6, 0.01, 0.01])
331 u_seq = np.ones((cfg_simple.N, N_CONTROL)) * 0.5
332 F_ref = np.ones(cfg_simple.N) * 5000.0
333 MR_ref = np.ones(cfg_simple.N) * 2.4
334
335 params = DynamicsParams.from_config(cfg_simple)
336
337 # Forward rollout
338 x_seq_nom, eng_estimates, _, _ = forward_rollout(
339 x0, u_seq, F_ref, MR_ref, cfg_simple, params,
340 self.engine_wrapper, cfg_simple.dt
341 )
342
343 # Backward pass
344 k_seq, K_seq, _, _ = backward_pass(
345 x_seq_nom, u_seq, eng_estimates, F_ref, MR_ref, cfg_simple, params,
346 self.engine_wrapper, cfg_simple.dt, reg=1e-3,
347 w_bar=np.zeros(N_STATE), use_robustification=False, gamma_robust=1.0
348 )
349
350 # Line search
351 u_seq_new, alpha, cost_new = forward_line_search(
352 x0, u_seq, k_seq, K_seq, x_seq_nom, F_ref, MR_ref, cfg_simple, params,
353 self.engine_wrapper, cfg_simple.dt, alpha_init=1.0, alpha_min=1e-4
354 )
355
356 # Check structure
357 self.assertEqual(u_seq_new.shape, u_seq.shape)
358 self.assertGreaterEqual(alpha, 1e-4)
359 self.assertLessEqual(alpha, 1.0)
360 self.assertTrue(np.isfinite(cost_new))
361
362 # Check bounds
363 self.assertTrue(np.all(u_seq_new >= 0.0))
364 self.assertTrue(np.all(u_seq_new <= 1.0))
365
366
367if __name__ == "__main__":
368 unittest.main()
369
#define max(a, b)
Definition Arduino.h:43
void setUp(void)
Definition test_main.cpp:17