STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
chamber_geometry_solver.py
Go to the documentation of this file.
1"""
2Chamber geometry solver that uses CEA lookup table to find real Cf.
3
4This solver iteratively converges on the correct throat area and thrust coefficient
5given chamber pressure and thrust, using thermochemistry from the CEA cache.
6"""
7
8import numpy as np
9from typing import Optional, Tuple, Dict, Any
10import os
11import logging
12
13# Handle both relative import (when used as module) and absolute import (when run as script)
14try:
15 from .chamber_geometry import (
16 chamber_geometry_calc,
17 area_exit_calc,
18 expansion_ratio_calc,
19 theta_default,
20 l_star_default,
21 chamber_diameter_default,
22 diameter_exit_default,
23 area_chamber_calc,
24 )
25except ImportError:
26 # Fallback for when running as script
28 chamber_geometry_calc,
29 area_exit_calc,
30 expansion_ratio_calc,
31 theta_default,
32 l_star_default,
33 chamber_diameter_default,
34 diameter_exit_default,
35 area_chamber_calc,
36 )
37from engine.pipeline.cea_cache import CEACache
38from engine.core.nozzle import nozzle_stagnation_loss
39from engine.pipeline.config_schemas import CEAConfig
40
41# Default CEA cache file path (relative to project root)
42DEFAULT_CEA_CACHE_FILE = "output/cache/cea_cache_LOX_RP1.npz"
43
44# Create module-level logger
45logger = logging.getLogger("evaluate")
46
47
49 pc_design: float,
50 thrust_design: float,
51 cea_cache: CEACache,
52 MR: float,
53 diameter_inner: float = chamber_diameter_default,
54 diameter_exit: float = diameter_exit_default,
55 l_star: float = l_star_default,
56 Pa: float = 101325.0,
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,
62 steps: int = 200,
63 export_dxf: Optional[str] = None,
64 verbose: bool = False,
65 theta: float = theta_default,
66) -> Tuple[np.ndarray, list, float, Dict[str, Any]]:
67 """
68 Solve for chamber geometry using CEA lookup table to find corrected Cf.
69
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.
73
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)
83
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
88
89 Parameters:
90 -----------
91 pc_design : float
92 Design chamber pressure [Pa]
93 thrust_design : float
94 Design thrust [N]
95 cea_cache : CEACache
96 CEA cache instance for thermochemistry lookup
97 MR : float
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)
105 Pa : float, optional
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)
125
126 Returns:
127 --------
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]
134 solver_info : dict
135 Dictionary containing:
136 - converged: bool
137 - iterations: int
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
141 - final_eps: float
142 - convergence_history: list of (iteration, A_throat, Cf, eps, residual)
143 """
144 # Validate nozzle efficiency
145 if not (0 < nozzle_efficiency <= 1.0):
146 raise ValueError(f"nozzle_efficiency must be in (0, 1], got {nozzle_efficiency}")
147
148 # Calculate exit area (fixed)
149 area_exit = area_exit_calc(diameter_exit)
150
151 # Initial guess for throat area using a typical Cf value
152 # Use a reasonable initial guess: Cf ~ 1.4-1.6 for typical nozzles (corrected)
153 # Account for nozzle efficiency in initial guess
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)
157
158 # Check if CEA cache supports 3D (eps-dependent) or 2D (fixed eps)
159 use_3d = cea_cache.use_3d
160
161 if verbose:
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²")
169
170 # Iteration variables
171 A_throat = A_throat_guess
172 convergence_history = []
173 converged = False
174
175 # Validate input parameters
176 if pc_design <= 0:
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}")
180 if area_exit <= 0:
181 raise ValueError(f"Exit area must be positive, got {area_exit}")
182
183 # Typical Cf range for rocket nozzles: 1.2-2.0
184 # Lower bound: underexpanded nozzles at sea level
185 # Upper bound: highly overexpanded nozzles or vacuum-optimized
186 Cf_min_reasonable = 1.0
187 Cf_max_reasonable = 2.5
188
189 for iteration in range(max_iterations):
190 # Calculate expansion ratio
191 eps = expansion_ratio_calc(area_exit, A_throat)
192
193 # Validate expansion ratio is reasonable
194 if eps < 1.0:
195 raise ValueError(
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²)."
198 )
199 if eps > 100.0:
200 import warnings
201 warnings.warn(
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."
204 )
205
206 # Look up Cf_ideal from CEA cache
207 Cf_ideal = None # Initialize to track if lookup succeeded
208 try:
209 if use_3d:
210 # 3D cache: use eps in lookup (this gives us the best Cf for this expansion ratio)
211 cea_props = cea_cache.eval(MR, pc_design, Pa, eps)
212 else:
213 # 2D cache: eps is fixed, but we still need to pass it
214 # The cache will use its default eps from config
215 cea_props = cea_cache.eval(MR, pc_design, Pa, None)
216
217 Cf_ideal = cea_props.get("Cf_ideal", Cf_ideal_initial_guess)
218
219 # Delivered coefficient on the runner's basis (engine.core.nozzle.calculate_thrust):
220 # F = zeta_n Cf_vac P0 At - Pa Ae, P0 = Pc/kappa, so per (Pc At):
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
223
224 # Validate Cf_ideal is finite and positive
225 if not np.isfinite(Cf_ideal) or Cf_ideal <= 0:
226 raise ValueError(f"Invalid Cf_ideal from CEA cache: {Cf_ideal}")
227
228 # Validate corrected Cf is finite and positive
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})")
231
232 # Validate Cf_ideal is within reasonable bounds (before efficiency correction)
233 if Cf_ideal < Cf_min_reasonable or Cf_ideal > Cf_max_reasonable:
234 import warnings
235 warnings.warn(
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."
240 )
241
242 except Exception as e:
243 if verbose:
244 logger.warning(f"CEA lookup failed at iteration {iteration}: {e}")
245 # Fallback to initial guess (already corrected for efficiency)
246 Cf = Cf_initial_guess
247 # Use initial guess for Cf_ideal if lookup failed
248 if Cf_ideal is None:
249 Cf_ideal = Cf_ideal_initial_guess
250 if iteration == 0:
251 # If first iteration fails, we can't proceed
252 raise RuntimeError(
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."
256 ) from e
257
258 # Calculate new throat area from thrust equation: F = Cf * Pc * A_throat
259 A_throat_new = thrust_design / (pc_design * Cf)
260
261 # Validate new throat area is reasonable
262 if A_throat_new <= 0 or not np.isfinite(A_throat_new):
263 raise ValueError(
264 f"Calculated throat area is invalid: {A_throat_new}. "
265 f"This may indicate Cf={Cf:.4f} is incorrect."
266 )
267
268 # Check convergence (relative error)
269 residual = abs(A_throat_new - A_throat) / max(A_throat, 1e-10)
270
271 # Also check absolute error for very small throat areas
272 residual_abs = abs(A_throat_new - A_throat)
273
274 # Store both corrected Cf and ideal Cf in history
275 convergence_history.append({
276 'iteration': iteration,
277 'A_throat': A_throat,
278 'Cf': Cf, # Corrected Cf (with efficiency applied)
279 'Cf_ideal': Cf_ideal, # Ideal Cf from CEA (actual value looked up)
280 'eps': eps,
281 'residual': residual,
282 'residual_abs': residual_abs
283 })
284
285 if verbose:
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}")
288
289 # Check convergence (both relative and absolute)
290 if residual < tolerance or residual_abs < 1e-10:
291 converged = True
292 A_throat = A_throat_new
293 break
294
295 # Update for next iteration (use adaptive relaxation for stability)
296 # For large residuals, use more conservative relaxation
297 if residual > 0.1:
298 relaxation = 0.5 # More conservative for large changes
299 elif residual > 0.01:
300 relaxation = 0.7 # Standard relaxation
301 else:
302 relaxation = 0.9 # More aggressive near convergence
303
304 A_throat = relaxation * A_throat_new + (1 - relaxation) * A_throat
305
306 # Prevent oscillation: if we're oscillating, reduce relaxation further
307 if iteration >= 2:
308 prev_residual = convergence_history[-2]['residual']
309 if residual > prev_residual * 0.9: # Not improving much
310 relaxation = max(0.3, relaxation * 0.8) # More conservative
311 A_throat = relaxation * A_throat_new + (1 - relaxation) * A_throat
312
313 if not converged:
314 import warnings
315 warnings.warn(
316 f"Chamber geometry solver did not converge after {max_iterations} iterations. "
317 f"Final residual: {residual:.2e}. Using last computed values."
318 )
319
320 # Final Cf lookup with converged values
321 eps_final = expansion_ratio_calc(area_exit, A_throat)
322 if use_3d:
323 # For 3D cache: This gives us the ideal Cf for the converged expansion ratio
324 # This is the best achievable Cf for the given (Pc, MR, eps, Pa) conditions
325 cea_props_final = cea_cache.eval(MR, pc_design, Pa, eps_final)
326 else:
327 # For 2D cache: Uses fixed expansion ratio from config
328 cea_props_final = cea_cache.eval(MR, pc_design, Pa, None)
329
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
333
334 # Final validation: Verify the converged solution is self-consistent
335 # Check that F = Cf_final * Pc * A_throat (within tolerance)
336 thrust_check = Cf_final * pc_design * A_throat
337 thrust_error = abs(thrust_check - thrust_design) / thrust_design
338
339 if thrust_error > 0.01: # 1% tolerance
340 import warnings
341 warnings.warn(
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."
347 )
348
349 # Validate final Cf_ideal is reasonable (before efficiency correction)
350 if Cf_ideal_final < Cf_min_reasonable or Cf_ideal_final > Cf_max_reasonable:
351 import warnings
352 warnings.warn(
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."
356 )
357
358 if verbose:
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}%)")
365
366 # Now use the converged Cf to calculate full geometry
367 # Convert Cf to force_coefficient for the existing function
368 force_coefficient = Cf_final
369
370 chamber_pts, table_data, lengths = chamber_geometry_calc(
371 pc_design=pc_design,
372 thrust_design=thrust_design,
373 force_coeffcient=force_coefficient,
374 diameter_inner=diameter_inner,
375 diameter_exit=diameter_exit,
376 l_star=l_star,
377 theta=theta,
378 do_plot=do_plot,
379 color_segments=color_segments,
380 steps=steps,
381 export_dxf=export_dxf,
382 )
383
384 total_chamber_length = lengths['total']
385
386 # Add Cf information to table data
387 # Find the row with "Contraction Ratio" and insert after it
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':
391 # Insert Cf row after Contraction Ratio
392 cf_row = [
393 'Thrust Coefficient (Cf)',
394 f'{Cf_final:.6f}',
395 '',
396 f'{Cf_final:.6f}',
397 ''
398 ]
399 table_data_with_cf.insert(i + 1, cf_row)
400 break
401
402 # Solver information
403 solver_info = {
404 'converged': converged,
405 'iterations': iteration + 1,
406 'final_Cf': float(Cf_final), # Corrected Cf (with efficiency applied)
407 'final_Cf_ideal': float(Cf_ideal_final), # Ideal Cf from CEA
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,
415 'cea_props': {
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),
420 }
421 }
422
423 return chamber_pts, table_data_with_cf, total_chamber_length, solver_info
424
425
426def _get_default_cea_config() -> CEAConfig:
427 """
428 Create a default CEA config using the standard cache file.
429
430 Returns:
431 --------
432 CEAConfig with default cache file path
433 """
434 # Get project root (assume we're in chamber/ directory, go up one level)
435 # Or if running from project root, use relative path
436 cache_file = DEFAULT_CEA_CACHE_FILE
437
438 # Try to find the cache file - check if it exists relative to current working directory
439 if not os.path.exists(cache_file):
440 # Try from project root
441 project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
442 cache_file_abs = os.path.join(project_root, DEFAULT_CEA_CACHE_FILE)
443 if os.path.exists(cache_file_abs):
444 cache_file = cache_file_abs
445 else:
446 # Use relative path anyway - CEACache will handle path resolution
447 cache_file = DEFAULT_CEA_CACHE_FILE
448
449 return CEAConfig(
450 ox_name="LOX",
451 fuel_name="RP-1",
452 expansion_ratio=10.0, # Default for 2D cache (will be overridden if 3D cache)
453 cache_file=cache_file,
454 Pc_range=[1.0e6, 5.0e6], # Will be updated from cache metadata if cache exists
455 MR_range=[2.0, 3.0], # Will be updated from cache metadata if cache exists
456 n_points=34, # Will be updated from cache metadata if cache exists
457 )
458
459
461 pc_design: float,
462 thrust_design: float,
463 MR: float,
464 cea_config: Optional[CEAConfig] = None,
465 diameter_inner: float = chamber_diameter_default,
466 diameter_exit: float = diameter_exit_default,
467 l_star: float = l_star_default,
468 **kwargs
469) -> Tuple[np.ndarray, list, float, Dict[str, Any]]:
470 """
471 Solve chamber geometry using CEA lookup table (convenience function).
472
473 This is the main entry point. If no CEA config is provided, uses the default
474 cache file at output/cache/cea_cache_LOX_RP1.npz.
475
476 Parameters:
477 -----------
478 pc_design : float
479 Design chamber pressure [Pa]
480 thrust_design : float
481 Design thrust [N]
482 MR : float
483 Mixture ratio (O/F)
484 cea_config : CEAConfig, optional
485 CEA configuration object. If None, uses default cache file.
486 diameter_inner : float, optional
487 Inner chamber diameter [m]
488 diameter_exit : float, optional
489 Exit diameter [m]
490 l_star : float, optional
491 Characteristic length [m]
492 **kwargs
493 Additional arguments passed to solve_chamber_geometry_with_cea
494 (do_plot, color_segments, steps, export_dxf, verbose, etc.)
495
496 Returns:
497 --------
498 Same as solve_chamber_geometry_with_cea
499 """
500 if cea_config is None:
501 cea_config = _get_default_cea_config()
502
503 cea_cache = CEACache(cea_config)
505 pc_design=pc_design,
506 thrust_design=thrust_design,
507 cea_cache=cea_cache,
508 MR=MR,
509 diameter_inner=diameter_inner,
510 diameter_exit=diameter_exit,
511 l_star=l_star,
512 **kwargs
513 )
514
515
517 pc_design: float,
518 thrust_design: float,
519 cea_config: CEAConfig,
520 MR: float,
521 diameter_inner: float = chamber_diameter_default,
522 diameter_exit: float = diameter_exit_default,
523 l_star: float = l_star_default,
524 **kwargs
525) -> Tuple[np.ndarray, list, float, Dict[str, Any]]:
526 """
527 Convenience function that creates a CEA cache from config and solves.
528
529 Note: For most use cases, use solve_chamber_geometry() instead, which
530 automatically uses the default cache file.
531
532 Parameters:
533 -----------
534 pc_design : float
535 Design chamber pressure [Pa]
536 thrust_design : float
537 Design thrust [N]
538 cea_config : CEAConfig
539 CEA configuration object
540 MR : float
541 Mixture ratio (O/F)
542 diameter_inner : float, optional
543 Inner chamber diameter [m]
544 diameter_exit : float, optional
545 Exit diameter [m]
546 l_star : float, optional
547 Characteristic length [m]
548 **kwargs
549 Additional arguments passed to solve_chamber_geometry_with_cea
550
551 Returns:
552 --------
553 Same as solve_chamber_geometry_with_cea
554 """
555 cea_cache = CEACache(cea_config)
557 pc_design=pc_design,
558 thrust_design=thrust_design,
559 cea_cache=cea_cache,
560 MR=MR,
561 diameter_inner=diameter_inner,
562 diameter_exit=diameter_exit,
563 l_star=l_star,
564 **kwargs
565 )
566
567
569 area_throat: float,
570 area_exit: float,
571 volume_chamber: float,
572 lstar: float,
573 chamber_diameter: float,
574 length: Optional[float],
575 do_plot: bool = False,
576 color_segments: bool = False,
577 steps: int = 200,
578 export_dxf: Optional[str] = None,
579 theta: float = theta_default,
580) -> Tuple[np.ndarray, list, Dict[str, float]]:
581 """
582 Generate chamber geometry plot from fully-constrained parameters.
583
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.
587
588 Parameters:
589 -----------
590 area_throat : float
591 Throat area [m²]
592 area_exit : float
593 Exit area [m²]
594 volume_chamber : float
595 Chamber volume [m³]
596 lstar : 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)
612
613 Returns:
614 --------
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
619 lengths : dict
620 Dictionary containing:
621 - cylindrical: Cylindrical section length [m]
622 - contraction: Contraction section length [m]
623 - total: Total chamber length [m]
624 """
625 # Calculate derived values
626 area_chamber = np.pi * (chamber_diameter / 2) ** 2
627 contraction_ratio = area_chamber / area_throat
628 expansion_ratio = area_exit / area_throat
629
630 # Generate nozzle using the rao function
631 from engine.core.chamber_geometry import generate_nozzle
632 nozzle_pts, nozzle_x_first, nozzle_y_first = generate_nozzle(
633 area_throat, area_exit, steps=steps, theta=theta)
634
635 # Calculate chamber sections
636 # The nozzle entrance radius (nozzle_y_first) is where the contraction cone meets the nozzle.
637 # This was hardcoded to 45 deg, which made the Chamber Geometry tab's fast path draw a
638 # 45 deg convergent no matter what the config asked for -- a second copy of the contour
639 # that never got the theta-general fix in chamber_geometry.py.
640 theta_contraction = float(theta)
641
642 # Calculate contraction length using the helper function
643 from engine.core.chamber_geometry import contraction_length_horizontal_calc
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,
648 )
649
650 # Barrel length from the declared volume, the same construction the generator uses: a
651 # stored `length` goes stale (it did after the 6.5 kN k-scaling, drawing L* 1.0035).
652 from engine.core.chamber_geometry import chamber_length_calc
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:
655 import warnings
656 warnings.warn(
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"
660 )
661
662 # Validate lengths
663 if cylindrical_length <= 0:
664 import warnings
665 warnings.warn(
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."
669 )
670 cylindrical_length = 0.01 # 10mm minimum
671
672 # Calculate total chamber length (should match input length)
673 total_chamber_length = cylindrical_length + contraction_length_horizontal
674
675 # Calculate chamber radius
676 r_c = np.sqrt(area_chamber / np.pi)
677
678 # Calculate where cylindrical section starts.
679 # The convergent runs from (x_cyl_start, r_c) down to (nozzle_x_first, nozzle_y_first)
680 # over a horizontal run of (r_c - y_first)*cot(theta). At theta = 45 deg this reduces to
681 # the old x_first + y_first - r_c.
682 x_cyl_start = nozzle_x_first - (r_c - nozzle_y_first) * np.tan(np.pi / 2 - theta_contraction)
683
684 # Generate cylindrical section (constant radius)
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)
688
689 # Generate contraction section at half-angle theta
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)
692
693 # Combine all sections: cylindrical -> contraction -> nozzle
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])), # both ends are shared vertices
697 nozzle_pts # Nozzle already starts at the connection point
698 ])
699
700 # Calculate additional diameters for table data
701 diameter_throat = np.sqrt(4 * area_throat / np.pi)
702 diameter_exit = np.sqrt(4 * area_exit / np.pi)
703
704 # Conversion factors
705 m_to_in = 39.37
706 m2_to_in2 = 1550.0031
707 m3_to_in3 = 61023.7441
708
709 # Convert to imperial
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
721
722 # Create table data with metric and imperial units
723 table_data = [
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'],
738 ]
739
740 # Plot if requested
741 if do_plot:
742 import matplotlib.pyplot as plt
743 from pathlib import Path
744
745 # Create figure with two subplots: contour on top, table below
746 fig = plt.figure(figsize=(16, 14))
747 gs = fig.add_gridspec(2, 1, height_ratios=[2, 1], hspace=0.15)
748
749 # Top subplot for contour
750 ax = fig.add_subplot(gs[0])
751
752 if color_segments:
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)
756 ax.legend()
757 else:
758 ax.plot(chamber_pts[:, 0], chamber_pts[:, 1], 'k-', linewidth=2)
759
760 # Stretch the plot vertically to show more detail in radius direction
761 ax.relim()
762 ax.autoscale()
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
767
768 # Set aspect ratio to stretch y-direction
769 aspect_ratio = (x_range / y_range) * 0.2
770 ax.set_aspect(aspect_ratio, 'box')
771
772 # Add small padding to y-axis
773 ax.set_ylim(y_min - 0.05 * y_range, y_max + 0.05 * y_range)
774
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')
779
780 # Bottom subplot for table
781 ax_table = fig.add_subplot(gs[1])
782 ax_table.axis('off')
783
784 # Create table
785 table = ax_table.table(cellText=table_data[1:], colLabels=table_data[0],
786 cellLoc='left', loc='center',
787 bbox=[0, 0, 1, 1])
788 table.auto_set_font_size(False)
789 table.set_fontsize(18)
790 table.scale(1.5, 4.5)
791
792 # Set column widths for better readability
793 col_widths = [0.22, 0.22, 0.12, 0.22, 0.12]
794 for i in range(5):
795 for j in range(len(table_data)):
796 table[(j, i)].set_width(col_widths[i])
797
798 # Style the header row
799 for i in range(5):
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)
803
804 # Alternate row colors and style cells
805 for i in range(1, len(table_data)):
806 for j in range(5):
807 if i % 2 == 0:
808 table[(i, j)].set_facecolor('#f1f1f2')
809 else:
810 table[(i, j)].set_facecolor('white')
811 table[(i, j)].set_text_props(size=18)
812 table[(i, j)].set_height(0.30)
813
814 # Ensure directory exists before saving
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')
818 plt.close()
819
820 # Export to DXF if requested
821 if export_dxf is not None:
822 from engine.core.dxf_export import export_chamber_dxf
823 export_chamber_dxf(chamber_pts, export_dxf)
824
825 # Return lengths as a dictionary for easy access
826 lengths = {
827 'cylindrical': cylindrical_length,
828 'contraction': contraction_length_horizontal,
829 'total': total_chamber_length, # barrel + cone
830 'entrance_arc': 1.5 * np.sqrt(area_throat / np.pi) * np.sin(theta_contraction),
831 }
832 lengths['face_to_throat'] = total_chamber_length + lengths['entrance_arc']
833
834 return chamber_pts, table_data, lengths
835
836
837if __name__ == "__main__":
838 # Example usage - uses default cache file automatically
839 # Test parameters
840 pc_design = 2.068e6 # 300 PSI
841 thrust_design = 6000.0 # N
842 MR = 2.5 # O/F ratio
843
844 print("=" * 80)
845 print("Chamber Geometry Solver Test")
846 print(f"Using default CEA cache: {DEFAULT_CEA_CACHE_FILE}")
847 print("=" * 80)
848
849 try:
850 # Use the convenience function - automatically uses default cache file
851 chamber_pts, table_data, total_length, solver_info = solve_chamber_geometry(
852 pc_design=pc_design,
853 thrust_design=thrust_design,
854 MR=MR,
855 do_plot=True,
856 color_segments=True,
857 export_dxf='chamber/chamber_contour_cea.dxf',
858 verbose=True,
859 )
860
861 print("\n" + "=" * 80)
862 print("Solution Summary")
863 print("=" * 80)
864 print(f"Converged: {solver_info['converged']}")
865 print(f"Iterations: {solver_info['iterations']}")
866 print(f"Final Cf: {solver_info['final_Cf']:.6f}")
867 print(f"Final A_throat: {solver_info['final_A_throat']*1e6:.4f} mm²")
868 print(f"Final eps: {solver_info['final_eps']:.4f}")
869 print(f"Total chamber length: {total_length*1000:.2f} mm")
870
871 except Exception as e:
872 print(f"\nError: {e}")
873 import traceback
874 traceback.print_exc()
875
#define max(a, b)
Definition Arduino.h:43
Tuple[np.ndarray, list, float, Dict[str, Any]] solve_chamber_geometry_from_config(float pc_design, float thrust_design, CEAConfig cea_config, float MR, float diameter_inner=chamber_diameter_default, float diameter_exit=diameter_exit_default, float l_star=l_star_default, **kwargs)
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)
Tuple[np.ndarray, list, Dict[str, float]] solved_chamber_plot(float area_throat, float area_exit, float volume_chamber, float lstar, float chamber_diameter, Optional[float] length, bool do_plot=False, bool color_segments=False, int steps=200, Optional[str] export_dxf=None, float theta=theta_default)
Tuple[np.ndarray, list, float, Dict[str, Any]] solve_chamber_geometry(float pc_design, float thrust_design, float MR, Optional[CEAConfig] cea_config=None, float diameter_inner=chamber_diameter_default, float diameter_exit=diameter_exit_default, float l_star=l_star_default, **kwargs)