STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_robust_ddp_engine_wrapper.py
Go to the documentation of this file.
1"""Unit tests for engine wrapper."""
2
3import unittest
4import numpy as np
5from unittest.mock import Mock, patch
6
8 EngineWrapper,
9 EngineEstimate,
10 estimate_from_pressures,
11)
12from engine.pipeline.config_schemas import PintleEngineConfig
13from engine.core.runner import PintleEngineRunner
14
15
16class TestEngineWrapper(unittest.TestCase):
17 """Test engine wrapper."""
18
19 def setUp(self):
20 """Set up test fixtures."""
21 # Create a minimal mock config
22 # In real usage, this would be loaded from a config file
23 try:
24 from engine.pipeline.io import load_config
25 config_path = "configs/default.yaml"
26 try:
27 self.config = load_config(config_path)
28 except FileNotFoundError:
29 # Create minimal config if default doesn't exist
30 self.config = self._create_minimal_config()
31 except Exception:
32 self.config = self._create_minimal_config()
33
34 def _create_minimal_config(self) -> PintleEngineConfig:
35 """Create minimal config for testing."""
36 # This is a fallback - in practice, tests should use real configs
38 PintleEngineConfig,
39 PintleInjectorConfig,
40 PintleInjectorGeometry,
41 FuelGeometry,
42 OxidizerGeometry,
43 FluidConfig,
44 FeedSystemConfig,
45 DischargeConfig,
46 CombustionConfig,
47 CEAConfig,
48 ChamberGeometryConfig,
49 )
50
51 # Create minimal valid config
52 config = PintleEngineConfig(
53 injector=PintleInjectorConfig(
54 type="pintle",
55 geometry=PintleInjectorGeometry(
56 fuel=FuelGeometry(
57 d_pintle_tip=0.01,
58 n_holes=8,
59 d_hole=0.001,
60 ),
61 oxidizer=OxidizerGeometry(
62 d_annulus_inner=0.008,
63 d_annulus_outer=0.012,
64 ),
65 ),
66 ),
67 fluids={
68 "fuel": FluidConfig(
69 name="RP-1",
70 density=800.0,
71 viscosity=2e-3,
72 surface_tension=0.025,
73 ),
74 "oxidizer": FluidConfig(
75 name="LOX",
76 density=1140.0,
77 viscosity=0.2e-3,
78 surface_tension=0.013,
79 ),
80 },
81 feed_system={
82 "fuel": FeedSystemConfig(
83 K0=10.0,
84 K1=0.0,
85 phi_type="none",
86 d_inlet=0.02,
87 ),
88 "oxidizer": FeedSystemConfig(
89 K0=10.0,
90 K1=0.0,
91 phi_type="none",
92 d_inlet=0.02,
93 ),
94 },
95 discharge={
96 "fuel": DischargeConfig(Cd_inf=0.7),
97 "oxidizer": DischargeConfig(Cd_inf=0.7),
98 },
99 combustion=CombustionConfig(
100 cea=CEAConfig(
101 fuel="RP-1",
102 oxidizer="LOX",
103 expansion_ratio=10.0,
104 ),
105 efficiency=Mock(),
106 ),
107 chamber_geometry=ChamberGeometryConfig(
108 volume=0.001,
109 A_throat=1e-4,
110 A_exit=1e-3,
111 length=0.1,
112 chamber_diameter=0.08,
113 ),
114 )
115 return config
116
118 """Test EngineEstimate dataclass structure."""
119 estimate = EngineEstimate(
120 P_ch=2e6,
121 F=1000.0,
122 mdot_F=0.5,
123 mdot_O=1.0,
124 MR=2.0,
125 injector_dp_F=0.5e6,
126 injector_dp_O=0.5e6,
127 )
128
129 self.assertEqual(estimate.P_ch, 2e6)
130 self.assertEqual(estimate.F, 1000.0)
131 self.assertEqual(estimate.MR, 2.0)
132 self.assertEqual(estimate.injector_dp_F, 0.5e6)
133 self.assertEqual(estimate.injector_dp_O, 0.5e6)
134
136 """Test wrapper initialization."""
137 wrapper = EngineWrapper(self.config, cache_size=64)
138 self.assertIsNotNone(wrapper.runner)
139 self.assertEqual(wrapper.cache_size, 64)
140
141 @patch('engine.control.robust_ddp.engine_wrapper.PintleEngineRunner')
142 def test_estimate_from_pressures_mock(self, mock_runner_class):
143 """Test estimate_from_pressures with mocked runner."""
144 # Create mock runner
145 mock_runner = Mock()
146 mock_runner.evaluate.return_value = {
147 "Pc": 2e6, # Chamber pressure [Pa]
148 "F": 1000.0, # Thrust [N]
149 "mdot_F": 0.5, # Fuel mass flow [kg/s]
150 "mdot_O": 1.0, # Oxidizer mass flow [kg/s]
151 "MR": 2.0, # Mixture ratio
152 "stability": {
153 "stability_state": "stable",
154 "stability_score": 0.8,
155 "chugging": {"frequency": 100.0},
156 },
157 "diagnostics": {"test": "value"},
158 }
159 mock_runner_class.return_value = mock_runner
160
161 # Create wrapper
162 wrapper = EngineWrapper(self.config)
163 wrapper.runner = mock_runner
164
165 # Test estimation
166 P_d_F = 3e6 # 3 MPa feed pressure
167 P_d_O = 3.5e6 # 3.5 MPa feed pressure
168
169 estimate = wrapper.estimate_from_pressures(P_d_F, P_d_O)
170
171 # Verify estimate
172 self.assertEqual(estimate.P_ch, 2e6)
173 self.assertEqual(estimate.F, 1000.0)
174 self.assertEqual(estimate.mdot_F, 0.5)
175 self.assertEqual(estimate.mdot_O, 1.0)
176 self.assertEqual(estimate.MR, 2.0)
177
178 # Verify injector pressure drops
179 self.assertEqual(estimate.injector_dp_F, P_d_F - 2e6) # 1 MPa
180 self.assertEqual(estimate.injector_dp_O, P_d_O - 2e6) # 1.5 MPa
181
182 # Verify stability metrics
183 self.assertIsNotNone(estimate.stability_metrics)
184 self.assertEqual(estimate.stability_metrics["stability_state"], "stable")
185
186 # Verify runner was called with correct pressures
187 mock_runner.evaluate.assert_called_once()
188 call_args = mock_runner.evaluate.call_args
189 self.assertEqual(call_args.kwargs["P_tank_F"], P_d_F)
190 self.assertEqual(call_args.kwargs["P_tank_O"], P_d_O)
191 self.assertTrue(call_args.kwargs.get("silent", False))
192
193 @patch('engine.control.robust_ddp.engine_wrapper.PintleEngineRunner')
194 def test_estimate_without_stability(self, mock_runner_class):
195 """Test estimate when stability results are not available."""
196 mock_runner = Mock()
197 mock_runner.evaluate.return_value = {
198 "Pc": 2e6,
199 "F": 1000.0,
200 "mdot_F": 0.5,
201 "mdot_O": 1.0,
202 "MR": 2.0,
203 "diagnostics": {},
204 }
205 mock_runner_class.return_value = mock_runner
206
207 wrapper = EngineWrapper(self.config)
208 wrapper.runner = mock_runner
209
210 estimate = wrapper.estimate_from_pressures(3e6, 3.5e6)
211
212 # Should have minimal stability metrics
213 self.assertIsNotNone(estimate.stability_metrics)
214 self.assertIn("injector_stiffness_ok", estimate.stability_metrics)
215 self.assertIn("injector_dp_frac_F", estimate.stability_metrics)
216 self.assertIn("injector_dp_frac_O", estimate.stability_metrics)
217
218 @patch('engine.control.robust_ddp.engine_wrapper.PintleEngineRunner')
219 def test_estimate_error_handling(self, mock_runner_class):
220 """Test estimate handles evaluation errors gracefully."""
221 mock_runner = Mock()
222 mock_runner.evaluate.side_effect = ValueError("Evaluation failed")
223 mock_runner_class.return_value = mock_runner
224
225 wrapper = EngineWrapper(self.config)
226 wrapper.runner = mock_runner
227
228 estimate = wrapper.estimate_from_pressures(3e6, 3.5e6)
229
230 # Should return estimate with NaN values
231 self.assertTrue(np.isnan(estimate.P_ch))
232 self.assertTrue(np.isnan(estimate.F))
233 self.assertTrue(np.isnan(estimate.mdot_F))
234 self.assertIsNotNone(estimate.diagnostics)
235 self.assertIn("error", estimate.diagnostics)
236
237 def test_caching(self):
238 """Test that caching works correctly."""
239 wrapper = EngineWrapper(self.config, cache_size=10)
240
241 # Mock runner to count calls
242 call_count = {"count": 0}
243 def mock_evaluate(**kwargs):
244 call_count["count"] += 1
245 return {
246 "Pc": 2e6,
247 "F": 1000.0,
248 "mdot_F": 0.5,
249 "mdot_O": 1.0,
250 "MR": 2.0,
251 "diagnostics": {},
252 }
253
254 wrapper.runner.evaluate = mock_evaluate
255
256 # First call - should evaluate
257 P_d_F = 3e6
258 P_d_O = 3.5e6
259 estimate1 = wrapper.estimate_from_pressures(P_d_F, P_d_O, use_cache=True)
260 self.assertEqual(call_count["count"], 1)
261
262 # Second call with same pressures - should use cache
263 estimate2 = wrapper.estimate_from_pressures(P_d_F, P_d_O, use_cache=True)
264 self.assertEqual(call_count["count"], 1) # No additional call
265 self.assertEqual(estimate1.P_ch, estimate2.P_ch)
266
267 # Call with cache disabled - should evaluate again
268 estimate3 = wrapper.estimate_from_pressures(P_d_F, P_d_O, use_cache=False)
269 self.assertEqual(call_count["count"], 2) # Additional call
270
272 """Test cache statistics."""
273 wrapper = EngineWrapper(self.config, cache_size=10)
274
275 stats = wrapper.get_cache_stats()
276 self.assertEqual(stats["size"], 0)
277 self.assertEqual(stats["max_size"], 10)
278 self.assertEqual(stats["usage"], 0.0)
279
280 # Add some entries
281 wrapper._result_cache = {"key1": Mock(), "key2": Mock()}
282 stats = wrapper.get_cache_stats()
283 self.assertEqual(stats["size"], 2)
284 self.assertAlmostEqual(stats["usage"], 0.2, places=1)
285
287 """Test cache clearing."""
288 wrapper = EngineWrapper(self.config)
289 wrapper._result_cache = {"key1": Mock(), "key2": Mock()}
290
291 self.assertEqual(len(wrapper._result_cache), 2)
292 wrapper.clear_cache()
293 self.assertEqual(len(wrapper._result_cache), 0)
294
296 """Test convenience function."""
297 with patch('engine.control.robust_ddp.engine_wrapper.EngineWrapper') as mock_wrapper_class:
298 mock_wrapper = Mock()
299 mock_estimate = EngineEstimate(
300 P_ch=2e6,
301 F=1000.0,
302 mdot_F=0.5,
303 mdot_O=1.0,
304 MR=2.0,
305 injector_dp_F=1e6,
306 injector_dp_O=1.5e6,
307 )
308 mock_wrapper.estimate_from_pressures.return_value = mock_estimate
309 mock_wrapper_class.return_value = mock_wrapper
310
311 estimate = estimate_from_pressures(3e6, 3.5e6, self.config)
312
313 self.assertEqual(estimate.P_ch, 2e6)
314 mock_wrapper.estimate_from_pressures.assert_called_once_with(3e6, 3.5e6, use_cache=True)
315
316
317class TestEngineWrapperIntegration(unittest.TestCase):
318 """Integration tests comparing wrapper to direct pipeline calls."""
319
320 def setUp(self):
321 """Set up test fixtures."""
322 try:
323 from engine.pipeline.io import load_config
324 config_path = "configs/default.yaml"
325 try:
326 self.config = load_config(config_path)
329 self.has_config = True
330 except FileNotFoundError:
331 self.has_config = False
332 self.skipTest("No default config file found")
333 except Exception as e:
334 self.has_config = False
335 self.skipTest(f"Could not load config: {e}")
336
338 """Test that wrapper output matches direct pipeline calls."""
339 if not self.has_config:
340 self.skipTest("No config available")
341
342 # Test pressures (in Pa)
343 P_d_F = 3e6 # 3 MPa (~435 psi)
344 P_d_O = 3.5e6 # 3.5 MPa (~508 psi)
345
346 # Direct pipeline call
347 try:
348 direct_results = self.runner.evaluate(
349 P_tank_O=P_d_O,
350 P_tank_F=P_d_F,
351 silent=True,
352 )
353 except Exception as e:
354 self.skipTest(f"Direct pipeline evaluation failed: {e}")
355
356 # Wrapper call
357 estimate = self.wrapper.estimate_from_pressures(P_d_F, P_d_O, use_cache=False)
358
359 # Compare results
360 self.assertAlmostEqual(
361 estimate.P_ch, direct_results["Pc"],
362 delta=1e3, # Allow 1 kPa difference
363 msg="Chamber pressure should match"
364 )
365
366 self.assertAlmostEqual(
367 estimate.F, direct_results["F"],
368 delta=10.0, # Allow 10 N difference
369 msg="Thrust should match"
370 )
371
372 self.assertAlmostEqual(
373 estimate.mdot_F, direct_results["mdot_F"],
374 delta=0.01, # Allow 0.01 kg/s difference
375 msg="Fuel mass flow should match"
376 )
377
378 self.assertAlmostEqual(
379 estimate.mdot_O, direct_results["mdot_O"],
380 delta=0.01, # Allow 0.01 kg/s difference
381 msg="Oxidizer mass flow should match"
382 )
383
384 self.assertAlmostEqual(
385 estimate.MR, direct_results["MR"],
386 delta=0.1, # Allow 0.1 difference in MR
387 msg="Mixture ratio should match"
388 )
389
390 # Verify injector pressure drops
391 expected_dp_F = P_d_F - direct_results["Pc"]
392 expected_dp_O = P_d_O - direct_results["Pc"]
393
394 self.assertAlmostEqual(
395 estimate.injector_dp_F, expected_dp_F,
396 delta=1e3,
397 msg="Fuel injector pressure drop should match"
398 )
399
400 self.assertAlmostEqual(
401 estimate.injector_dp_O, expected_dp_O,
402 delta=1e3,
403 msg="Oxidizer injector pressure drop should match"
404 )
405
407 """Test wrapper on multiple pressure points."""
408 if not self.has_config:
409 self.skipTest("No config available")
410
411 # Test multiple pressure combinations
412 test_cases = [
413 (2e6, 2.5e6), # Low pressures
414 (3e6, 3.5e6), # Medium pressures
415 (5e6, 6e6), # High pressures
416 ]
417
418 for P_d_F, P_d_O in test_cases:
419 with self.subTest(P_d_F=P_d_F, P_d_O=P_d_O):
420 try:
421 # Direct call
422 direct_results = self.runner.evaluate(
423 P_tank_O=P_d_O,
424 P_tank_F=P_d_F,
425 silent=True,
426 )
427
428 # Wrapper call
429 estimate = self.wrapper.estimate_from_pressures(
430 P_d_F, P_d_O, use_cache=False
431 )
432
433 # Verify estimates are reasonable
434 self.assertTrue(np.isfinite(estimate.P_ch))
435 self.assertTrue(np.isfinite(estimate.F))
436 self.assertTrue(estimate.F > 0)
437 self.assertTrue(estimate.mdot_F > 0)
438 self.assertTrue(estimate.mdot_O > 0)
439 self.assertTrue(estimate.MR > 0)
440
441 # Verify injector pressure drops are positive
442 self.assertGreater(estimate.injector_dp_F, 0)
443 self.assertGreater(estimate.injector_dp_O, 0)
444
445 except Exception as e:
446 # Some pressure combinations may be infeasible
447 # That's okay - just verify we get a valid estimate structure
448 self.assertIsNotNone(estimate)
449 self.assertIsNotNone(estimate.diagnostics)
450
451
452if __name__ == '__main__':
453 unittest.main()
454
void setUp(void)
Definition test_main.cpp:17