STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
interactive_pipeline.py
Go to the documentation of this file.
1"""Interactive CLI for the pintle engine pipeline.
2
3Features:
4- Forward mode: user provides tank pressures (psi) and receives performance outputs
5- Inverse mode: user provides target thrust (kN) and pipeline solves required tank pressures
6
7This script uses the existing PintleEngineRunner to evaluate the pipeline.
8"""
9
10from __future__ import annotations
11
12import math
13import sys
14from pathlib import Path
15from typing import Tuple, Dict, Any, Optional
16
17import numpy as np
18from scipy.optimize import brentq
19
20sys.path.insert(0, str(Path(__file__).parent.parent))
21
22from engine.pipeline.io import load_config
23from engine.core.runner import PintleEngineRunner
24
25# -----------------------------------------------------------------------------
26# Helper utilities
27# -----------------------------------------------------------------------------
28
29PSI_TO_PA = 6894.76
30PA_TO_PSI = 1.0 / PSI_TO_PA
31
32CONFIG_PATH = Path(__file__).parent.parent / "configs" / "default.yaml"
33
34
35class ThrustSolveError(RuntimeError):
36 """Custom exception carrying diagnostics for inverse thrust solving."""
37
38 def __init__(self, message: str, diagnostics: Dict[str, Any]):
39 super().__init__(message)
40 self.diagnostics = diagnostics
41
42
43def load_runner() -> PintleEngineRunner:
44 """Load configuration and create a runner instance."""
45 config = load_config(str(CONFIG_PATH))
46 return PintleEngineRunner(config)
47
48
49def print_header(title: str) -> None:
50 print("\n" + "=" * 80)
51 print(title)
52 print("=" * 80)
53
54
55def format_value(value: float, unit: str, precision: int = 2) -> str:
56 return f"{value:.{precision}f} {unit}"
57
58
59def summarize_results(results: Dict[str, Any]) -> None:
60 """Pretty-print the main outputs from a pipeline evaluation."""
61 Pc_psi = results["Pc"] * PA_TO_PSI
62 thrust_kN = results["F"] / 1000.0
63 mdot_total = results["mdot_total"]
64 mdot_O = results["mdot_O"]
65 mdot_F = results["mdot_F"]
66 MR = results["MR"]
67 Isp = results["Isp"]
68 cstar = results["cstar_actual"]
69 v_exit = results["v_exit"]
70 P_exit_psi = results["P_exit"] * PA_TO_PSI
71
72 print("Performance Summary:")
73 print(f" Thrust : {format_value(thrust_kN, 'kN')}")
74 print(f" Specific Impulse : {format_value(Isp, 's', 1)}")
75 print(f" Chamber Pressure : {format_value(Pc_psi, 'psi', 1)}")
76 print(f" Total Mass Flow : {format_value(mdot_total, 'kg/s', 3)}")
77 print(f" - Oxidizer : {format_value(mdot_O, 'kg/s', 3)}")
78 print(f" - Fuel : {format_value(mdot_F, 'kg/s', 3)}")
79 print(f" Mixture Ratio (O/F): {MR:.3f}")
80 print(f" c* (actual) : {format_value(cstar, 'm/s', 1)}")
81 print(f" Exit Velocity : {format_value(v_exit, 'm/s', 1)}")
82 print(f" Exit Pressure : {format_value(P_exit_psi, 'psi', 2)}")
83
84 cooling = results.get("cooling", {})
85 if cooling:
86 print("\nCooling Summary:")
87 regen = cooling.get("regen")
88 if regen and regen.get("enabled", False):
89 print(f" Regen outlet T : {regen['coolant_outlet_temperature']:.1f} K")
90 print(f" Regen heat flux : {regen['overall_heat_flux']/1000:.1f} kW/m²")
91 if 'mdot_coolant' in regen:
92 print(f" Coolant flow : {regen['mdot_coolant']:.3f} kg/s")
93 film = cooling.get("film")
94 if film and film.get("enabled", False):
95 print(
96 f" Film effectiveness: {film['effectiveness']:.2f} (mass fraction {film['mass_fraction']:.3f})"
97 )
98 print(f" Film heat factor : {film['heat_flux_factor']:.2f} | Film flow {film['mdot_film']:.3f} kg/s")
99 ablative = cooling.get("ablative")
100 if ablative and ablative.get("enabled", False):
101 print(
102 f" Ablative recession: {ablative['recession_rate']*1e6:.3f} µm/s"
103 )
104 print(f" Ablative heat flux: {ablative['effective_heat_flux']/1000:.1f} kW/m²")
105
106
107# -----------------------------------------------------------------------------
108# Forward evaluation (tank pressures -> performance)
109# -----------------------------------------------------------------------------
110
111def forward_mode(runner: PintleEngineRunner) -> None:
112 print_header("FORWARD MODE: Tank Pressures -> Performance")
113
114 try:
115 P_tank_O_psi = float(input("Enter LOX tank pressure [psi]: ").strip())
116 P_tank_F_psi = float(input("Enter Fuel tank pressure [psi]: ").strip())
117 except ValueError:
118 print("Invalid numeric input. Returning to menu.")
119 return
120
121 if P_tank_O_psi <= 0 or P_tank_F_psi <= 0:
122 print("Tank pressures must be positive. Returning to menu.")
123 return
124
125 P_tank_O = P_tank_O_psi * PSI_TO_PA
126 P_tank_F = P_tank_F_psi * PSI_TO_PA
127
128 try:
129 results = runner.evaluate(P_tank_O, P_tank_F)
130 except Exception as exc:
131 print(f"Pipeline evaluation failed: {exc}")
132 return
133
134 print_header("RESULTS")
135 summarize_results(results)
136
137
138# -----------------------------------------------------------------------------
139# Inverse evaluation (target thrust -> tank pressures)
140# -----------------------------------------------------------------------------
141
143 scale: float,
144 runner: PintleEngineRunner,
145 base_pressures: Tuple[float, float],
146 target_thrust_kN: float,
147) -> float:
148 """Return thrust difference at given scale factor."""
149 P_tank_O_base, P_tank_F_base = base_pressures
150 P_tank_O = scale * P_tank_O_base
151 P_tank_F = scale * P_tank_F_base
152 try:
153 results = runner.evaluate(P_tank_O, P_tank_F)
154 except Exception as exc: # propagate for root finder diagnostics
155 raise RuntimeError(f"Evaluation failed at scale={scale:.4f}: {exc}") from exc
156 thrust_kN = results["F"] / 1000.0
157 return thrust_kN - target_thrust_kN
158
159
161 runner: PintleEngineRunner,
162 target_thrust_kN: float,
163 target_MR: float,
164 initial_guess_psi: Optional[Tuple[float, float]] = None,
165 max_iterations: int = 50,
166 tolerance: float = 0.01,
167) -> Tuple[Tuple[float, float], Dict[str, Any], Dict[str, Any]]:
168 """Solve for tank pressures that achieve both target thrust AND target O/F ratio.
169
170 This uses a 2D Newton-Raphson iteration to find (P_tank_O, P_tank_F) such that:
171 - Thrust(P_O, P_F) = target_thrust_kN
172 - MR(P_O, P_F) = target_MR
173
174 Parameters:
175 -----------
176 runner : PintleEngineRunner
177 Engine pipeline runner
178 target_thrust_kN : float
179 Desired thrust [kN]
180 target_MR : float
181 Desired mixture ratio (O/F)
182 initial_guess_psi : tuple, optional
183 Starting guess for (P_tank_O, P_tank_F) in psi
184 If None, uses (1305, 974) as default
185 max_iterations : int
186 Maximum Newton iterations
187 tolerance : float
188 Convergence tolerance (relative error)
189
190 Returns:
191 --------
192 (P_tank_O_psi, P_tank_F_psi) : tuple
193 Solution tank pressures [psi]
194 results : dict
195 Full pipeline results at solution
196 diagnostics : dict
197 Iteration history and convergence info
198 """
199 from scipy.optimize import fsolve
200
201 if initial_guess_psi is None:
202 initial_guess_psi = (1305.0, 974.0)
203
204 P_O_guess, P_F_guess = initial_guess_psi
205
206 # Track iteration history
207 history = {
208 "P_tank_O": [],
209 "P_tank_F": [],
210 "thrust": [],
211 "MR": [],
212 "thrust_error": [],
213 "MR_error": [],
214 }
215
216 def residual(P_psi: np.ndarray) -> np.ndarray:
217 """Residual function for fsolve: [thrust_error, MR_error]"""
218 P_O_psi, P_F_psi = P_psi
219
220 # Clamp to positive values
221 if P_O_psi <= 0 or P_F_psi <= 0:
222 return np.array([1e6, 1e6])
223
224 try:
225 P_O_pa = P_O_psi * PSI_TO_PA
226 P_F_pa = P_F_psi * PSI_TO_PA
227 res = runner.evaluate(P_O_pa, P_F_pa)
228
229 thrust_kN = res["F"] / 1000.0
230 MR = res["MR"]
231
232 # Safe division with validation
233 thrust_error = (thrust_kN - target_thrust_kN) / target_thrust_kN if target_thrust_kN > 0 else 1.0
234 MR_error = (MR - target_MR) / target_MR if target_MR > 0 else 1.0
235
236 # Log history
237 history["P_tank_O"].append(P_O_psi)
238 history["P_tank_F"].append(P_F_psi)
239 history["thrust"].append(thrust_kN)
240 history["MR"].append(MR)
241 history["thrust_error"].append(thrust_error)
242 history["MR_error"].append(MR_error)
243
244 return np.array([thrust_error, MR_error])
245
246 except Exception as exc:
247 # If evaluation fails, return large error
248 return np.array([1e6, 1e6])
249
250 # CRITICAL FIX: Use more robust solver with better options
251 # fsolve can fail if residual is poorly conditioned - use better method
252 from scipy.optimize import root
253
254 # Solve using root (more robust than fsolve)
255 try:
256 # Try 'hybr' method first (default, fast)
257 result = root(
258 residual,
259 x0=np.array([P_O_guess, P_F_guess]),
260 method='hybr',
261 options={'xtol': tolerance, 'maxfev': max_iterations * 10},
262 )
263
264 if not result.success:
265 # If hybr fails, try 'lm' (Levenberg-Marquardt, more robust)
266 result = root(
267 residual,
268 x0=np.array([P_O_guess, P_F_guess]),
269 method='lm',
270 options={'xtol': tolerance, 'max_nfev': max_iterations * 10},
271 )
272
273 solution = result.x
274 ier = 1 if result.success else 0
275 msg = result.message if hasattr(result, 'message') else str(result.status)
276
277 P_O_sol, P_F_sol = solution
278
279 # CRITICAL FIX: Check if solution is actually good before accepting
280 # Even if solver says it converged, verify the result
281 if ier != 1 or not result.success:
282 # Check if we got a reasonable solution anyway
283 try:
284 test_results = runner.evaluate(P_O_sol * PSI_TO_PA, P_F_sol * PSI_TO_PA)
285 test_thrust = test_results["F"] / 1000.0
286 test_MR = test_results["MR"]
287 thrust_err = abs(test_thrust - target_thrust_kN) / target_thrust_kN if target_thrust_kN > 0 else 1.0
288 MR_err = abs(test_MR - target_MR) / target_MR if target_MR > 0 else 1.0
289
290 # If solution is actually good (within 2x tolerance), accept it
291 if thrust_err < tolerance * 2.0 and MR_err < tolerance * 2.0:
292 # Solution is good enough - accept it
293 pass
294 else:
295 raise ThrustSolveError(
296 f"Root finder did not converge: {msg}",
297 {
298 "history": history,
299 "final_pressures": (P_O_sol, P_F_sol),
300 "target_thrust": target_thrust_kN,
301 "target_MR": target_MR,
302 "actual_thrust": test_thrust,
303 "actual_MR": test_MR,
304 },
305 )
306 except Exception as e:
307 raise ThrustSolveError(
308 f"Root finder did not converge: {msg}",
309 {
310 "history": history,
311 "final_pressures": (P_O_sol, P_F_sol),
312 "target_thrust": target_thrust_kN,
313 "target_MR": target_MR,
314 },
315 ) from e
316
317 # Evaluate final solution
318 final_results = runner.evaluate(P_O_sol * PSI_TO_PA, P_F_sol * PSI_TO_PA)
319 final_thrust = final_results["F"] / 1000.0
320 final_MR = final_results["MR"]
321
322 thrust_error_pct = abs((final_thrust - target_thrust_kN) / target_thrust_kN) * 100
323 MR_error_pct = abs((final_MR - target_MR) / target_MR) * 100
324
325 diagnostics = {
326 "history": history,
327 "iterations": len(history["thrust"]),
328 "final_thrust": final_thrust,
329 "final_MR": final_MR,
330 "thrust_error_pct": thrust_error_pct,
331 "MR_error_pct": MR_error_pct,
332 "converged": True,
333 }
334
335 return (P_O_sol, P_F_sol), final_results, diagnostics
336
337 except Exception as exc:
338 raise ThrustSolveError(
339 f"Failed to solve for thrust and O/F: {exc}",
340 {
341 "history": history,
342 "target_thrust": target_thrust_kN,
343 "target_MR": target_MR,
344 "initial_guess": initial_guess_psi,
345 },
346 ) from exc
347
348
350 runner: PintleEngineRunner,
351 target_thrust_kN: float,
352 base_pressures_psi: Tuple[float, float],
353 scale_bounds: Tuple[float, float] = (0.2, 5.0),
354 num_samples: int = 60,
355) -> Tuple[Tuple[float, float], Dict[str, Any], Dict[str, Any]]:
356 """Solve for tank pressures that achieve the target thrust.
357
358 The solution scales the baseline tank pressures by a factor 'scale'.
359 The function also returns diagnostic information useful for UIs.
360 """
361
362 base_O_psi, base_F_psi = base_pressures_psi
363 if base_O_psi <= 0 or base_F_psi <= 0:
364 raise ValueError("Baseline tank pressures must be positive.")
365
366 base_pressures_pa = (base_O_psi * PSI_TO_PA, base_F_psi * PSI_TO_PA)
367
368 try:
369 baseline_results = runner.evaluate(*base_pressures_pa)
370 except Exception as exc:
371 raise ThrustSolveError(
372 f"Baseline evaluation failed: {exc}",
373 {
374 "baseline_pressed": base_pressures_pa,
375 "target_thrust": target_thrust_kN,
376 },
377 ) from exc
378 baseline_thrust = baseline_results["F"] / 1000.0
379
380 scale_min, scale_max = scale_bounds
381 if scale_min <= 0 or scale_max <= 0:
382 raise ValueError("Scale bounds must be positive.")
383
384 # Sample thrust across scale range to bracket the target
385 sample_scales = np.linspace(scale_min, scale_max, num_samples)
386 sample_scales = np.unique(np.append(sample_scales, 1.0)) # ensure baseline included
387 sample_scales.sort()
388
389 thrust_samples = []
390 diff_samples = []
391 cache: Dict[float, Dict[str, Any]] = {}
392 invalid_samples: list[Tuple[float, str]] = []
393
394 for scale in sample_scales:
395 P_tank_O = scale * base_pressures_pa[0]
396 P_tank_F = scale * base_pressures_pa[1]
397 try:
398 res = runner.evaluate(P_tank_O, P_tank_F)
399 except Exception as exc:
400 invalid_samples.append((scale, str(exc)))
401 cache[scale] = {"error": str(exc)}
402 thrust_samples.append(np.nan)
403 diff_samples.append(np.nan)
404 continue
405
406 cache[scale] = res
407 thrust = res["F"] / 1000.0
408 thrust_samples.append(thrust)
409 diff_samples.append(thrust - target_thrust_kN)
410
411 thrust_array = np.asarray(thrust_samples, dtype=float)
412 diff_array = np.asarray(diff_samples, dtype=float)
413 finite_mask = np.isfinite(thrust_array)
414
415 if not np.any(finite_mask):
416 diagnostics = {
417 "baseline_thrust": baseline_thrust,
418 "scale_bounds": scale_bounds,
419 "sample_scales": sample_scales,
420 "sample_thrusts": thrust_samples,
421 "invalid_samples": invalid_samples,
422 }
423 raise ThrustSolveError(
424 "All sampled scale evaluations failed. Consider adjusting baseline pressures or scale bounds.",
425 diagnostics,
426 )
427
428 valid_scales = sample_scales[finite_mask]
429 valid_thrusts = thrust_array[finite_mask]
430 valid_diffs = diff_array[finite_mask]
431
432 thrust_min = float(np.min(valid_thrusts))
433 thrust_max = float(np.max(valid_thrusts))
434
435 diagnostics = {
436 "baseline_thrust": baseline_thrust,
437 "min_thrust": thrust_min,
438 "max_thrust": thrust_max,
439 "scale_bounds": scale_bounds,
440 "sample_scales": sample_scales,
441 "sample_thrusts": thrust_samples,
442 "invalid_samples": invalid_samples,
443 }
444
445 if target_thrust_kN < thrust_min or target_thrust_kN > thrust_max:
446 raise ThrustSolveError(
447 f"Target thrust {target_thrust_kN:.2f} kN is outside achievable range "
448 f"[{thrust_min:.2f}, {thrust_max:.2f}] kN for scale bounds {scale_bounds}. "
449 f"Baseline thrust: {baseline_thrust:.2f} kN.",
450 diagnostics,
451 )
452
453 bracket: Optional[Tuple[float, float]] = None
454 for idx in range(len(valid_scales) - 1):
455 diff_i = valid_diffs[idx]
456 diff_j = valid_diffs[idx + 1]
457
458 if abs(diff_i) < 1e-3:
459 scale = valid_scales[idx]
460 P_tank_O_solution = scale * base_pressures_pa[0]
461 P_tank_F_solution = scale * base_pressures_pa[1]
462 results = cache.get(scale)
463 if not results:
464 results = runner.evaluate(P_tank_O_solution, P_tank_F_solution)
465 return (P_tank_O_solution, P_tank_F_solution), results, diagnostics
466
467 if diff_i * diff_j <= 0:
468 bracket = (valid_scales[idx], valid_scales[idx + 1])
469 break
470
471 if bracket is None:
472 raise ThrustSolveError(
473 "Failed to bracket target thrust. Consider expanding scale bounds.",
474 diagnostics,
475 )
476
477 try:
478 scale = brentq(
479 _thrust_difference,
480 bracket[0],
481 bracket[1],
482 args=(runner, base_pressures_pa, target_thrust_kN),
483 xtol=1e-5,
484 rtol=1e-5,
485 maxiter=100,
486 )
487 except Exception as exc:
488 raise ThrustSolveError(f"Root finding failed: {exc}", diagnostics) from exc
489
490 P_tank_O_solution = scale * base_pressures_pa[0]
491 P_tank_F_solution = scale * base_pressures_pa[1]
492 results = runner.evaluate(P_tank_O_solution, P_tank_F_solution)
493
494 return (P_tank_O_solution, P_tank_F_solution), results, diagnostics
495
496
497def inverse_mode(runner: PintleEngineRunner) -> None:
498 print_header("INVERSE MODE: Target Thrust -> Tank Pressures")
499 try:
500 target_thrust_kN = float(input("Enter desired thrust [kN]: ").strip())
501 except ValueError:
502 print("Invalid thrust input. Returning to menu.")
503 return
504
505 if target_thrust_kN <= 0:
506 print("Target thrust must be positive. Returning to menu.")
507 return
508
509 # Baseline pressures (psi)
510 try:
511 base_O_psi = float(
512 input("Enter baseline LOX tank pressure [psi] (default 1305): ").strip() or "1305"
513 )
514 base_F_psi = float(
515 input("Enter baseline Fuel tank pressure [psi] (default 974): ").strip() or "974"
516 )
517 except ValueError:
518 print("Invalid baseline pressures. Returning to menu.")
519 return
520
521 try:
522 (P_tank_O_solution, P_tank_F_solution), results, diagnostics = solve_for_thrust(
523 runner,
524 target_thrust_kN,
525 (base_O_psi, base_F_psi),
526 )
527 except ThrustSolveError as exc:
528 print(f"Failed to find tank pressures for target thrust: {exc}")
529 diag = exc.diagnostics
530 print(
531 f"Achievable thrust range within scale bounds {diag['scale_bounds']}: "
532 f"{diag['min_thrust']:.2f} - {diag['max_thrust']:.2f} kN"
533 )
534 return
535 except Exception as exc:
536 print(f"Failed to find tank pressures for target thrust: {exc}")
537 return
538
539 P_tank_O_psi = P_tank_O_solution * PA_TO_PSI
540 P_tank_F_psi = P_tank_F_solution * PA_TO_PSI
541
542 print_header("SOLUTION")
543 print(f"Required LOX tank pressure : {format_value(P_tank_O_psi, 'psi', 1)}")
544 print(f"Required Fuel tank pressure: {format_value(P_tank_F_psi, 'psi', 1)}")
545 summarize_results(results)
546
547 print_header("DIAGNOSTICS")
548 print(
549 f"Baseline thrust (scale=1.0): {diagnostics['baseline_thrust']:.2f} kN"
550 f" | Achievable range within scales {diagnostics['scale_bounds']}: "
551 f"{diagnostics['min_thrust']:.2f} - {diagnostics['max_thrust']:.2f} kN"
552 )
553
554
555# -----------------------------------------------------------------------------
556# Main loop
557# -----------------------------------------------------------------------------
558
560 runner = load_runner()
561 print_header("PINTLE ENGINE PIPELINE - INTERACTIVE CLI")
562 print(f"Configuration: {CONFIG_PATH}")
563
564 while True:
565 print("\nSelect an option:")
566 print(" 1) Forward mode (tank pressures -> performance)")
567 print(" 2) Inverse mode (target thrust -> tank pressures)")
568 print(" q) Quit")
569 choice = input("Enter choice: ").strip().lower()
570
571 if choice == "1":
572 forward_mode(runner)
573 elif choice == "2":
574 inverse_mode(runner)
575 elif choice in {"q", "quit", "exit"}:
576 print("Exiting interactive CLI.")
577 break
578 else:
579 print("Invalid choice. Please select 1, 2, or q.")
580
581
582if __name__ == "__main__":
583 try:
585 except KeyboardInterrupt:
586 print("\nInterrupted by user. Exiting.")
__init__(self, str message, Dict[str, Any] diagnostics)
float _thrust_difference(float scale, PintleEngineRunner runner, Tuple[float, float] base_pressures, float target_thrust_kN)
Tuple[Tuple[float, float], Dict[str, Any], Dict[str, Any]] solve_for_thrust_and_MR(PintleEngineRunner runner, float target_thrust_kN, float target_MR, Optional[Tuple[float, float]] initial_guess_psi=None, int max_iterations=50, float tolerance=0.01)
str format_value(float value, str unit, int precision=2)
None inverse_mode(PintleEngineRunner runner)
Tuple[Tuple[float, float], Dict[str, Any], Dict[str, Any]] solve_for_thrust(PintleEngineRunner runner, float target_thrust_kN, Tuple[float, float] base_pressures_psi, Tuple[float, float] scale_bounds=(0.2, 5.0), int num_samples=60)
None summarize_results(Dict[str, Any] results)
PintleEngineRunner load_runner()
None forward_mode(PintleEngineRunner runner)