STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
validate_chamber.py
Go to the documentation of this file.
1"""Validate chamber intrinsics against Huzel and Huang reference data"""
2
3import numpy as np
4import matplotlib.pyplot as plt
5import sys
6from pathlib import Path
7
8sys.path.insert(0, str(Path(__file__).parent.parent))
9
10from engine.pipeline.io import load_config
11from engine.core.runner import PintleEngineRunner
13 eta_cstar,
14 calculate_Lstar,
15 calculate_actual_chamber_temp,
16 calculate_frozen_flow_correction
17)
18
19# Load configuration
20config_path = Path(__file__).parent.parent / "configs" / "default.yaml"
21config = load_config(str(config_path))
22
23# Initialize runner
24runner = PintleEngineRunner(config)
25
26print("=" * 70)
27print("VALIDATING CHAMBER INTRINSICS vs HUZEL & HUANG")
28print("=" * 70)
29
30# Test chamber pressures (typical range)
31chamber_pressures = [300, 500, 700] # psia (will convert to Pa)
32
33# Get actual MR range from cache (don't plot outside data range!)
34cache = runner.solver.cea_cache
35MR_min = cache.MR_min
36MR_max = cache.MR_max
37print(f"Cache MR range: {MR_min:.2f} to {MR_max:.2f}")
38
39# Mixture ratio range (only within cache bounds)
40MR_range = np.linspace(MR_min, MR_max, 30)
41
42# Universal gas constant
43R_UNIVERSAL = 8314.462618 # J/(kmol·K)
44
45# Calculate L* for chamber-driven corrections (use config value if provided)
46from engine.pipeline.config_schemas import ensure_chamber_geometry
47cg = ensure_chamber_geometry(config)
48Lstar = calculate_Lstar(
49 cg.volume,
50 cg.A_throat,
51 Lstar_override=cg.Lstar
52)
53print(f"L* = {Lstar:.3f} m (for chamber-driven corrections)")
54if cg.Lstar is not None:
55 print(f" (Using L* from config: {cg.Lstar:.3f} m)")
56else:
57 print(f" (Calculated from V={cg.volume:.6f} m³, At={cg.A_throat:.6f} m²)")
58
59# Storage for results at different chamber pressures
60results_by_Pc = {}
61
62for Pc_psia in chamber_pressures:
63 Pc_Pa = Pc_psia * 6894.76 # Convert to Pa
64
65 Tc_ideal_values = []
66 Tc_actual_values = []
67 cstar_ideal_values = []
68 cstar_actual_values = []
69 gamma_ideal_values = []
70 gamma_actual_values = []
71 R_values = []
72 M_values = [] # Molecular weight
73 eta_values = []
74
75 print(f"\nEvaluating at Pc = {Pc_psia} psia ({Pc_Pa/1e6:.2f} MPa)...")
76
77 for MR in MR_range:
78 try:
79 # Get IDEAL CEA properties (infinite-area equilibrium)
80 cea_props = runner.solver.cea_cache.eval(MR, Pc_Pa)
81
82 Tc_ideal = cea_props['Tc'] # K
83 cstar_ideal = cea_props['cstar_ideal'] # m/s
84 gamma_ideal = cea_props['gamma']
85 R = cea_props['R'] # J/(kg·K)
86
87 # Apply CHAMBER-DRIVEN corrections
88 # 1. Combustion efficiency (L* correction)
89 # Build minimal advanced_params for validation
90 from engine.pipeline.constants import DEFAULT_TURBULENCE_INTENSITY_ND
91 advanced_params = {
92 "Pc": Pc_Pa,
93 "Tc": Tc_ideal,
94 "cstar_ideal": cstar_ideal,
95 "gamma": gamma_ideal,
96 "R": R,
97 "MR": MR,
98 "Ac": cg.area_cross,
99 "At": cg.A_throat,
100 "chamber_length": cg.length,
101 "Dinj": 0.002, # Typical injector diameter for validation
102 "m_dot_total": 3.0, # Typical mass flow for validation
103 "spray_diagnostics": None,
104 "turbulence_intensity": DEFAULT_TURBULENCE_INTENSITY_ND,
105 }
106 eta = eta_cstar(
107 Lstar,
108 config.combustion.efficiency,
109 cooling_efficiency=1.0, # No cooling losses for validation
110 advanced_params=advanced_params,
111 )
112
113 # 2. Actual c* accounting for finite chamber
114 cstar_actual = eta * cstar_ideal
115
116 # 3. Actual chamber temperature (incomplete combustion)
117 Tc_actual = calculate_actual_chamber_temp(
118 Tc_ideal,
119 eta,
120 gamma_ideal
121 )
122
123 # 4. Frozen flow correction for gamma
124 gamma_frozen_factor = calculate_frozen_flow_correction(Lstar, gamma_ideal)
125 gamma_actual = gamma_ideal * gamma_frozen_factor
126
127 # Store both ideal and actual values
128 Tc_ideal_values.append(Tc_ideal)
129 Tc_actual_values.append(Tc_actual)
130 cstar_ideal_values.append(cstar_ideal)
131 cstar_actual_values.append(cstar_actual)
132 gamma_ideal_values.append(gamma_ideal)
133 gamma_actual_values.append(gamma_actual)
134 R_values.append(R)
135 eta_values.append(eta)
136
137 # Calculate molecular weight from R
138 # R = R_universal / M → M = R_universal / R
139 M = R_UNIVERSAL / R # kg/kmol
140 M_values.append(M)
141
142 except Exception as e:
143 print(f" Warning: Failed at MR={MR:.2f}: {e}")
144 Tc_ideal_values.append(np.nan)
145 Tc_actual_values.append(np.nan)
146 cstar_ideal_values.append(np.nan)
147 cstar_actual_values.append(np.nan)
148 gamma_ideal_values.append(np.nan)
149 gamma_actual_values.append(np.nan)
150 R_values.append(np.nan)
151 M_values.append(np.nan)
152 eta_values.append(np.nan)
153
154 results_by_Pc[Pc_psia] = {
155 'Tc_ideal': np.array(Tc_ideal_values),
156 'Tc_actual': np.array(Tc_actual_values),
157 'cstar_ideal': np.array(cstar_ideal_values),
158 'cstar_actual': np.array(cstar_actual_values),
159 'gamma_ideal': np.array(gamma_ideal_values),
160 'gamma_actual': np.array(gamma_actual_values),
161 'R': np.array(R_values),
162 'M': np.array(M_values),
163 'eta': np.array(eta_values)
164 }
165
166# Convert to imperial units for comparison with Huzel & Huang
167print("\nConverting to imperial units...")
168
169for Pc_psia in chamber_pressures:
170 results = results_by_Pc[Pc_psia]
171
172 # Temperature: K → °F (both ideal and actual)
173 results['Tc_ideal_F'] = (results['Tc_ideal'] - 273.15) * 9/5 + 32
174 results['Tc_actual_F'] = (results['Tc_actual'] - 273.15) * 9/5 + 32
175
176 # Characteristic velocity: m/s → ft/s (both ideal and actual)
177 results['cstar_ideal_fps'] = results['cstar_ideal'] * 3.28084
178 results['cstar_actual_fps'] = results['cstar_actual'] * 3.28084
179
180 # Molecular weight: kg/kmol → lb/lbmol (same numerically)
181 results['M_lb_lbmol'] = results['M']
182
183 # R: J/(kg·K) → ft·lbf/(lbm·°R)
184 # R_imperial = R_SI * 0.0053566
185 results['R_imperial'] = results['R'] * 0.0053566
186
187# Create validation plots
188fig, axes = plt.subplots(3, 2, figsize=(16, 14))
189fig.suptitle("Chamber Intrinsics Validation vs Huzel & Huang (LOX/RP-1)",
190 fontsize=16, fontweight="bold")
191
192# Plot 1: Combustion Chamber Temperature (ACTUAL - chamber-driven)
193ax = axes[0, 0]
194for Pc_psia in chamber_pressures:
195 results = results_by_Pc[Pc_psia]
196 # Only plot valid (non-NaN) data - use ACTUAL (chamber-driven) values
197 valid_mask = ~np.isnan(results['Tc_actual_F'])
198 if np.any(valid_mask):
199 ax.plot(MR_range[valid_mask], results['Tc_actual_F'][valid_mask],
200 linewidth=2, marker='o', markersize=4,
201 label=f'Pc = {Pc_psia} psia (actual)')
202 # Also show ideal as dashed line for comparison
203 ideal_mask = ~np.isnan(results['Tc_ideal_F'])
204 if np.any(ideal_mask):
205 ax.plot(MR_range[ideal_mask], results['Tc_ideal_F'][ideal_mask],
206 linewidth=1, linestyle='--', alpha=0.5,
207 label=f'Pc = {Pc_psia} psia (ideal CEA)')
208ax.set_xlabel('Mixture Ratio (O/F)')
209ax.set_ylabel('Chamber Temperature [°F]')
210ax.set_title('Chamber Temperature (ACTUAL - chamber-driven)')
211ax.grid(True, alpha=0.3)
212ax.legend(fontsize=8)
213ax.set_xlim(MR_min, MR_max)
214ax.set_ylim(3000, 7000)
215# Add reference annotation
216ax.text(0.05, 0.95, 'H&H Peak: ~6000°F @ MR≈2.75',
217 transform=ax.transAxes, fontsize=10, verticalalignment='top',
218 bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
219
220# Plot 2: Characteristic Velocity (c*) - ACTUAL
221ax = axes[0, 1]
222for Pc_psia in chamber_pressures:
223 results = results_by_Pc[Pc_psia]
224 # Only plot valid (non-NaN) data - use ACTUAL (chamber-driven) values
225 valid_mask = ~np.isnan(results['cstar_actual_fps'])
226 if np.any(valid_mask):
227 ax.plot(MR_range[valid_mask], results['cstar_actual_fps'][valid_mask],
228 linewidth=2, marker='o', markersize=4,
229 label=f'Pc = {Pc_psia} psia (actual)')
230 # Also show ideal as dashed line for comparison
231 ideal_mask = ~np.isnan(results['cstar_ideal_fps'])
232 if np.any(ideal_mask):
233 ax.plot(MR_range[ideal_mask], results['cstar_ideal_fps'][ideal_mask],
234 linewidth=1, linestyle='--', alpha=0.5,
235 label=f'Pc = {Pc_psia} psia (ideal CEA)')
236ax.set_xlabel('Mixture Ratio (O/F)')
237ax.set_ylabel('Characteristic Velocity [ft/s]')
238ax.set_title('Characteristic Velocity (c* - ACTUAL)')
239ax.grid(True, alpha=0.3)
240ax.legend(fontsize=8)
241ax.set_xlim(MR_min, MR_max)
242ax.set_ylim(5000, 6000)
243ax.text(0.05, 0.95, 'H&H Peak: ~5850 ft/s @ MR≈2.25',
244 transform=ax.transAxes, fontsize=10, verticalalignment='top',
245 bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
246
247# Plot 3: Specific Heat Ratio (gamma) - ACTUAL
248ax = axes[1, 0]
249for Pc_psia in chamber_pressures:
250 results = results_by_Pc[Pc_psia]
251 # Only plot valid (non-NaN) data - use ACTUAL (frozen flow corrected) values
252 valid_mask = ~np.isnan(results['gamma_actual'])
253 if np.any(valid_mask):
254 ax.plot(MR_range[valid_mask], results['gamma_actual'][valid_mask],
255 linewidth=2, marker='o', markersize=4,
256 label=f'Pc = {Pc_psia} psia (actual)')
257 # Also show ideal as dashed line for comparison
258 ideal_mask = ~np.isnan(results['gamma_ideal'])
259 if np.any(ideal_mask):
260 ax.plot(MR_range[ideal_mask], results['gamma_ideal'][ideal_mask],
261 linewidth=1, linestyle='--', alpha=0.5,
262 label=f'Pc = {Pc_psia} psia (ideal CEA)')
263ax.set_xlabel('Mixture Ratio (O/F)')
264ax.set_ylabel('Specific Heat Ratio (gamma)')
265ax.set_title('Specific Heat Ratio (gamma - ACTUAL with frozen flow)')
266ax.grid(True, alpha=0.3)
267ax.legend(fontsize=8)
268ax.set_xlim(MR_min, MR_max)
269ax.set_ylim(1.15, 1.30)
270ax.text(0.05, 0.95, 'H&H Range: 1.28→1.21 (decreasing)',
271 transform=ax.transAxes, fontsize=10, verticalalignment='top',
272 bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
273
274# Plot 4: Molecular Weight
275ax = axes[1, 1]
276for Pc_psia in chamber_pressures:
277 results = results_by_Pc[Pc_psia]
278 # Only plot valid (non-NaN) data
279 valid_mask = ~np.isnan(results['M_lb_lbmol'])
280 if np.any(valid_mask):
281 ax.plot(MR_range[valid_mask], results['M_lb_lbmol'][valid_mask],
282 linewidth=2, marker='o', markersize=4,
283 label=f'Pc = {Pc_psia} psia')
284ax.set_xlabel('Mixture Ratio (O/F)')
285ax.set_ylabel('Molecular Weight [lb/lbmol]')
286ax.set_title('Molecular Weight of Combustion Products (M)')
287ax.grid(True, alpha=0.3)
288ax.legend()
289ax.set_xlim(MR_min, MR_max)
290ax.set_ylim(15, 30)
291ax.text(0.05, 0.95, 'H&H Range: 17.5→24.5 (increasing)',
292 transform=ax.transAxes, fontsize=10, verticalalignment='top',
293 bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
294
295# Plot 5: Gas Constant R (SI units)
296ax = axes[2, 0]
297for Pc_psia in chamber_pressures:
298 results = results_by_Pc[Pc_psia]
299 # Only plot valid (non-NaN) data
300 valid_mask = ~np.isnan(results['R'])
301 if np.any(valid_mask):
302 ax.plot(MR_range[valid_mask], results['R'][valid_mask],
303 linewidth=2, marker='o', markersize=4,
304 label=f'Pc = {Pc_psia} psia')
305ax.set_xlabel('Mixture Ratio (O/F)')
306ax.set_ylabel('Gas Constant R [J/(kg·K)]')
307ax.set_title('Gas Constant (R = R_universal / M)')
308ax.grid(True, alpha=0.3)
309ax.legend()
310ax.set_xlim(MR_min, MR_max)
311
312# Plot 6: Summary Table at MR = 2.2 (target)
313ax = axes[2, 1]
314ax.axis('off')
315
316# Create comparison table
317MR_target = 2.2
318idx_target = np.argmin(np.abs(MR_range - MR_target))
319
320table_data = []
321table_data.append(['Parameter', 'Our Model', 'H&H Ref', 'Units'])
322table_data.append(['─' * 15, '─' * 12, '─' * 12, '─' * 10])
323
324# Get values at MR = 2.2, Pc = 500 psia (ACTUAL chamber-driven)
325results_500 = results_by_Pc[500]
326Tc_actual_F = results_500['Tc_actual_F'][idx_target]
327cstar_actual_fps = results_500['cstar_actual_fps'][idx_target]
328gamma_actual = results_500['gamma_actual'][idx_target]
329M = results_500['M_lb_lbmol'][idx_target]
330eta = results_500['eta'][idx_target]
331
332table_data.append(['Tc (actual)', f'{Tc_actual_F:.0f}', '~5800', '°F'])
333table_data.append(['c* (actual)', f'{cstar_actual_fps:.0f}', '~5850', 'ft/s'])
334table_data.append(['gamma (actual)', f'{gamma_actual:.3f}', '~1.23', '-'])
335table_data.append(['M', f'{M:.1f}', '~22', 'lb/lbmol'])
336table_data.append(['eta_c*', f'{eta:.3f}', '-', '-'])
337
338# Format table
339table_text = 'Comparison at MR = 2.2, Pc = 500 psia\n\n'
340for row in table_data:
341 table_text += f'{row[0]:15s} {row[1]:>12s} {row[2]:>12s} {row[3]:>10s}\n'
342
343ax.text(0.1, 0.5, table_text, fontsize=11, family='monospace',
344 verticalalignment='center',
345 bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.3))
346
347plt.tight_layout()
348output_path = Path(__file__).parent / "chamber_intrinsics_validation.png"
349plt.savefig(str(output_path), dpi=300, bbox_inches='tight')
350print(f"\n[OK] Saved validation plots to {output_path}")
351
352# Print summary statistics
353print("\n" + "=" * 70)
354print("VALIDATION SUMMARY (at Pc = 500 psia, MR = 2.2)")
355print("=" * 70)
356
357results_500 = results_by_Pc[500]
358idx = np.argmin(np.abs(MR_range - 2.2))
359
360print(f"\nOur Model (CHAMBER-DRIVEN - actual):")
361print(f" Tc_actual = {results_500['Tc_actual'][idx]:.1f} K ({results_500['Tc_actual_F'][idx]:.0f} °F)")
362print(f" c*_actual = {results_500['cstar_actual'][idx]:.1f} m/s ({results_500['cstar_actual_fps'][idx]:.0f} ft/s)")
363print(f" gamma_actual = {results_500['gamma_actual'][idx]:.4f}")
364print(f" M = {results_500['M'][idx]:.2f} kg/kmol")
365print(f" R = {results_500['R'][idx]:.2f} J/(kg·K)")
366print(f" eta_c* = {results_500['eta'][idx]:.4f}")
367print(f"\n (Ideal CEA for comparison:)")
368print(f" Tc_ideal = {results_500['Tc_ideal'][idx]:.1f} K ({results_500['Tc_ideal_F'][idx]:.0f} °F)")
369print(f" c*_ideal = {results_500['cstar_ideal'][idx]:.1f} m/s ({results_500['cstar_ideal_fps'][idx]:.0f} ft/s)")
370print(f" gamma_ideal = {results_500['gamma_ideal'][idx]:.4f}")
371
372print(f"\nHuzel & Huang Reference (approximate from graph):")
373print(f" Tc approx 5800 degF (peak ~6000 degF @ MR approx 2.75)")
374print(f" c* approx 5850 ft/s (peak @ MR approx 2.25)")
375print(f" gamma approx 1.23 (decreasing with MR)")
376print(f" M approx 22 lb/lbmol (increasing with MR)")
377
378print("\n[OK] Validation complete!")
379print("=" * 70)
380