STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_robust_ddp_safety_filter.py
Go to the documentation of this file.
1"""Unit tests for safety filter module."""
2
3import unittest
4import numpy as np
5from unittest.mock import Mock
6
8 filter_action,
9 _is_action_safe,
10 _tube_violates_constraints,
11 _find_best_safe_action,
12 _generate_action_candidates,
13 _compute_action_cost,
14)
15from engine.control.robust_ddp.data_models import ControllerConfig, ControllerState
16from engine.control.robust_ddp.engine_wrapper import EngineEstimate, EngineWrapper
17from engine.control.robust_ddp.dynamics import IDX_P_U_F, IDX_P_U_O, IDX_P_COPV
18
19
20class TestSafetyFilter(unittest.TestCase):
21 """Test safety filter."""
22
23 def setUp(self):
24 """Set up test fixtures."""
26 N=10,
27 dt=0.01,
28 P_u_max=10e6, # 10 MPa max ullage pressure
29 P_copv_min=1e6, # 1 MPa min COPV pressure
30 MR_min=1.5,
31 MR_max=3.0,
32 injector_dp_frac=0.1,
33 qF=1.0,
34 qMR=10.0,
35 qGas=0.1,
36 )
37
38 # Normal state
39 self.x_normal = 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 ])
49
50 # State with high ullage pressure (near limit)
51 self.x_high_ullage = self.x_normal.copy()
52 self.x_high_ullage[IDX_P_U_F] = 9.5e6 # Near P_u_max
53
54 # State with low COPV pressure
55 self.x_low_copv = self.x_normal.copy()
56 self.x_low_copv[IDX_P_COPV] = 1.5e6 # Near P_copv_min
57
59 w_bar_array=np.ones(8) * 0.1e6, # 0.1 MPa uncertainty per component
60 )
61
62 # Mock engine wrapper
63 self.engine_wrapper = Mock(spec=EngineWrapper)
64
66 """Test tube constraint violation for ullage max."""
67 # Create tube that violates ullage max
68 x_lo = self.x_normal.copy()
69 x_hi = self.x_normal.copy()
70 x_hi[IDX_P_U_F] = 11e6 # Exceeds P_u_max (10e6)
71
72 violates = _tube_violates_constraints(x_lo, x_hi, self.cfg, None)
73 self.assertTrue(violates)
74
76 """Test tube constraint violation for COPV min."""
77 # Create tube that violates COPV min
78 x_lo = self.x_normal.copy()
79 x_hi = self.x_normal.copy()
80 x_hi[IDX_P_COPV] = 0.5e6 # Below P_copv_min (1e6)
81
82 violates = _tube_violates_constraints(x_lo, x_hi, self.cfg, None)
83 self.assertTrue(violates)
84
86 """Test tube constraint violation for MR bounds."""
87 # Create engine estimate with MR violation
88 eng_est = EngineEstimate(
89 P_ch=2.5e6,
90 F=5000.0,
91 mdot_F=1.0,
92 mdot_O=5.0, # MR = 5.0 > MR_max (3.0)
93 MR=5.0,
94 injector_dp_F=0.5e6,
95 injector_dp_O=0.5e6,
96 )
97 self.engine_wrapper.estimate_from_pressures = Mock(return_value=eng_est)
98
99 x_lo = self.x_normal.copy()
100 x_hi = self.x_normal.copy()
101
102 violates = _tube_violates_constraints(x_lo, x_hi, self.cfg, self.engine_wrapper)
103 self.assertTrue(violates)
104
106 """Test tube that satisfies all constraints."""
107 x_lo = self.x_normal.copy()
108 x_hi = self.x_normal.copy()
109
110 # Normal engine estimate
111 eng_est = EngineEstimate(
112 P_ch=2.5e6,
113 F=5000.0,
114 mdot_F=1.0,
115 mdot_O=2.0, # MR = 2.0 (within bounds)
116 MR=2.0,
117 injector_dp_F=0.5e6,
118 injector_dp_O=0.5e6,
119 )
120 self.engine_wrapper.estimate_from_pressures = Mock(return_value=eng_est)
121
122 violates = _tube_violates_constraints(x_lo, x_hi, self.cfg, self.engine_wrapper)
123 self.assertFalse(violates)
124
126 """Test action candidate generation."""
127 candidates = _generate_action_candidates(self.cfg)
128
129 # Should have at least binary candidates
130 self.assertGreaterEqual(len(candidates), 4)
131
132 # Check binary candidates are present
133 binary_set = {
134 (0.0, 0.0),
135 (0.0, 1.0),
136 (1.0, 0.0),
137 (1.0, 1.0),
138 }
139 candidate_set = {tuple(c) for c in candidates}
140 self.assertTrue(binary_set.issubset(candidate_set))
141
143 """Test action cost computation."""
144 u = np.array([0.5, 0.5])
145
146 # Cost without references
147 cost = _compute_action_cost(
148 self.x_normal, u, self.cfg, None, None, None
149 )
150 self.assertGreater(cost, 0.0)
151
152 # Cost with references
153 eng_est = EngineEstimate(
154 P_ch=2.5e6,
155 F=5000.0,
156 mdot_F=1.0,
157 mdot_O=2.0,
158 MR=2.0,
159 injector_dp_F=0.5e6,
160 injector_dp_O=0.5e6,
161 )
162 self.engine_wrapper.estimate_from_pressures = Mock(return_value=eng_est)
163
164 cost_with_ref = _compute_action_cost(
165 self.x_normal, u, self.cfg, self.engine_wrapper, 5000.0, 2.0
166 )
167 self.assertGreater(cost_with_ref, cost) # Should be higher with tracking error
168
170 """Test filter_action with safe proposed action."""
171 # Proposed action that should be safe
172 proposed = np.array([0.5, 0.5])
173
174 # Mock engine wrapper for safe tube propagation
175 eng_est = EngineEstimate(
176 P_ch=2.5e6,
177 F=5000.0,
178 mdot_F=1.0,
179 mdot_O=2.0,
180 MR=2.0,
181 injector_dp_F=0.5e6,
182 injector_dp_O=0.5e6,
183 )
184 self.engine_wrapper.estimate_from_pressures = Mock(return_value=eng_est)
185
186 safe_action = filter_action(
187 x=self.x_normal,
188 proposed=proposed,
189 state=self.state,
190 cfg=self.cfg,
191 engine_wrapper=self.engine_wrapper,
192 )
193
194 # Should return same action if safe
195 np.testing.assert_array_almost_equal(safe_action, proposed, decimal=3)
196
198 """Test filter_action with unsafe action that violates ullage max."""
199 # Proposed action that would increase ullage pressure too much
200 # Use high control to pressurize (unsafe when already near limit)
201 proposed = np.array([1.0, 1.0]) # Full open
202
203 # Mock engine wrapper
204 eng_est = EngineEstimate(
205 P_ch=2.5e6,
206 F=5000.0,
207 mdot_F=1.0,
208 mdot_O=2.0,
209 MR=2.0,
210 injector_dp_F=0.5e6,
211 injector_dp_O=0.5e6,
212 )
213 self.engine_wrapper.estimate_from_pressures = Mock(return_value=eng_est)
214
215 # Use state with high ullage pressure
216 safe_action = filter_action(
217 x=self.x_high_ullage,
218 proposed=proposed,
219 state=self.state,
220 cfg=self.cfg,
221 engine_wrapper=self.engine_wrapper,
222 num_steps=1, # Single step for faster test
223 )
224
225 # Should return different (safer) action
226 # May be zeros or lower control
227 self.assertIsNotNone(safe_action)
228 self.assertTrue(np.all(safe_action >= 0.0))
229 self.assertTrue(np.all(safe_action <= 1.0))
230
232 """Test filter_action with unsafe action that violates MR bounds."""
233 # Proposed action that would cause MR violation
234 proposed = np.array([0.1, 1.0]) # Low fuel, high oxidizer -> high MR
235
236 # Mock engine wrapper with MR violation
237 eng_est_high_mr = EngineEstimate(
238 P_ch=2.5e6,
239 F=5000.0,
240 mdot_F=0.5,
241 mdot_O=3.0, # MR = 6.0 > MR_max (3.0)
242 MR=6.0,
243 injector_dp_F=0.5e6,
244 injector_dp_O=0.5e6,
245 )
246
247 # For tube propagation, use normal MR
248 eng_est_normal = EngineEstimate(
249 P_ch=2.5e6,
250 F=5000.0,
251 mdot_F=1.0,
252 mdot_O=2.0,
253 MR=2.0,
254 injector_dp_F=0.5e6,
255 injector_dp_O=0.5e6,
256 )
257
258 # Return high MR for worst-case check
259 def mock_estimate(P_d_F, P_d_O):
260 # Use high MR for upper bound check
261 if P_d_O > 2.5e6: # High oxidizer pressure
262 return eng_est_high_mr
263 return eng_est_normal
264
265 self.engine_wrapper.estimate_from_pressures = Mock(side_effect=mock_estimate)
266
267 safe_action = filter_action(
268 x=self.x_normal,
269 proposed=proposed,
270 state=self.state,
271 cfg=self.cfg,
272 engine_wrapper=self.engine_wrapper,
273 num_steps=1,
274 )
275
276 # Should return different (safer) action
277 self.assertIsNotNone(safe_action)
278 # Should have higher fuel or lower oxidizer to fix MR
279 self.assertTrue(safe_action[0] > proposed[0] or safe_action[1] < proposed[1])
280
282 """Test filter_action clamps inputs."""
283 # Proposed action outside [0, 1]
284 proposed = np.array([-0.1, 1.5])
285
286 eng_est = EngineEstimate(
287 P_ch=2.5e6,
288 F=5000.0,
289 mdot_F=1.0,
290 mdot_O=2.0,
291 MR=2.0,
292 injector_dp_F=0.5e6,
293 injector_dp_O=0.5e6,
294 )
295 self.engine_wrapper.estimate_from_pressures = Mock(return_value=eng_est)
296
297 safe_action = filter_action(
298 x=self.x_normal,
299 proposed=proposed,
300 state=self.state,
301 cfg=self.cfg,
302 engine_wrapper=self.engine_wrapper,
303 )
304
305 # Should be clamped to [0, 1]
306 self.assertTrue(np.all(safe_action >= 0.0))
307 self.assertTrue(np.all(safe_action <= 1.0))
308
310 """Test finding best safe action from candidates."""
311 # Proposed unsafe action
312 proposed = np.array([1.0, 1.0])
313
314 eng_est = EngineEstimate(
315 P_ch=2.5e6,
316 F=5000.0,
317 mdot_F=1.0,
318 mdot_O=2.0,
319 MR=2.0,
320 injector_dp_F=0.5e6,
321 injector_dp_O=0.5e6,
322 )
323 self.engine_wrapper.estimate_from_pressures = Mock(return_value=eng_est)
324
325 w_bar = get_w_bar_array(self.state)
326
327 best_action = _find_best_safe_action(
328 x=self.x_normal,
329 proposed=proposed,
330 w_bar=w_bar,
331 cfg=self.cfg,
332 engine_wrapper=self.engine_wrapper,
333 F_ref=5000.0,
334 MR_ref=2.0,
335 num_steps=1,
336 dt=self.cfg.dt,
337 )
338
339 # Should return a valid action
340 self.assertIsNotNone(best_action)
341 self.assertEqual(best_action.shape, (2,))
342 self.assertTrue(np.all(best_action >= 0.0))
343 self.assertTrue(np.all(best_action <= 1.0))
344
345
346if __name__ == "__main__":
347 unittest.main()
348
void setUp(void)
Definition test_main.cpp:17