STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
cold_gas_thruster.py
Go to the documentation of this file.
1"""
2Cold Gas Thruster (CGT) analysis module.
3
4Design: 4 CO₂ cold-gas thrusters at the nosecone, sharing the same nozzle
5geometry, firing perpendicular to the rocket's longitudinal axis (±Y, ±Z).
6
7Given a converging-diverging nozzle geometry (throat + exit areas, bell%)
8and an inlet CO₂ stagnation pressure, computes:
9 - Thrust per thruster (momentum + pressure thrust)
10 - Specific impulse
11 - Mass flow rate
12 - Nozzle exit conditions (Mach, temperature, pressure, velocity)
13 - Torque and angular acceleration about the rocket CG
14
15Reuses:
16 - engine.core.mach_solver.solve_mach_robust (isentropic area-Mach solver)
17 - engine.core.nozzle_solver.rao (Rao bell nozzle contour)
18"""
19
20from __future__ import annotations
21
22import numpy as np
23from dataclasses import dataclass, field
24from typing import Optional, Dict, Any
25
26from engine.core.mach_solver import solve_mach_robust
27from engine.core.chamber_geometry import generate_nozzle
28from engine.core.chamber_geometry_solver import solved_chamber_plot
29
30
31# ---------------------------------------------------------------------------
32# Constants
33# ---------------------------------------------------------------------------
34R_UNIVERSAL = 8.314462618 # J/(mol·K)
35G0 = 9.80665 # m/s² standard gravity
36
37# CO₂ ideal-gas defaults
38CO2_GAMMA = 1.289
39CO2_MOLAR_MASS = 0.04401 # kg/mol → R_specific = 188.92 J/(kg·K)
40
41# 12 psi in Pascals
42PSI_TO_PA = 6894.757
43DEFAULT_AMBIENT_PA = 12.0 * PSI_TO_PA # 82 737 Pa
44
45
46# ---------------------------------------------------------------------------
47# Configuration
48# ---------------------------------------------------------------------------
49@dataclass
51 """All inputs for one cold-gas thruster analysis.
52
53 Nozzle geometry
54 ---------------
55 throat_diameter : float — Throat diameter [m]
56 exit_diameter : float — Exit diameter [m]
57 bell_percent : float — Rao bell length fraction (default 0.80)
58 nozzle_method : str — 'garcia' or 'top' (default 'garcia')
59
60 Gas state
61 ---------
62 inlet_pressure : float — CO₂ stagnation (total) pressure [Pa]
63 inlet_temperature : float — CO₂ stagnation temperature [K] (default 293)
64 gamma : float — Specific heat ratio (default 1.289 for CO₂)
65 molar_mass : float — Molar mass [kg/mol] (default 0.04401)
66 nozzle_efficiency : float — Velocity efficiency factor (default 0.95)
67
68 Thruster arrangement
69 --------------------
70 num_thrusters : int — Number of thrusters (default 4)
71 ambient_pressure : float — Back-pressure [Pa] (default 12 psi = 82 737 Pa)
72
73 Rocket geometry (for torque calculation)
74 ----------------------------------------
75 rocket_length : float — Total length tail→nose [m] (default 7.5)
76 rocket_radius : float — Body tube radius [m] (default 0.1015)
77 rocket_mass : float — Total mass [kg] (default 130.0)
78 cg_from_tail : float — CG position from tail [m] (default 3.0)
79 """
80
81 # --- Nozzle geometry (user must fill in) ---
82 throat_diameter: float = 0.0
83 exit_diameter: float = 0.0
84 bell_percent: float = 0.80
85 nozzle_method: str = "top"
86
87 # --- Gas state ---
88 inlet_pressure: float = 0.0 # Pa — user must fill in
89 inlet_temperature: float = 293.0 # K
90 gamma: float = CO2_GAMMA
91 molar_mass: float = CO2_MOLAR_MASS
92 nozzle_efficiency: float = 0.95
93
94 # --- Thruster arrangement ---
95 num_thrusters: int = 4
96 ambient_pressure: float = DEFAULT_AMBIENT_PA # 12 psi
97
98 # --- Rocket geometry ---
99 rocket_length: float = 5.9182 # 233 in
100 rocket_radius: float = 0.1015 # 4 in
101 rocket_mass: float = 63.5029 # 140 lbs
102 cg_from_tail: float = 2.6162 # (233 - 130) = 103 in from tail (moment arm = 130 in)
103
104 # --- Chamber geometry (optional for performance, used for contour) ---
105 chamber_diameter: float = 0.010 # 10 mm
106 chamber_length: float = 0.015 # 15 mm
107
108 # --- Geometry for solved_chamber_plot (purely geometric contour) ---
109 # Set volume_chamber > 0 to use the solved path instead of the hand-rolled fallback.
110 volume_chamber: float = 0.0 # Chamber volume [m³] (0 = not set → auto-compute)
111 Lstar: float = 0.0 # Characteristic length L* [m] (informational)
112
113 # --- Derived (computed at runtime) ---
114 @property
115 def R_specific(self) -> float:
116 """Specific gas constant R = R_universal / molar_mass [J/(kg·K)]."""
117 return R_UNIVERSAL / self.molar_mass
118
119 @property
120 def A_throat(self) -> float:
121 """Throat area [m²]."""
122 return np.pi / 4.0 * self.throat_diameter ** 2
123
124 @property
125 def A_exit(self) -> float:
126 """Exit area [m²]."""
127 return np.pi / 4.0 * self.exit_diameter ** 2
128
129 @property
130 def expansion_ratio(self) -> float:
131 """Area ratio ε = A_exit / A_throat."""
132 if self.A_throat == 0:
133 return 0.0
134 return self.A_exit / self.A_throat
135
136 @property
137 def A_chamber(self) -> float:
138 """Chamber area [m²]."""
139 return np.pi / 4.0 * self.chamber_diameter ** 2
140
141 @property
142 def _effective_volume_chamber(self) -> float:
143 """Chamber volume [m³]. If not explicitly set, estimate from A_chamber × length."""
144 if self.volume_chamber > 0:
145 return self.volume_chamber
146 return self.A_chamber * self.chamber_length
147
148 @property
149 def _use_solved_plot(self) -> bool:
150 """True when the caller has provided enough geometry for solved_chamber_plot."""
151 return self.volume_chamber > 0
152
153 @property
154 def moment_arm(self) -> float:
155 """Distance from nosecone (thruster location) to CG [m]."""
156 return self.rocket_length - self.cg_from_tail
157
158
159# ---------------------------------------------------------------------------
160# Result container
161# ---------------------------------------------------------------------------
162@dataclass
164 """Full output of one CGT analysis."""
165
166 # Per-thruster performance
167 thrust: float = 0.0 # F per thruster [N]
168 mass_flow: float = 0.0 # ṁ per thruster [kg/s]
169 specific_impulse: float = 0.0 # Isp [s]
170 momentum_thrust: float = 0.0 # ṁ·v_exit [N]
171 pressure_thrust: float = 0.0 # (Pe-Pa)·Ae [N]
172
173 # Exit conditions
174 exit_mach: float = 0.0
175 exit_velocity: float = 0.0 # v_exit actual [m/s]
176 exit_temperature: float = 0.0 # T_exit [K]
177 exit_pressure: float = 0.0 # P_exit [Pa]
178 inlet_pressure: float = 0.0 # P0 [Pa]
179 ambient_pressure: float = 0.0 # Pa [Pa]
180
181 # Throat conditions
182 throat_mach: float = 1.0 # Mach number at throat
183 is_choked: bool = True # Whether flow is choked (M=1 at throat)
184 critical_pressure_ratio: float = 0.0
185
186 # Nozzle geometry
187 throat_area: float = 0.0 # A_t [m²]
188 exit_area: float = 0.0 # A_e [m²]
189 expansion_ratio: float = 0.0
190
191 # Chamber geometry
192 chamber_diameter: float = 0.0 # [m]
193 chamber_length: float = 0.0 # [m]
194
195 # Totals (all thrusters)
196 total_thrust: float = 0.0 # N (single axis — 1 thruster at a time)
197 total_mass_flow: float = 0.0 # kg/s if ALL fire
198
199 # Torque / angular dynamics (per single thruster)
200 moment_arm: float = 0.0 # [m]
201 torque: float = 0.0 # τ = F × arm [N·m]
202 moi_estimate: float = 0.0 # I simplified [kg·m²]
203 angular_accel: float = 0.0 # α = τ / I [rad/s²]
204
205 # Nozzle contour (optional)
206 nozzle_contour: Optional[np.ndarray] = None
207
208 def summary(self) -> str:
209 """Human-readable summary of results."""
210 lines = [
211 "=" * 60,
212 " COLD GAS THRUSTER ANALYSIS — CO₂",
213 "=" * 60,
214 "",
215 "── Nozzle Geometry ─────────────────────────────────",
216 f" Throat diameter : {np.sqrt(self.throat_area * 4 / np.pi) * 1e3:.3f} mm",
217 f" Exit diameter : {np.sqrt(self.exit_area * 4 / np.pi) * 1e3:.3f} mm",
218 f" Expansion ratio ε : {self.expansion_ratio:.4f}",
219 f" Throat area : {self.throat_area * 1e6:.4f} mm²",
220 f" Exit area : {self.exit_area * 1e6:.4f} mm²",
221 "",
222 "── Chamber Geometry ────────────────────────────────",
223 f" Chamber diameter : {self.chamber_diameter * 1e3:.3f} mm",
224 f" Chamber length : {self.chamber_length * 1e3:.3f} mm",
225 "",
226 "── Exit Conditions ─────────────────────────────────",
227 f" Exit Mach : {self.exit_mach:.4f}",
228 f" Exit velocity : {self.exit_velocity:.2f} m/s",
229 f" Exit temperature : {self.exit_temperature:.2f} K",
230 f" Exit pressure : {self.exit_pressure:.2f} Pa ({self.exit_pressure / PSI_TO_PA:.2f} psi)",
231 "",
232 "── Throat Flow Check ───────────────────────────────",
233 f" Throat Mach : {self.throat_mach:.4f}",
234 f" Flow is choked : {self.is_choked}",
235 f" Min Pr for choking : {self.critical_pressure_ratio:.4f}",
236 f" Actual Pr (P0/Pa) : {self.inlet_pressure/self.ambient_pressure if self.ambient_pressure > 0 else float('inf'):.4f}",
237 "",
238 "── Per-Thruster Performance ────────────────────────",
239 f" Mass flow rate ṁ : {self.mass_flow * 1e3:.4f} g/s",
240 f" Momentum thrust : {self.momentum_thrust:.4f} N",
241 f" Pressure thrust : {self.pressure_thrust:.4f} N",
242 f" TOTAL THRUST : {self.thrust:.4f} N",
243 f" Specific impulse : {self.specific_impulse:.2f} s",
244 "",
245 "── Torque / Angular Dynamics (single thruster) ────",
246 f" Moment arm (nose→CG): {self.moment_arm:.3f} m",
247 f" Torque : {self.torque:.4f} N·m",
248 f" MOI estimate : {self.moi_estimate:.2f} kg·m²",
249 f" Angular accel α : {self.angular_accel:.6f} rad/s²"
250 f" ({np.degrees(self.angular_accel):.4f} °/s²)",
251 "",
252 "=" * 60,
253 ]
254 return "\n".join(lines)
255
256
257# ---------------------------------------------------------------------------
258# Main analysis class
259# ---------------------------------------------------------------------------
261 """Cold-gas thruster performance analyser.
262
263 Usage
264 -----
265 >>> cfg = ColdGasThrusterConfig(
266 ... throat_diameter=0.005,
267 ... exit_diameter=0.010,
268 ... inlet_pressure=500_000,
269 ... )
270 >>> cgt = ColdGasThruster(cfg)
271 >>> result = cgt.compute()
272 >>> print(result.summary())
273 """
274
275 def __init__(self, config: ColdGasThrusterConfig):
276 self.cfg = config
277
278 # ------------------------------------------------------------------
279 # Core analysis
280 # ------------------------------------------------------------------
281 def compute(self) -> ColdGasThrusterResult:
282 """Run the full isentropic cold-gas analysis and return results."""
283 cfg = self.cfg
284 g = cfg.gamma
285 R = cfg.R_specific
286 P0 = cfg.inlet_pressure
287 T0 = cfg.inlet_temperature
288 A_t = cfg.A_throat
289 A_e = cfg.A_exit
290 eps = cfg.expansion_ratio
291 Pa = cfg.ambient_pressure
292 eta = cfg.nozzle_efficiency
293
294 # -- 1. Exit Mach number from expansion ratio ----------------------
295 M_exit, converged = solve_mach_robust(eps, g, supersonic=True)
296 if not converged:
297 raise RuntimeError(
298 f"Mach solver did not converge for ε={eps:.4f}, γ={g:.4f}"
299 )
300
301 # -- 2. Isentropic exit conditions ---------------------------------
302 # T_exit / T0 = [1 + (γ-1)/2 · M²]⁻¹
303 T_exit = T0 / (1.0 + (g - 1.0) / 2.0 * M_exit ** 2)
304
305 # P_exit / P0 = [1 + (γ-1)/2 · M²]^(-γ/(γ-1))
306 P_exit = P0 * (1.0 + (g - 1.0) / 2.0 * M_exit ** 2) ** (-g / (g - 1.0))
307
308 # v_exit = M · √(γ R T_exit) (ideal), then apply efficiency
309 v_exit_ideal = M_exit * np.sqrt(g * R * T_exit)
310 v_exit = v_exit_ideal * eta
311
312 # -- 3. Throat Flow Check ------------------------------------------
313 # Flow is choked if P0/Pa > ((gamma+1)/2)^(gamma/(gamma-1))
314 critical_pr = ((g + 1.0) / 2.0) ** (g / (g - 1.0))
315 actual_pr = P0 / Pa if Pa > 0 else float('inf')
316 is_choked = actual_pr >= critical_pr
317
318 # -- 4. Mass flow rate ---------------------------------------------
319 # ṁ = P0 · A_t · √(γ / (R·T0)) · [2/(γ+1)]^((γ+1)/(2(γ-1)))
320 mass_flow_factor = (2.0 / (g + 1.0)) ** ((g + 1.0) / (2.0 * (g - 1.0)))
321 mdot = P0 * A_t * np.sqrt(g / (R * T0)) * mass_flow_factor
322
323 # -- 4. Thrust (per thruster) --------------------------------------
324 momentum_thrust = mdot * v_exit
325 pressure_thrust = (P_exit - Pa) * A_e
326 thrust = momentum_thrust + pressure_thrust
327
328 # -- 5. Specific impulse -------------------------------------------
329 Isp = thrust / (mdot * G0) if mdot > 0 else 0.0
330
331 # -- 6. Torque / angular acceleration (single thruster at nosecone)
332 arm = cfg.moment_arm
333 torque = thrust * arm
334
335 # Simplified MOI: slender rod about CM → I = (1/12)·m·L²
336 I_rod = (1.0 / 12.0) * cfg.rocket_mass * cfg.rocket_length ** 2
337 alpha = torque / I_rod if I_rod > 0 else 0.0
338
340 thrust=thrust,
341 mass_flow=mdot,
342 specific_impulse=Isp,
343 momentum_thrust=momentum_thrust,
344 pressure_thrust=pressure_thrust,
345 exit_mach=M_exit,
346 exit_velocity=v_exit,
347 exit_temperature=T_exit,
348 exit_pressure=P_exit,
349 inlet_pressure=P0,
350 ambient_pressure=Pa,
351 throat_mach=1.0 if is_choked else 0.0, # Simplified: assume M=1 if choked
352 is_choked=is_choked,
353 critical_pressure_ratio=critical_pr,
354 throat_area=A_t,
355 exit_area=A_e,
356 expansion_ratio=eps,
357 chamber_diameter=cfg.chamber_diameter,
358 chamber_length=cfg.chamber_length,
359 total_thrust=thrust, # single-axis, one thruster at a time
360 total_mass_flow=mdot * cfg.num_thrusters,
361 moment_arm=arm,
362 torque=torque,
363 moi_estimate=I_rod,
364 angular_accel=alpha,
365 )
366
367 # ------------------------------------------------------------------
368 # Nozzle contour generation
369 # ------------------------------------------------------------------
371 self,
372 steps: int = 200,
373 do_plot: bool = True,
374 color_segments: bool = True,
375 ) -> np.ndarray:
376 """Generate the converging-diverging nozzle + chamber contour.
377
378 Two paths are available:
379
380 **Solved path** (preferred): when ``config.volume_chamber > 0``, calls
381 :func:`engine.core.chamber_geometry_solver.solved_chamber_plot` which is
382 purely geometric — no CEA or thermodynamic iteration required. Requires:
383 ``A_throat``, ``A_exit``, ``volume_chamber``, ``chamber_diameter``, and
384 ``chamber_length`` (``Lstar`` is optional / informational).
385
386 **Fallback path**: when ``volume_chamber`` is not set, the original
387 hand-rolled contour (cylindrical section + 45° contraction + Rao bell) is
388 used for backward-compatibility.
389
390 Parameters
391 ----------
392 steps : int
393 Number of points per contour segment.
394 do_plot : bool
395 Whether to save a plot to ``payload/cgt_nozzle.png``.
396 color_segments : bool
397 Colour-code sections in the plot (fallback path only).
398
399 Returns
400 -------
401 pts : np.ndarray, shape (N, 2)
402 Contour points (axial x [m], radius y [m]).
403 """
404 import matplotlib.pyplot as plt
405 import os
406
407 cfg = self.cfg
408
409 # ------------------------------------------------------------------
410 # Choose contour generation path
411 # ------------------------------------------------------------------
412 if cfg._use_solved_plot:
413 # ── Solved path ────────────────────────────────────────────────
414 # All inputs are purely geometric; no thermodynamics needed.
415 lstar_val = cfg.Lstar if cfg.Lstar > 0 else cfg._effective_volume_chamber / cfg.A_throat
416 pts, _table, _lengths = solved_chamber_plot(
417 area_throat=cfg.A_throat,
418 area_exit=cfg.A_exit,
419 volume_chamber=cfg._effective_volume_chamber,
420 lstar=lstar_val,
421 chamber_diameter=cfg.chamber_diameter,
422 length=cfg.chamber_length,
423 do_plot=False,
424 steps=steps,
425 )
426 else:
427 # ── Fallback path (backward-compatible hand-rolled geometry) ───
428 pts, nozzle_x_first, nozzle_y_first = generate_nozzle(
429 cfg.A_throat, cfg.A_exit, steps=steps
430 )
431
432 r_c = cfg.chamber_diameter / 2.0
433 x_cyl_start = nozzle_x_first + nozzle_y_first - r_c
434
435 x_contraction = np.linspace(x_cyl_start, nozzle_x_first, steps)
436 y_contraction = r_c - x_contraction + x_cyl_start
437
438 x_cyl_end = x_cyl_start - cfg.chamber_length
439 x_cyl = np.linspace(x_cyl_end, x_cyl_start, steps)
440 y_cyl = np.full_like(x_cyl, r_c)
441
442 pts = np.vstack([
443 np.column_stack((x_cyl, y_cyl)),
444 np.column_stack((x_contraction[1:], y_contraction[1:])),
445 pts,
446 ])
447
448 # ------------------------------------------------------------------
449 # Optional plot
450 # ------------------------------------------------------------------
451 if do_plot:
452 x_mm = pts[:, 0] * 1e3
453 y_mm = pts[:, 1] * 1e3
454 x_span = x_mm.max() - x_mm.min()
455 y_span = 2.0 * y_mm.max()
456
457 margin = 0.20
458 fig_w = x_span * (1.0 + 2 * margin)
459 fig_h = y_span * (1.0 + 2 * margin)
460 scale = 10.0 / max(fig_w, fig_h)
461 fig_w_in = max(fig_w * scale, 4.0)
462 fig_h_in = max(fig_h * scale, 2.0)
463
464 fig, ax = plt.subplots(figsize=(fig_w_in, fig_h_in))
465 ax.plot(x_mm, y_mm, "k-", linewidth=2)
466 ax.plot(x_mm, -y_mm, "k-", linewidth=2)
467 ax.axhline(0, color="grey", linewidth=0.5, linestyle="--")
468 ax.set_aspect("equal", adjustable="box")
469 ax.set_xlabel("Axial position [mm]")
470 ax.set_ylabel("Radius [mm]")
471 path_label = "solved" if cfg._use_solved_plot else "hand-rolled"
472 ax.set_title(f"Cold Gas Thruster — Full Contour ({path_label})")
473 ax.grid(True, alpha=0.3)
474
475 vol_str = (
476 f"Vol = {cfg._effective_volume_chamber*1e6:.3f} cm³\n"
477 if cfg._use_solved_plot else ""
478 )
479 info = (
480 f"Chamber ∅ {cfg.chamber_diameter*1e3:.1f} mm\n"
481 f"Throat ∅ {cfg.throat_diameter*1e3:.2f} mm\n"
482 f"Exit ∅ {cfg.exit_diameter*1e3:.2f} mm\n"
483 f"ε = {cfg.expansion_ratio:.2f}\n"
484 + vol_str
485 )
486 ax.text(
487 0.02, 0.98, info,
488 transform=ax.transAxes,
489 fontsize=8, va="top",
490 bbox=dict(boxstyle="round", facecolor="white", alpha=0.85),
491 )
492
493 os.makedirs("payload", exist_ok=True)
494 fig.savefig("payload/cgt_nozzle.png", dpi=150, bbox_inches="tight")
495 plt.close(fig)
496
497 return pts
498
499
500# ---------------------------------------------------------------------------
501# CLI entry-point — run as: python -m payload.cold_gas_thruster
502# ---------------------------------------------------------------------------
503def main():
504 """Example analysis with placeholder inputs."""
505 print("Cold Gas Thruster Analysis")
506 print("=" * 40)
507 print()
508 print("Configure your thruster in ColdGasThrusterConfig.")
509 print("Example usage:")
510 print()
511 print(" from payload import ColdGasThruster, ColdGasThrusterConfig")
512 print()
513 print(" cfg = ColdGasThrusterConfig(")
514 print(" throat_diameter = <your value>, # [m]")
515 print(" exit_diameter = <your value>, # [m]")
516 print(" inlet_pressure = <your value>, # [Pa]")
517 print(" )")
518 print(" cgt = ColdGasThruster(cfg)")
519 print(" result = cgt.compute()")
520 print(" print(result.summary())")
521 print()
522
523 # --- Quick demo with sample values (remove / replace as needed) ---
524 demo_cfg = ColdGasThrusterConfig(
525 throat_diameter=0.005, # 3 mm throat
526 exit_diameter=0.015, # 8 mm exit
527 inlet_pressure=800_000, # 800 kPa (~116 psi)
528 chamber_diameter=0.012, # 12 mm
529 chamber_length=0.020, # 20 mm
530 volume_chamber=np.pi / 4 * 0.012**2 * 0.020, # ← enables solved path
531 )
532 cgt = ColdGasThruster(demo_cfg)
533 result = cgt.compute()
534 print(result.summary())
535
536 # Generate nozzle contour plot
537 print(f"\nGenerating nozzle contour plot...")
538 pts = cgt.generate_nozzle_contour(do_plot=True)
539 print(f"Nozzle contour saved to payload/cgt_nozzle.png ({len(pts)} points)")
540
541 # Try to open/show the plot
542 import matplotlib.pyplot as plt
543 print("Opening plot window...")
544 plt.show()
545
546
547if __name__ == "__main__":
548 main()
#define max(a, b)
Definition Arduino.h:43
np.ndarray generate_nozzle_contour(self, int steps=200, bool do_plot=True, bool color_segments=True)
__init__(self, ColdGasThrusterConfig config)