26 chamber_diameter: Optional[float] =
None,
27 volume: Optional[float] =
None,
29 """Static pressure from the injector face to the throat, quasi-one-dimensional.
31 Pc is the injector-end pressure (u ~ 0 there). Constant-area heat addition carries the
32 flow to the chamber-end Mach number M_c with p + rho u^2 conserved, so the combustor-end
33 static pressure is Pc / (1 + gamma M_c^2); the nozzle stagnation pressure is Pc / kappa
34 (Rayleigh, engine.core.nozzle.nozzle_stagnation_loss) and the convergent is isentropic
35 from it. How the heat is released along the barrel is not modelled, so the barrel is
36 given at its two ends only. Without ``chamber_diameter`` the chamber is infinite-area
37 (kappa = 1) and only the face and throat are returned.
39 positions are distances from the injector face [m].
45 crit = (2.0 / (g + 1.0)) ** (g / (g - 1.0))
46 if not chamber_diameter
or chamber_diameter <= 0:
48 return {
"positions": [0.0, float(
"nan")],
"pressures": [float(Pc), float(P_throat)],
49 "P_injection": float(Pc),
"P_mid": float(
"nan"),
"P_throat": float(P_throat),
50 "infinite_area":
True}
51 V = volume
if volume
else Lstar * A_throat
52 c = wall_contour(A_throat, chamber_diameter, V, n_chamber=
max(int(n_points), 24))
53 CR = (0.5 * chamber_diameter) ** 2 * np.pi / A_throat
54 kappa = nozzle_stagnation_loss(CR, g)
55 M_c = mach_from_area_ratio(CR, g,
False)
57 conv = (c.x >= c.x_cone_start - 1e-12) & (c.x < -1e-9)
58 x_conv = np.append(c.x[conv], 0.0)
59 r_conv = np.append(c.r[conv], c.R_t)
60 M = mach_from_area_ratio(np.maximum((r_conv / c.R_t) ** 2, 1.0), g, np.zeros_like(r_conv, dtype=bool))
61 p_conv = P0 * (1.0 + 0.5 * (g - 1.0) * M * M) ** (-g / (g - 1.0))
62 positions = np.concatenate([[0.0], x_conv - c.x_face])
63 pressures = np.concatenate([[Pc], p_conv])
65 "positions": positions.tolist(),
66 "pressures": pressures.tolist(),
67 "P_injection": float(Pc),
68 "P_combustor_end": float(Pc / (1.0 + g * M_c * M_c)),
69 "P_mid": float(Pc / (1.0 + g * M_c * M_c)),
70 "P_throat": float(p_conv[-1]),
71 "P0_nozzle": float(P0),
72 "stagnation_loss_kappa": float(kappa),
73 "infinite_area":
False,
87 P_back: Optional[float] =
None,
88 A_chamber: Optional[float] =
None,
91 Calculate chamber intrinsic properties.
98 Chamber temperature [K]
100 Total mass flow [kg/s]
104 Gas constant [J/(kg·K)]
110 Characteristic length [m]
113 P_back : float, optional
114 Back pressure [Pa] (ambient pressure). If None, uses 0.9 * 101325 Pa as fallback.
115 Used to verify flow is choked at throat.
120 - Lstar: Characteristic length [m]
121 - residence_time: Gas residence time [s]
122 - velocity_mean: Mean gas velocity [m/s]
123 - velocity_throat: Velocity at throat (sonic) [m/s]
124 - density: Chamber gas density [kg/m³]
125 - sound_speed: Sound speed in chamber [m/s]
126 - mach_number: Mean Mach number
127 - reynolds_number: Reynolds number
130 rho, rho_valid = NumericalStability.safe_divide(Pc, R * Tc, 1.0,
"rho")
131 if not rho_valid.passed:
135 sound_speed_squared = gamma * R * Tc
136 sound_speed, sound_valid = NumericalStability.safe_sqrt(sound_speed_squared,
"sound_speed")
137 if not sound_valid.passed:
152 if A_chamber
is not None and np.isfinite(A_chamber)
and A_chamber > 0:
153 A_avg = float(A_chamber)
157 A_avg = A_throat * 3.0
159 velocity_mean, v_valid = NumericalStability.safe_divide(
160 mdot_total, rho * A_avg, 0.0,
"velocity_mean"
162 if not v_valid.passed:
176 throat_velocity_factor, factor_valid = NumericalStability.safe_divide(
177 gamma, gamma + 1.0, 0.5,
"gamma/(gamma+1)"
179 if factor_valid.passed:
182 throat_velocity_squared = 2.0 * gamma * R * Tc / (gamma + 1.0)
183 velocity_throat, throat_v_valid = NumericalStability.safe_sqrt(
184 throat_velocity_squared,
"velocity_throat"
186 if not throat_v_valid.passed:
187 velocity_throat = sound_speed
189 velocity_throat = sound_speed
192 T_throat = Tc * (2.0 / (gamma + 1.0))
193 a_throat_squared = gamma * R * T_throat
194 a_throat, a_throat_valid = NumericalStability.safe_sqrt(a_throat_squared,
"a_throat")
195 if not a_throat_valid.passed:
196 a_throat = sound_speed * np.sqrt(2.0 / (gamma + 1.0))
200 M_throat_verified, M_verify_valid = NumericalStability.safe_divide(
201 velocity_throat, a_throat, 1.0,
"M_throat_verified"
203 if not M_verify_valid.passed:
204 M_throat_verified = 1.0
207 if abs(M_throat_verified - 1.0) > 1e-6:
210 f
"Throat Mach verification failed: M_throat_verified = {M_throat_verified:.8f} "
211 f
"(expected 1.0). Deviation: {abs(M_throat_verified - 1.0):.2e}. "
212 f
"This may indicate numerical precision issues.",
219 P_back_used = P_back
if P_back
is not None else 0.9 * 101325.0
220 pressure_ratio_exponent = gamma / (gamma - 1.0)
221 pressure_ratio_base = 2.0 / (gamma + 1.0)
222 critical_pressure_ratio = pressure_ratio_base ** pressure_ratio_exponent
225 actual_pressure_ratio =
None
227 actual_pressure_ratio = P_back_used / Pc
229 is_choked = actual_pressure_ratio <= critical_pressure_ratio
234 f
"Flow may not be choked: P_back/Pc = {actual_pressure_ratio:.6f} > "
235 f
"critical ratio = {critical_pressure_ratio:.6f}. "
236 f
"Throat Mach number may be < 1.0. "
237 f
"P_back = {P_back_used:.2e} Pa, Pc = {Pc:.2e} Pa.",
249 mach_number, mach_valid = NumericalStability.safe_divide(
250 velocity_mean, sound_speed, 0.01,
"mach_number_chamber"
252 if not mach_valid.passed:
257 residence_time, tau_valid = NumericalStability.safe_divide(
258 V_chamber * rho, mdot_total, 0.001,
"residence_time"
260 if not tau_valid.passed:
261 residence_time = 0.001
269 viscosity = mu_ref * ((Tc / T_ref) ** 1.5) * ((T_ref + S) / (Tc + S))
272 reynolds_number, Re_valid = NumericalStability.safe_divide(
273 rho * velocity_mean * Lstar, viscosity, 1000.0,
"reynolds_number"
275 if not Re_valid.passed:
276 reynolds_number = 10000.0
279 residence_time =
max(residence_time, 1e-6)
284 mach_number =
max(
min(mach_number, 0.99), 0.001)
285 reynolds_number =
max(reynolds_number, 100.0)
288 "Lstar": float(Lstar),
289 "residence_time": float(residence_time),
290 "velocity_mean": float(velocity_mean),
291 "velocity_throat": float(velocity_throat),
292 "density": float(rho),
293 "sound_speed": float(sound_speed),
294 "mach_number": float(mach_number),
295 "mach_number_chamber": float(mach_number),
296 "mach_number_throat": 1.0,
297 "mach_number_throat_verified": float(M_throat_verified),
298 "is_choked": bool(is_choked),
299 "critical_pressure_ratio": float(critical_pressure_ratio),
300 "actual_pressure_ratio": float(actual_pressure_ratio)
if actual_pressure_ratio
is not None else None,
301 "reynolds_number": float(reynolds_number),
302 "viscosity": float(viscosity),
303 "volume": float(V_chamber),
304 "A_throat": float(A_throat),
305 "A_avg": float(A_avg),
311 D_chamber_initial: float,
312 ablative_config: AblativeCoolingConfig,
313 recession_thickness_chamber: float = 0.0,
317 Calculate ablative liner geometry profile along chamber length.
323 D_chamber_initial : float
324 Initial chamber inner diameter [m] (before ablative)
325 ablative_config : AblativeCoolingConfig
326 Ablative configuration
327 recession_thickness_chamber : float
328 Cumulative recession thickness [m] (default: 0.0)
330 Number of points along chamber (default: 50)
335 - positions: Array of positions along chamber [m]
336 - D_chamber_inner: Inner diameter (gas side) [m]
337 - D_ablative_outer: Outer diameter (ablative surface) [m]
338 - ablative_thickness: Ablative thickness at each position [m]
339 - recession: Recession at each position [m]
341 if not ablative_config.enabled:
343 "positions": np.linspace(0, L_chamber, n_points).tolist(),
344 "D_chamber_inner": [D_chamber_initial] * n_points,
345 "D_ablative_outer": [D_chamber_initial] * n_points,
346 "ablative_thickness": [0.0] * n_points,
347 "recession": [0.0] * n_points,
350 positions = np.linspace(0.0, L_chamber, n_points)
353 ablative_thickness_current =
max(
354 ablative_config.initial_thickness - recession_thickness_chamber,
361 D_chamber_inner = D_chamber_initial + 2.0 * recession_thickness_chamber * ablative_config.coverage_fraction
365 D_ablative_outer = D_chamber_inner + 2.0 * ablative_thickness_current
369 recession_array = np.full(n_points, recession_thickness_chamber)
370 ablative_thickness_array = np.full(n_points, ablative_thickness_current)
373 "positions": positions.tolist(),
374 "D_chamber_inner": (D_chamber_inner * np.ones(n_points)).tolist(),
375 "D_ablative_outer": (D_ablative_outer * np.ones(n_points)).tolist(),
376 "ablative_thickness": ablative_thickness_array.tolist(),
377 "recession": recession_array.tolist(),
382 D_throat_initial: float,
383 graphite_config: GraphiteInsertConfig,
384 recession_thickness_graphite: float = 0.0,
385) -> Dict[str, float]:
387 Calculate graphite insert geometry at throat.
391 D_throat_initial : float
392 Initial throat diameter [m] (defined by graphite insert)
393 graphite_config : GraphiteInsertConfig
394 Graphite configuration
395 recession_thickness_graphite : float
396 Cumulative graphite recession [m] (default: 0.0)
401 - D_throat_current: Current throat diameter [m] (grows with recession)
402 - D_graphite_outer: Outer diameter of graphite insert [m]
403 - graphite_thickness_remaining: Remaining graphite thickness [m]
404 - graphite_thickness_initial: Initial graphite thickness [m]
405 - recession: Total recession [m]
407 if not graphite_config.enabled:
409 "D_throat_current": D_throat_initial,
410 "D_graphite_outer": D_throat_initial,
411 "graphite_thickness_remaining": 0.0,
412 "graphite_thickness_initial": 0.0,
419 D_throat_current = D_throat_initial + 2.0 * recession_thickness_graphite * graphite_config.coverage_fraction
423 graphite_thickness_remaining =
max(
424 graphite_config.initial_thickness - recession_thickness_graphite,
430 D_graphite_outer = D_throat_current + 2.0 * graphite_thickness_remaining
433 "D_throat_current": float(D_throat_current),
434 "D_graphite_outer": float(D_graphite_outer),
435 "graphite_thickness_remaining": float(graphite_thickness_remaining),
436 "graphite_thickness_initial": float(graphite_config.initial_thickness),
437 "recession": float(recession_thickness_graphite),
445 D_chamber_initial: float,
446 D_throat_initial: float,
447 ablative_config: Optional[AblativeCoolingConfig] =
None,
448 graphite_config: Optional[GraphiteInsertConfig] =
None,
449 stainless_config: Optional[StainlessSteelCaseConfig] =
None,
450 recession_chamber: float = 0.0,
451 recession_graphite: float = 0.0,
455 Calculate complete chamber geometry including ablative, graphite, and stainless steel.
457 This provides a comprehensive view of the multi-layer wall structure:
458 - Hot gas → Phenolic ablator → Stainless steel case (chamber)
459 - Hot gas → Graphite insert → Stainless steel case (throat)
464 Current chamber volume [m³]
466 Current throat area [m²]
469 D_chamber_initial : float
470 Initial chamber inner diameter [m]
471 D_throat_initial : float
472 Initial throat diameter [m]
473 ablative_config : AblativeCoolingConfig, optional
474 Ablative configuration
475 graphite_config : GraphiteInsertConfig, optional
476 Graphite configuration
477 stainless_config : StainlessSteelCaseConfig, optional
478 Stainless steel case configuration
479 recession_chamber : float
480 Cumulative ablative recession [m]
481 recession_graphite : float
482 Cumulative graphite recession [m]
484 Number of points along chamber (default: 50)
489 Complete geometry profile including:
490 - positions: Array of positions [m]
491 - D_gas_chamber: Gas-side diameter (chamber) [m]
492 - D_ablative_outer: Ablative outer diameter [m]
493 - D_stainless_outer: Stainless steel outer diameter [m]
494 - D_throat_current: Current throat diameter [m]
495 - D_graphite_outer: Graphite outer diameter [m]
496 - ablative_thickness: Ablative thickness [m]
497 - graphite_thickness: Graphite thickness [m]
498 - stainless_thickness: Stainless steel thickness [m]
502 D_chamber_current = np.sqrt(
max(0, 4.0 * V_chamber / (np.pi * L_chamber)))
if L_chamber > 0
else D_chamber_initial
503 D_throat_current = np.sqrt(
max(0, 4.0 * A_throat / np.pi))
if A_throat > 0
else 0.015
505 positions = np.linspace(0.0, L_chamber, n_points)
508 if ablative_config
and ablative_config.enabled:
509 ablative_thickness =
max(
510 ablative_config.initial_thickness - recession_chamber,
513 D_gas_chamber = D_chamber_initial + 2.0 * recession_chamber * ablative_config.coverage_fraction
514 D_ablative_outer = D_gas_chamber + 2.0 * ablative_thickness
516 ablative_thickness = 0.0
517 D_gas_chamber = D_chamber_current
518 D_ablative_outer = D_gas_chamber
521 if stainless_config
and stainless_config.enabled:
522 stainless_thickness = stainless_config.thickness
523 D_stainless_outer = D_ablative_outer + 2.0 * stainless_thickness
525 stainless_thickness = 0.0
526 D_stainless_outer = D_ablative_outer
531 graphite_config
if graphite_config
else type(
'obj', (object,), {
'enabled':
False})(),
535 D_throat_current = graphite_geometry[
"D_throat_current"]
536 D_graphite_outer = graphite_geometry[
"D_graphite_outer"]
537 graphite_thickness = graphite_geometry[
"graphite_thickness_remaining"]
540 if stainless_config
and stainless_config.enabled:
541 D_stainless_throat_outer = D_graphite_outer + 2.0 * stainless_thickness
543 D_stainless_throat_outer = D_graphite_outer
546 "positions": positions.tolist(),
547 "D_gas_chamber": (D_gas_chamber * np.ones(n_points)).tolist(),
548 "D_ablative_outer": (D_ablative_outer * np.ones(n_points)).tolist(),
549 "D_stainless_outer": (D_stainless_outer * np.ones(n_points)).tolist(),
550 "D_throat_current": float(D_throat_current),
551 "D_graphite_outer": float(D_graphite_outer),
552 "D_stainless_throat_outer": float(D_stainless_throat_outer),
553 "ablative_thickness": (ablative_thickness * np.ones(n_points)).tolist(),
554 "graphite_thickness": float(graphite_thickness),
555 "stainless_thickness": float(stainless_thickness),
556 "recession_chamber": float(recession_chamber),
557 "recession_graphite": float(recession_graphite),
562 geometry: Dict[str, Any],
563 pressure_profile: Optional[Dict[str, Any]] =
None,
564 save_path: Optional[str] =
None,
565 show_plot: bool =
True,
568 Visualize complete chamber cross-section with ablative, graphite, and stainless steel.
570 Creates a 2D cross-sectional view showing:
571 - Chamber wall layers (ablative → stainless)
572 - Throat region (graphite → stainless)
573 - Pressure profile overlay (optional)
578 Complete geometry from calculate_complete_chamber_geometry
579 pressure_profile : dict, optional
580 Pressure profile from calculate_chamber_pressure_profile
581 save_path : str, optional
582 Path to save figure (default: None, don't save)
584 Whether to display plot (default: True)
587 import matplotlib.pyplot
as plt
588 from matplotlib.patches
import Circle
590 print(
"matplotlib not available - skipping visualization")
593 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 8))
595 positions = np.array(geometry[
"positions"])
596 L_chamber = positions[-1]
599 ax1.set_aspect(
'equal')
600 ax1.set_xlabel(
'Axial Position [m]', fontsize=12)
601 ax1.set_ylabel(
'Radius [m]', fontsize=12)
602 ax1.set_title(
'Chamber Cross-Section (Side View)', fontsize=14, fontweight=
'bold')
603 ax1.grid(
True, alpha=0.3)
606 D_gas = np.array(geometry[
"D_gas_chamber"]) / 2.0
607 D_ablative = np.array(geometry[
"D_ablative_outer"]) / 2.0
608 D_stainless = np.array(geometry[
"D_stainless_outer"]) / 2.0
611 ax1.fill_between(positions, 0, D_gas, color=
'orange', alpha=0.3, label=
'Hot Gas')
612 ax1.plot(positions, D_gas,
'r-', linewidth=2, label=
'Gas Boundary')
615 if np.any(D_ablative > D_gas):
616 ax1.fill_between(positions, D_gas, D_ablative, color=
'brown', alpha=0.5, label=
'Phenolic Ablator')
617 ax1.plot(positions, D_ablative,
'brown', linewidth=1.5, linestyle=
'--')
620 if np.any(D_stainless > D_ablative):
621 ax1.fill_between(positions, D_ablative, D_stainless, color=
'gray', alpha=0.6, label=
'Stainless Steel')
622 ax1.plot(positions, D_stainless,
'gray', linewidth=1.5, linestyle=
'--')
625 D_throat = geometry[
"D_throat_current"] / 2.0
626 D_graphite = geometry[
"D_graphite_outer"] / 2.0
627 D_stainless_throat = geometry[
"D_stainless_throat_outer"] / 2.0
630 throat_pos = L_chamber
631 ax1.plot([throat_pos, throat_pos], [0, D_stainless_throat],
'k-', linewidth=2)
634 if geometry[
"graphite_thickness"] > 0:
635 ax1.add_patch(Circle((throat_pos, 0), D_graphite, fill=
False,
636 edgecolor=
'black', linewidth=2, linestyle=
':', label=
'Graphite Insert'))
637 ax1.add_patch(Circle((throat_pos, 0), D_throat, fill=
False,
638 edgecolor=
'red', linewidth=2, label=
'Throat'))
640 ax1.legend(loc=
'upper right', fontsize=10)
641 ax1.set_xlim(-0.01, L_chamber * 1.1)
642 ax1.set_ylim(0,
max(D_stainless) * 1.2)
645 ax2.set_aspect(
'equal')
646 ax2.set_xlabel(
'Radius [m]', fontsize=12)
647 ax2.set_ylabel(
'Radius [m]', fontsize=12)
648 ax2.set_title(
'Wall Structure (Mid-Chamber Cross-Section)', fontsize=14, fontweight=
'bold')
649 ax2.grid(
True, alpha=0.3)
652 mid_idx = len(positions) // 2
653 R_gas_mid = D_gas[mid_idx]
654 R_ablative_mid = D_ablative[mid_idx]
655 R_stainless_mid = D_stainless[mid_idx]
658 theta = np.linspace(0, 2*np.pi, 100)
661 ax2.plot(R_gas_mid * np.cos(theta), R_gas_mid * np.sin(theta),
662 'r-', linewidth=3, label=
'Gas Boundary')
665 if R_ablative_mid > R_gas_mid:
666 ax2.fill_between(R_ablative_mid * np.cos(theta),
667 R_gas_mid * np.sin(theta),
668 R_ablative_mid * np.sin(theta),
669 color=
'brown', alpha=0.5, label=
'Phenolic Ablator')
670 ax2.plot(R_ablative_mid * np.cos(theta), R_ablative_mid * np.sin(theta),
671 'brown', linewidth=2, linestyle=
'--')
674 if R_stainless_mid > R_ablative_mid:
675 ax2.fill_between(R_stainless_mid * np.cos(theta),
676 R_ablative_mid * np.sin(theta),
677 R_stainless_mid * np.sin(theta),
678 color=
'gray', alpha=0.6, label=
'Stainless Steel')
679 ax2.plot(R_stainless_mid * np.cos(theta), R_stainless_mid * np.sin(theta),
680 'gray', linewidth=2, linestyle=
'--')
682 ax2.legend(loc=
'upper right', fontsize=10)
683 max_radius = R_stainless_mid * 1.2
684 ax2.set_xlim(-max_radius, max_radius)
685 ax2.set_ylim(-max_radius, max_radius)
689 f
"Ablative Thickness: {geometry['ablative_thickness'][0]*1000:.1f} mm\n"
690 f
"Graphite Thickness: {geometry['graphite_thickness']*1000:.1f} mm\n"
691 f
"Stainless Thickness: {geometry['stainless_thickness']*1000:.1f} mm\n"
692 f
"Chamber Recession: {geometry['recession_chamber']*1000:.2f} mm\n"
693 f
"Graphite Recession: {geometry['recession_graphite']*1000:.2f} mm"
695 ax2.text(0.02, 0.98, info_text, transform=ax2.transAxes,
696 fontsize=9, verticalalignment=
'top',
697 bbox=dict(boxstyle=
'round', facecolor=
'wheat', alpha=0.5))
702 plt.savefig(save_path, dpi=300, bbox_inches=
'tight')
703 print(f
"Figure saved to {save_path}")