53 diameter_inner: float = chamber_diameter_default,
54 diameter_exit: float = diameter_exit_default,
55 l_star: float = l_star_default,
57 nozzle_efficiency: float = 1.0,
58 tolerance: float = 1e-6,
59 max_iterations: int = 100,
60 do_plot: bool =
False,
61 color_segments: bool =
False,
63 export_dxf: Optional[str] =
None,
64 verbose: bool =
False,
65 theta: float = theta_default,
66) -> Tuple[np.ndarray, list, float, Dict[str, Any]]:
68 Solve for chamber geometry using CEA lookup table to find corrected Cf.
70 This function iteratively solves for the throat area that achieves the desired
71 thrust using the corrected thrust coefficient (Cf) that accounts for nozzle
72 efficiency losses, matching what the pipeline uses for thrust calculations.
74 The solver finds the optimal throat area by:
75 1. Guessing an initial throat area
76 2. Calculating expansion ratio eps = A_exit / A_throat
77 3. Looking up ideal Cf from CEA cache using (Pc, MR, eps, Pa)
78 - For 3D cache: Cf is optimized for the current expansion ratio
79 4. Applying nozzle efficiency correction: Cf_corrected = nozzle_efficiency * Cf_ideal
80 - This matches the corrected Cf used in the pipeline (pintle_models/nozzle.py)
81 5. Calculating new throat area: A_throat = F / (Cf_corrected * Pc)
82 6. Iterating until convergence (throat area stabilizes)
84 The final Cf is the corrected thrust coefficient that accounts for:
85 - Ideal Cf from CEA (isentropic flow, real gas properties)
86 - Nozzle efficiency losses (boundary layer, divergence, etc.)
87 - This matches the Cf used in the actual thrust calculations
92 Design chamber pressure [Pa]
96 CEA cache instance for thermochemistry lookup
98 Mixture ratio (O/F) for CEA lookup
99 diameter_inner : float, optional
100 Inner chamber diameter [m] (default: 3.4")
101 diameter_exit : float, optional
102 Exit diameter [m] (default: 4")
103 l_star : float, optional
104 Characteristic length [m] (default: 1.27 m)
106 Ambient pressure [Pa] (default: sea level)
107 nozzle_efficiency : float, optional
108 Nozzle efficiency factor (0-1) to apply to ideal Cf (default: 1.0).
109 Typical values: 0.96-0.99. This accounts for boundary layer losses,
110 divergence losses, and other nozzle inefficiencies.
111 tolerance : float, optional
112 Convergence tolerance for throat area (default: 1e-6)
113 max_iterations : int, optional
114 Maximum number of iterations (default: 100)
115 do_plot : bool, optional
116 Whether to generate plot (default: False)
117 color_segments : bool, optional
118 Whether to color-code segments in plot (default: False)
119 steps : int, optional
120 Number of points for geometry generation (default: 200)
121 export_dxf : str or None, optional
122 Path to export DXF file (default: None)
123 verbose : bool, optional
124 Whether to print convergence information (default: False)
128 chamber_pts : numpy array
129 Array of (x, y) points representing the full chamber contour
130 table_data : list of lists
131 Table data with metric and imperial units
132 total_chamber_length : float
133 Total chamber length [m]
135 Dictionary containing:
138 - final_Cf: float (corrected Cf with efficiency applied)
139 - final_Cf_ideal: float (ideal Cf from CEA before efficiency correction)
140 - final_A_throat: float
142 - convergence_history: list of (iteration, A_throat, Cf, eps, residual)
145 if not (0 < nozzle_efficiency <= 1.0):
146 raise ValueError(f
"nozzle_efficiency must be in (0, 1], got {nozzle_efficiency}")
149 area_exit = area_exit_calc(diameter_exit)
154 Cf_ideal_initial_guess = 1.5
155 Cf_initial_guess = nozzle_efficiency * Cf_ideal_initial_guess
156 A_throat_guess = thrust_design / (pc_design * Cf_initial_guess)
159 use_3d = cea_cache.use_3d
162 logger.info(f
"Initializing chamber geometry solver...")
163 logger.info(f
" Pc = {pc_design/1e6:.2f} MPa")
164 logger.info(f
" F = {thrust_design:.1f} N")
165 logger.info(f
" MR = {MR:.3f}")
166 logger.info(f
" Nozzle efficiency = {nozzle_efficiency:.4f}")
167 logger.info(f
" CEA cache: {'3D (eps-dependent)' if use_3d else '2D (fixed eps)'}")
168 logger.info(f
" Initial A_throat guess = {A_throat_guess*1e6:.4f} mm²")
171 A_throat = A_throat_guess
172 convergence_history = []
177 raise ValueError(f
"Chamber pressure must be positive, got {pc_design}")
178 if thrust_design <= 0:
179 raise ValueError(f
"Thrust must be positive, got {thrust_design}")
181 raise ValueError(f
"Exit area must be positive, got {area_exit}")
186 Cf_min_reasonable = 1.0
187 Cf_max_reasonable = 2.5
189 for iteration
in range(max_iterations):
191 eps = expansion_ratio_calc(area_exit, A_throat)
196 f
"Expansion ratio must be >= 1.0, got {eps:.4f}. "
197 f
"This means exit area ({area_exit*1e6:.2f} mm²) < throat area ({A_throat*1e6:.2f} mm²)."
202 f
"Very high expansion ratio ({eps:.2f}) detected. "
203 f
"This may indicate throat area is too small or exit area is too large."
211 cea_props = cea_cache.eval(MR, pc_design, Pa, eps)
215 cea_props = cea_cache.eval(MR, pc_design, Pa,
None)
217 Cf_ideal = cea_props.get(
"Cf_ideal", Cf_ideal_initial_guess)
221 kappa = nozzle_stagnation_loss(area_chamber_calc(diameter_inner) / A_throat, cea_props[
"gamma"])
222 Cf = nozzle_efficiency * cea_props[
"Cf_vac"] / kappa - Pa * eps / pc_design
225 if not np.isfinite(Cf_ideal)
or Cf_ideal <= 0:
226 raise ValueError(f
"Invalid Cf_ideal from CEA cache: {Cf_ideal}")
229 if not np.isfinite(Cf)
or Cf <= 0:
230 raise ValueError(f
"Invalid corrected Cf: {Cf} (from Cf_ideal={Cf_ideal}, efficiency={nozzle_efficiency})")
233 if Cf_ideal < Cf_min_reasonable
or Cf_ideal > Cf_max_reasonable:
236 f
"Cf_ideal ({Cf_ideal:.4f}) is outside typical range [{Cf_min_reasonable:.2f}, {Cf_max_reasonable:.2f}]. "
237 f
"This may indicate: (1) unusual expansion ratio ({eps:.2f}), "
238 f
"(2) CEA cache issue, or (3) design point issue. "
239 f
"Proceeding with caution."
242 except Exception
as e:
244 logger.warning(f
"CEA lookup failed at iteration {iteration}: {e}")
246 Cf = Cf_initial_guess
249 Cf_ideal = Cf_ideal_initial_guess
253 f
"CEA lookup failed on first iteration. "
254 f
"Check that (Pc={pc_design/1e6:.2f} MPa, MR={MR:.3f}, eps={eps:.2f}) "
255 f
"is within cache bounds."
259 A_throat_new = thrust_design / (pc_design * Cf)
262 if A_throat_new <= 0
or not np.isfinite(A_throat_new):
264 f
"Calculated throat area is invalid: {A_throat_new}. "
265 f
"This may indicate Cf={Cf:.4f} is incorrect."
269 residual = abs(A_throat_new - A_throat) /
max(A_throat, 1e-10)
272 residual_abs = abs(A_throat_new - A_throat)
275 convergence_history.append({
276 'iteration': iteration,
277 'A_throat': A_throat,
279 'Cf_ideal': Cf_ideal,
281 'residual': residual,
282 'residual_abs': residual_abs
286 logger.info(f
" Iter {iteration}: A_throat={A_throat*1e6:.6f} mm², "
287 f
"eps={eps:.4f}, Cf_ideal={Cf_ideal:.6f}, Cf_corrected={Cf:.6f}, residual={residual:.2e}")
290 if residual < tolerance
or residual_abs < 1e-10:
292 A_throat = A_throat_new
299 elif residual > 0.01:
304 A_throat = relaxation * A_throat_new + (1 - relaxation) * A_throat
308 prev_residual = convergence_history[-2][
'residual']
309 if residual > prev_residual * 0.9:
310 relaxation =
max(0.3, relaxation * 0.8)
311 A_throat = relaxation * A_throat_new + (1 - relaxation) * A_throat
316 f
"Chamber geometry solver did not converge after {max_iterations} iterations. "
317 f
"Final residual: {residual:.2e}. Using last computed values."
321 eps_final = expansion_ratio_calc(area_exit, A_throat)
325 cea_props_final = cea_cache.eval(MR, pc_design, Pa, eps_final)
328 cea_props_final = cea_cache.eval(MR, pc_design, Pa,
None)
330 Cf_ideal_final = cea_props_final.get(
"Cf_ideal", Cf / nozzle_efficiency
if nozzle_efficiency > 0
else Cf)
331 kappa_final = nozzle_stagnation_loss(area_chamber_calc(diameter_inner) / A_throat, cea_props_final[
"gamma"])
332 Cf_final = nozzle_efficiency * cea_props_final[
"Cf_vac"] / kappa_final - Pa * eps_final / pc_design
336 thrust_check = Cf_final * pc_design * A_throat
337 thrust_error = abs(thrust_check - thrust_design) / thrust_design
339 if thrust_error > 0.01:
342 f
"Final solution may not be self-consistent: "
343 f
"Requested thrust = {thrust_design:.2f} N, "
344 f
"Calculated thrust = {thrust_check:.2f} N "
345 f
"(error = {thrust_error*100:.2f}%). "
346 f
"This may indicate convergence issues."
350 if Cf_ideal_final < Cf_min_reasonable
or Cf_ideal_final > Cf_max_reasonable:
353 f
"Final Cf_ideal ({Cf_ideal_final:.4f}) is outside typical range "
354 f
"[{Cf_min_reasonable:.2f}, {Cf_max_reasonable:.2f}]. "
355 f
"Verify design parameters are correct."
359 logger.info(f
"\nConverged after {iteration + 1} iterations:")
360 logger.info(f
" Final A_throat = {A_throat*1e6:.6f} mm²")
361 logger.info(f
" Final eps = {eps_final:.4f}")
362 logger.info(f
" Final Cf_ideal = {Cf_ideal_final:.6f} (from CEA)")
363 logger.info(f
" Final Cf_corrected = {Cf_final:.6f} (with efficiency={nozzle_efficiency:.4f})")
364 logger.info(f
" Thrust check: {thrust_check:.2f} N (requested: {thrust_design:.2f} N, error: {thrust_error*100:.2f}%)")
368 force_coefficient = Cf_final
370 chamber_pts, table_data, lengths = chamber_geometry_calc(
372 thrust_design=thrust_design,
373 force_coeffcient=force_coefficient,
374 diameter_inner=diameter_inner,
375 diameter_exit=diameter_exit,
379 color_segments=color_segments,
381 export_dxf=export_dxf,
384 total_chamber_length = lengths[
'total']
388 table_data_with_cf = table_data.copy()
389 for i, row
in enumerate(table_data_with_cf):
390 if len(row) > 0
and row[0] ==
'Contraction Ratio':
393 'Thrust Coefficient (Cf)',
399 table_data_with_cf.insert(i + 1, cf_row)
404 'converged': converged,
405 'iterations': iteration + 1,
406 'final_Cf': float(Cf_final),
407 'final_Cf_ideal': float(Cf_ideal_final),
408 'final_A_throat': float(A_throat),
409 'final_eps': float(eps_final),
410 'final_length_cylindrical': float(lengths[
'cylindrical']),
411 'final_length_contraction': float(lengths[
'contraction']),
412 'final_length_total': float(lengths[
'total']),
413 'nozzle_efficiency': float(nozzle_efficiency),
414 'convergence_history': convergence_history,
416 'cstar_ideal': cea_props_final.get(
'cstar_ideal', np.nan),
417 'Tc': cea_props_final.get(
'Tc', np.nan),
418 'gamma': cea_props_final.get(
'gamma', np.nan),
419 'R': cea_props_final.get(
'R', np.nan),
423 return chamber_pts, table_data_with_cf, total_chamber_length, solver_info
571 volume_chamber: float,
573 chamber_diameter: float,
574 length: Optional[float],
575 do_plot: bool =
False,
576 color_segments: bool =
False,
578 export_dxf: Optional[str] =
None,
579 theta: float = theta_default,
580) -> Tuple[np.ndarray, list, Dict[str, float]]:
582 Generate chamber geometry plot from fully-constrained parameters.
584 This function is used when all geometry parameters are already known (e.g., from
585 optimizer output) and no CEA iteration is needed. It directly generates the chamber
586 contour from the given parameters.
594 volume_chamber : float
597 Characteristic length [m]
598 chamber_diameter : float
599 Chamber inner diameter [m]
600 length : float or None
601 Stored barrel + cone length [m], checked against the volume only: the barrel is
602 drawn from ``volume_chamber``. Face to throat adds the 1.5 Rt entrance arc
603 (lengths['face_to_throat']).
604 do_plot : bool, optional
605 Whether to generate plot (default: False)
606 color_segments : bool, optional
607 Whether to color-code segments in plot (default: False)
608 steps : int, optional
609 Number of points for geometry generation (default: 200)
610 export_dxf : str or None, optional
611 Path to export DXF file (default: None)
615 chamber_pts : numpy array
616 Array of (x, y) points representing the full chamber contour
617 table_data : list of lists
618 Table data with metric and imperial units
620 Dictionary containing:
621 - cylindrical: Cylindrical section length [m]
622 - contraction: Contraction section length [m]
623 - total: Total chamber length [m]
626 area_chamber = np.pi * (chamber_diameter / 2) ** 2
627 contraction_ratio = area_chamber / area_throat
628 expansion_ratio = area_exit / area_throat
632 nozzle_pts, nozzle_x_first, nozzle_y_first = generate_nozzle(
633 area_throat, area_exit, steps=steps, theta=theta)
640 theta_contraction = float(theta)
644 contraction_length_horizontal = contraction_length_horizontal_calc(
645 area_chamber=area_chamber,
646 entrance_arc_start_y=nozzle_y_first,
647 theta=theta_contraction,
653 cylindrical_length = chamber_length_calc(volume_chamber, area_throat, contraction_ratio, theta_contraction)
654 if length
is not None and abs((cylindrical_length + contraction_length_horizontal) - length) > 1e-4:
657 f
"stored chamber length {length*1000:.2f} mm disagrees with the declared volume "
658 f
"({(cylindrical_length + contraction_length_horizontal)*1000:.2f} mm barrel + cone); "
659 f
"drawing from the volume"
663 if cylindrical_length <= 0:
666 f
"Calculated cylindrical length ({cylindrical_length*1000:.2f} mm) is non-positive. "
667 f
"Total length ({length*1000:.2f} mm) may be too small for contraction length "
668 f
"({contraction_length_horizontal*1000:.2f} mm). Using minimum cylindrical length."
670 cylindrical_length = 0.01
673 total_chamber_length = cylindrical_length + contraction_length_horizontal
676 r_c = np.sqrt(area_chamber / np.pi)
682 x_cyl_start = nozzle_x_first - (r_c - nozzle_y_first) * np.tan(np.pi / 2 - theta_contraction)
685 x_cyl_end = x_cyl_start - cylindrical_length
686 x_cyl = np.linspace(x_cyl_end, x_cyl_start, steps)
687 y_cyl = np.full_like(x_cyl, r_c)
690 x_contraction = np.linspace(x_cyl_start, nozzle_x_first, steps)
691 y_contraction = r_c - (x_contraction - x_cyl_start) * np.tan(theta_contraction)
694 chamber_pts = np.vstack([
695 np.column_stack((x_cyl, y_cyl)),
696 np.column_stack((x_contraction[1:-1], y_contraction[1:-1])),
701 diameter_throat = np.sqrt(4 * area_throat / np.pi)
702 diameter_exit = np.sqrt(4 * area_exit / np.pi)
706 m2_to_in2 = 1550.0031
707 m3_to_in3 = 61023.7441
710 volume_chamber_in3 = volume_chamber * m3_to_in3
711 area_chamber_in2 = area_chamber * m2_to_in2
712 chamber_diameter_in = chamber_diameter * m_to_in
713 area_throat_in2 = area_throat * m2_to_in2
714 diameter_throat_in = diameter_throat * m_to_in
715 area_exit_in2 = area_exit * m2_to_in2
716 diameter_exit_in = diameter_exit * m_to_in
717 lstar_in = lstar * m_to_in
718 cylindrical_length_in = cylindrical_length * m_to_in
719 contraction_length_horizontal_in = contraction_length_horizontal * m_to_in
720 total_chamber_length_in = total_chamber_length * m_to_in
724 [
'Parameter',
'Metric Value',
'Metric Units',
'Imperial Value',
'Imperial Units'],
725 [
'Chamber Volume', f
'{volume_chamber:.6e}',
'm³', f
'{volume_chamber_in3:.4f}',
'in³'],
726 [
'Chamber Area', f
'{area_chamber:.6e}',
'm²', f
'{area_chamber_in2:.4f}',
'in²'],
727 [
'Chamber Diameter', f
'{chamber_diameter:.6e}',
'm', f
'{chamber_diameter_in:.4f}',
'in'],
728 [
'Throat Area', f
'{area_throat:.6e}',
'm²', f
'{area_throat_in2:.6f}',
'in²'],
729 [
'Throat Diameter', f
'{diameter_throat:.6e}',
'm', f
'{diameter_throat_in:.6f}',
'in'],
730 [
'Exit Area', f
'{area_exit:.6e}',
'm²', f
'{area_exit_in2:.4f}',
'in²'],
731 [
'Exit Diameter', f
'{diameter_exit:.6e}',
'm', f
'{diameter_exit_in:.4f}',
'in'],
732 [
'Expansion Ratio', f
'{expansion_ratio:.4f}',
'', f
'{expansion_ratio:.4f}',
''],
733 [
'Contraction Ratio', f
'{contraction_ratio:.4f}',
'', f
'{contraction_ratio:.4f}',
''],
734 [
'L*', f
'{lstar:.4f}',
'm', f
'{lstar_in:.4f}',
'in'],
735 [
'Cylindrical Length', f
'{cylindrical_length:.6e}',
'm', f
'{cylindrical_length_in:.4f}',
'in'],
736 [
'Contraction Length', f
'{contraction_length_horizontal:.6e}',
'm', f
'{contraction_length_horizontal_in:.4f}',
'in'],
737 [
'Total Chamber Length', f
'{total_chamber_length:.6e}',
'm', f
'{total_chamber_length_in:.4f}',
'in'],
742 import matplotlib.pyplot
as plt
743 from pathlib
import Path
746 fig = plt.figure(figsize=(16, 14))
747 gs = fig.add_gridspec(2, 1, height_ratios=[2, 1], hspace=0.15)
750 ax = fig.add_subplot(gs[0])
753 ax.plot(x_cyl, y_cyl, label=
'Cylindrical section', color=
'blue', linewidth=2)
754 ax.plot(x_contraction, y_contraction, label=
'Contraction (45°)', color=
'green', linewidth=2)
755 ax.plot(nozzle_pts[:, 0], nozzle_pts[:, 1], label=
'Nozzle', color=
'red', linewidth=2)
758 ax.plot(chamber_pts[:, 0], chamber_pts[:, 1],
'k-', linewidth=2)
763 x_min, x_max = ax.get_xlim()
764 y_min, y_max = ax.get_ylim()
765 x_range = x_max - x_min
766 y_range = y_max - y_min
769 aspect_ratio = (x_range / y_range) * 0.2
770 ax.set_aspect(aspect_ratio,
'box')
773 ax.set_ylim(y_min - 0.05 * y_range, y_max + 0.05 * y_range)
775 ax.set_xlabel(
"Axial distance x (m)", fontsize=12)
776 ax.set_ylabel(
"Radius y (m)", fontsize=12)
777 ax.grid(
True, alpha=0.3)
778 ax.set_title(
"Chamber Contour (Solved Geometry)", fontsize=14, fontweight=
'bold')
781 ax_table = fig.add_subplot(gs[1])
785 table = ax_table.table(cellText=table_data[1:], colLabels=table_data[0],
786 cellLoc=
'left', loc=
'center',
788 table.auto_set_font_size(
False)
789 table.set_fontsize(18)
790 table.scale(1.5, 4.5)
793 col_widths = [0.22, 0.22, 0.12, 0.22, 0.12]
795 for j
in range(len(table_data)):
796 table[(j, i)].set_width(col_widths[i])
800 table[(0, i)].set_facecolor(
'#40466e')
801 table[(0, i)].set_text_props(weight=
'bold', color=
'white', size=20)
802 table[(0, i)].set_height(0.35)
805 for i
in range(1, len(table_data)):
808 table[(i, j)].set_facecolor(
'#f1f1f2')
810 table[(i, j)].set_facecolor(
'white')
811 table[(i, j)].set_text_props(size=18)
812 table[(i, j)].set_height(0.30)
815 output_path = Path(
'chamber/solved_chamber_contour.png')
816 output_path.parent.mkdir(parents=
True, exist_ok=
True)
817 plt.savefig(str(output_path), dpi=150, bbox_inches=
'tight')
821 if export_dxf
is not None:
823 export_chamber_dxf(chamber_pts, export_dxf)
827 'cylindrical': cylindrical_length,
828 'contraction': contraction_length_horizontal,
829 'total': total_chamber_length,
830 'entrance_arc': 1.5 * np.sqrt(area_throat / np.pi) * np.sin(theta_contraction),
832 lengths[
'face_to_throat'] = total_chamber_length + lengths[
'entrance_arc']
834 return chamber_pts, table_data, lengths
Tuple[np.ndarray, list, float, Dict[str, Any]] solve_chamber_geometry_with_cea(float pc_design, float thrust_design, CEACache cea_cache, float MR, float diameter_inner=chamber_diameter_default, float diameter_exit=diameter_exit_default, float l_star=l_star_default, float Pa=101325.0, float nozzle_efficiency=1.0, float tolerance=1e-6, int max_iterations=100, bool do_plot=False, bool color_segments=False, int steps=200, Optional[str] export_dxf=None, bool verbose=False, float theta=theta_default)