STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_robust_ddp_dynamics.py
Go to the documentation of this file.
1"""Unit tests for robust DDP controller dynamics."""
2
3import unittest
4import numpy as np
5
7 step,
8 linearize,
9 DynamicsParams,
10 N_STATE,
11 N_CONTROL,
12 IDX_P_COPV,
13 IDX_P_REG,
14 IDX_P_U_F,
15 IDX_P_U_O,
16 IDX_P_D_F,
17 IDX_P_D_O,
18 IDX_V_U_F,
19 IDX_V_U_O,
20)
21from engine.control.robust_ddp.data_models import ControllerConfig
22
23
24class TestDynamics(unittest.TestCase):
25 """Test dynamics model."""
26
27 def setUp(self):
28 """Set up test fixtures."""
30 self.params = DynamicsParams.from_config(self.config)
31 self.dt = 0.01 # 10 ms
32
33 # Initial state: [P_copv, P_reg, P_u_F, P_u_O, P_d_F, P_d_O, V_u_F, V_u_O]
34 self.x0 = np.array([
35 30e6, # P_copv: 30 MPa (~4350 psi)
36 24e6, # P_reg: 24 MPa (~3480 psi)
37 3e6, # P_u_F: 3 MPa (~435 psi)
38 3.5e6, # P_u_O: 3.5 MPa (~508 psi)
39 2.5e6, # P_d_F: 2.5 MPa (~363 psi)
40 3e6, # P_d_O: 3 MPa (~435 psi)
41 0.01, # V_u_F: 0.01 m³ (10 L)
42 0.01, # V_u_O: 0.01 m³ (10 L)
43 ], dtype=np.float64)
44
45 # Control: [u_F, u_O] in [0, 1]
46 self.u0 = np.array([0.5, 0.5], dtype=np.float64)
47
48 # Mass flow rates [kg/s]
49 self.mdot_F = 0.5
50 self.mdot_O = 1.0
51
52 def test_step_basic(self):
53 """Test basic step function."""
54 x_next = step(self.x0, self.u0, self.dt, self.params, self.mdot_F, self.mdot_O)
55
56 self.assertEqual(len(x_next), N_STATE)
57 self.assertTrue(np.all(np.isfinite(x_next)))
58 self.assertTrue(np.all(x_next >= 0)) # All pressures/volumes non-negative
59
61 """Test that blowdown decreases P_u when u=0 and mdot>0."""
62 # Set control to zero (no pressurization)
63 u = np.array([0.0, 0.0], dtype=np.float64)
64
65 # Initial ullage pressures
66 P_u_F_initial = self.x0[IDX_P_U_F]
67 P_u_O_initial = self.x0[IDX_P_U_O]
68
69 # Step forward
70 x_next = step(self.x0, u, self.dt, self.params, self.mdot_F, self.mdot_O)
71
72 # Ullage pressures should decrease due to blowdown
73 P_u_F_next = x_next[IDX_P_U_F]
74 P_u_O_next = x_next[IDX_P_U_O]
75
76 self.assertLess(P_u_F_next, P_u_F_initial,
77 f"P_u_F should decrease: {P_u_F_next} < {P_u_F_initial}")
78 self.assertLess(P_u_O_next, P_u_O_initial,
79 f"P_u_O should decrease: {P_u_O_next} < {P_u_O_initial}")
80
82 """Test that pressurization increases P_u when u>0 and headroom positive."""
83 # Set high control (full pressurization)
84 u = np.array([1.0, 1.0], dtype=np.float64)
85
86 # Set regulator pressure higher than ullage (positive headroom)
87 x = self.x0.copy()
88 x[IDX_P_REG] = 5e6 # 5 MPa regulator
89 x[IDX_P_U_F] = 3e6 # 3 MPa fuel ullage (2 MPa headroom)
90 x[IDX_P_U_O] = 3e6 # 3 MPa oxidizer ullage (2 MPa headroom)
91
92 # Set low mass flow to minimize blowdown effect
93 mdot_F_low = 0.01
94 mdot_O_low = 0.01
95
96 # Initial ullage pressures
97 P_u_F_initial = x[IDX_P_U_F]
98 P_u_O_initial = x[IDX_P_U_O]
99
100 # Step forward
101 x_next = step(x, u, self.dt, self.params, mdot_F_low, mdot_O_low)
102
103 # Ullage pressures should increase due to pressurization
104 P_u_F_next = x_next[IDX_P_U_F]
105 P_u_O_next = x_next[IDX_P_U_O]
106
107 self.assertGreater(P_u_F_next, P_u_F_initial,
108 f"P_u_F should increase: {P_u_F_next} > {P_u_F_initial}")
109 self.assertGreater(P_u_O_next, P_u_O_initial,
110 f"P_u_O should increase: {P_u_O_next} > {P_u_O_initial}")
111
113 """Test that ullage volume increases with propellant consumption."""
114 V_u_F_initial = self.x0[IDX_V_U_F]
115 V_u_O_initial = self.x0[IDX_V_U_O]
116
117 x_next = step(self.x0, self.u0, self.dt, self.params, self.mdot_F, self.mdot_O)
118
119 V_u_F_next = x_next[IDX_V_U_F]
120 V_u_O_next = x_next[IDX_V_U_O]
121
122 # Volumes should increase as propellant is consumed
123 self.assertGreater(V_u_F_next, V_u_F_initial,
124 f"V_u_F should increase: {V_u_F_next} > {V_u_F_initial}")
125 self.assertGreater(V_u_O_next, V_u_O_initial,
126 f"V_u_O should increase: {V_u_O_next} > {V_u_O_initial}")
127
129 """Test COPV pressure decreases with control usage."""
130 P_copv_initial = self.x0[IDX_P_COPV]
131
132 # With control active
133 x_next = step(self.x0, self.u0, self.dt, self.params, self.mdot_F, self.mdot_O)
134 P_copv_next = x_next[IDX_P_COPV]
135
136 # COPV should decrease
137 self.assertLess(P_copv_next, P_copv_initial,
138 f"P_copv should decrease: {P_copv_next} < {P_copv_initial}")
139
140 # With zero control, should still decrease due to loss
141 u_zero = np.array([0.0, 0.0], dtype=np.float64)
142 x_next_zero = step(self.x0, u_zero, self.dt, self.params, 0.0, 0.0)
143 P_copv_next_zero = x_next_zero[IDX_P_COPV]
144
145 self.assertLess(P_copv_next_zero, P_copv_initial,
146 f"P_copv should decrease even with u=0 due to loss: "
147 f"{P_copv_next_zero} < {P_copv_initial}")
148
150 """Test regulator pressure behavior."""
151 # Test with setpoint
152 params_setpoint = DynamicsParams.from_config(self.config)
153 params_setpoint.reg_setpoint = 25e6 # 25 MPa setpoint
154
155 x_next = step(self.x0, self.u0, self.dt, params_setpoint, self.mdot_F, self.mdot_O)
156 P_reg_next = x_next[IDX_P_REG]
157
158 # Should be at setpoint
159 self.assertAlmostEqual(P_reg_next, 25e6, delta=1e3,
160 msg=f"P_reg should be at setpoint: {P_reg_next} ≈ 25e6")
161
162 # Test with ratio (no setpoint)
163 params_ratio = DynamicsParams.from_config(self.config)
164 params_ratio.reg_setpoint = None
165 params_ratio.reg_ratio = 0.8
166
167 x_next_ratio = step(self.x0, self.u0, self.dt, params_ratio, self.mdot_F, self.mdot_O)
168 P_reg_next_ratio = x_next_ratio[IDX_P_REG]
169 P_copv_next_ratio = x_next_ratio[IDX_P_COPV]
170
171 # Should be approximately reg_ratio * P_copv
172 expected_P_reg = params_ratio.reg_ratio * P_copv_next_ratio
173 self.assertAlmostEqual(P_reg_next_ratio, expected_P_reg, delta=1e3,
174 msg=f"P_reg should be {params_ratio.reg_ratio} * P_copv: "
175 f"{P_reg_next_ratio} ≈ {expected_P_reg}")
176
178 """Test feed pressure follows ullage pressure with lag."""
179 # Set feed pressures different from ullage
180 x = self.x0.copy()
181 x[IDX_P_D_F] = 1e6 # Low feed pressure
182 x[IDX_P_D_O] = 1e6 # Low feed pressure
183
184 x_next = step(x, self.u0, self.dt, self.params, self.mdot_F, self.mdot_O)
185
186 # Feed pressures should move toward ullage pressures
187 P_d_F_next = x_next[IDX_P_D_F]
188 P_d_O_next = x_next[IDX_P_D_O]
189
190 # Should be higher than initial (moving toward ullage)
191 self.assertGreater(P_d_F_next, x[IDX_P_D_F],
192 f"P_d_F should increase toward P_u_F: {P_d_F_next} > {x[IDX_P_D_F]}")
193 self.assertGreater(P_d_O_next, x[IDX_P_D_O],
194 f"P_d_O should increase toward P_u_O: {P_d_O_next} > {x[IDX_P_D_O]}")
195
197 """Test linearization returns correct matrix shapes."""
198 A, B = linearize(self.x0, self.u0, self.dt, self.params, self.mdot_F, self.mdot_O)
199
200 self.assertEqual(A.shape, (N_STATE, N_STATE))
201 self.assertEqual(B.shape, (N_STATE, N_CONTROL))
202 self.assertTrue(np.all(np.isfinite(A)))
203 self.assertTrue(np.all(np.isfinite(B)))
204
206 """Test linearization accuracy."""
207 # Compute linearization
208 A, B = linearize(self.x0, self.u0, self.dt, self.params, self.mdot_F, self.mdot_O)
209
210 # Nominal next state
211 x_nom = step(self.x0, self.u0, self.dt, self.params, self.mdot_F, self.mdot_O)
212
213 # Linearized prediction
214 x_lin = A @ self.x0 + B @ self.u0
215
216 # For small perturbations, linearization should be close
217 # Note: dynamics are nonlinear, so we don't expect exact match
218 # But should be within reasonable tolerance
219 error = np.linalg.norm(x_lin - x_nom)
220 self.assertLess(error, 1e6, # Allow 1 MPa error
221 f"Linearization error too large: {error}")
222
224 """Test dynamics with zero mass flow."""
225 x_next = step(self.x0, self.u0, self.dt, self.params, 0.0, 0.0)
226
227 # Ullage volumes should not change
228 self.assertEqual(x_next[IDX_V_U_F], self.x0[IDX_V_U_F])
229 self.assertEqual(x_next[IDX_V_U_O], self.x0[IDX_V_U_O])
230
231 # Pressures should still evolve (pressurization, COPV blowdown, etc.)
232 self.assertTrue(np.all(np.isfinite(x_next)))
233
235 """Test that control is clamped to [0, 1]."""
236 # Try control outside [0, 1]
237 u_high = np.array([2.0, -1.0], dtype=np.float64)
238
239 x_next = step(self.x0, u_high, self.dt, self.params, self.mdot_F, self.mdot_O)
240
241 # Should not crash and should produce valid state
242 self.assertTrue(np.all(np.isfinite(x_next)))
243 self.assertTrue(np.all(x_next >= 0))
244
246 """Test that all states remain non-negative."""
247 # Try various initial conditions
248 x_test = self.x0.copy()
249 x_test[IDX_P_COPV] = 1e6 # Low COPV
250 x_test[IDX_P_U_F] = 0.1e6 # Very low ullage
251
252 x_next = step(x_test, self.u0, self.dt, self.params, self.mdot_F, self.mdot_O)
253
254 # All states should be non-negative
255 self.assertTrue(np.all(x_next >= 0),
256 f"All states should be non-negative, got: {x_next}")
257
258
260 """The polytropic reference state must not leak between configs.
261
262 It used to live on the `step` FUNCTION object, so the first trajectory ever stepped in a
263 process set the reference temperatures/masses/volumes for every later one, across configs.
264 An optimizer evaluating thousands of candidates -- the Layer X access pattern in
265 docs/adr/0001 -- would hand candidates 2..N the reference state of candidate 1.
266 """
267
268 @staticmethod
269 def _params(**over):
270 from engine.control.robust_ddp.dynamics import DynamicsParams
271 base = dict(copv_cF=1.0, copv_cO=1.0, copv_loss=1.0e3, reg_ratio=0.8,
272 alpha_F=1.0, alpha_O=1.0, rho_F=789.0, rho_O=1140.0,
273 tau_line_F=0.05, tau_line_O=0.05)
274 base.update(over)
275 return DynamicsParams(**base)
276
278 a = self._params()
279 b = self._params()
280 a._temp_initialized = True
281 a._T_copv_0 = 293.0
282 self.assertFalse(getattr(b, '_temp_initialized', False),
283 "a second config must start with its own uninitialised reference state")
284
286 from engine.control.robust_ddp import dynamics as dyn
287 leaked = [n for n in dir(dyn.step)
288 if n.startswith('_T_') or n.startswith('_m_') or n.startswith('_V_')
289 or n == '_temp_initialized']
290 self.assertEqual(leaked, [], f"process-global state back on step(): {leaked}")
291
292
294 """Regulator/valve geometry must be settable, not literals inside step()."""
295
297 from engine.control.robust_ddp.dynamics import DynamicsParams
298 p = DynamicsParams(copv_cF=1.0, copv_cO=1.0, copv_loss=1.0e3, reg_ratio=0.8,
299 alpha_F=1.0, alpha_O=1.0, rho_F=789.0, rho_O=1140.0,
300 tau_line_F=0.05, tau_line_O=0.05)
301 self.assertAlmostEqual(p.gamma_gas, 1.4)
302 self.assertAlmostEqual(p.Cd_regulator, 0.7)
303 self.assertAlmostEqual(p.Cd_valve, 0.65)
304 self.assertAlmostEqual(p.A_regulator, 2e-5)
305 self.assertAlmostEqual(p.A_valve_F, 5e-5)
306 self.assertAlmostEqual(p.A_valve_O, 5e-5)
307
309 from engine.control.robust_ddp.dynamics import DynamicsParams
310 p = DynamicsParams(copv_cF=1.0, copv_cO=1.0, copv_loss=1.0e3, reg_ratio=0.8,
311 alpha_F=1.0, alpha_O=1.0, rho_F=789.0, rho_O=1140.0,
312 tau_line_F=0.05, tau_line_O=0.05,
313 A_valve_F=9.9e-5, Cd_regulator=0.55)
314 self.assertAlmostEqual(p.A_valve_F, 9.9e-5)
315 self.assertAlmostEqual(p.Cd_regulator, 0.55)
316
317
318if __name__ == '__main__':
319 unittest.main()
320
void setUp(void)
Definition test_main.cpp:17