STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
chamber_profiles.py
Go to the documentation of this file.
1"""Chamber pressure and temperature profiles along length with ablative and graphite visualization.
2
3This module provides:
41. Chamber pressure and temperature profiles
52. Ablative liner geometry (phenolic) with recession
63. Graphite insert geometry with recession
74. Stainless steel case geometry
85. Complete chamber cross-section visualization
9"""
10
11import numpy as np
12from typing import Dict, Optional, Any
13from engine.pipeline.numerical_robustness import NumericalStability
14from engine.pipeline.config_schemas import AblativeCoolingConfig, GraphiteInsertConfig, StainlessSteelCaseConfig
15
16
18 Pc: float,
19 Lstar: float,
20 mdot_total: float,
21 gamma: float,
22 R: float,
23 Tc: float,
24 A_throat: float,
25 n_points: int = 20,
26 chamber_diameter: Optional[float] = None,
27 volume: Optional[float] = None,
28) -> Dict[str, Any]:
29 """Static pressure from the injector face to the throat, quasi-one-dimensional.
30
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.
38
39 positions are distances from the injector face [m].
40 """
41 from engine.core.nozzle import nozzle_stagnation_loss
42 from engine.pipeline.thermal.gas_side import mach_from_area_ratio, wall_contour
43
44 g = float(gamma)
45 crit = (2.0 / (g + 1.0)) ** (g / (g - 1.0))
46 if not chamber_diameter or chamber_diameter <= 0:
47 P_throat = Pc * crit
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)
56 P0 = Pc / kappa
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])
64 return {
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,
74 }
75
76
78 Pc: float,
79 Tc: float,
80 mdot_total: float,
81 gamma: float,
82 R: float,
83 V_chamber: float,
84 A_throat: float,
85 Lstar: float,
86 MR: float,
87 P_back: Optional[float] = None,
88 A_chamber: Optional[float] = None,
89) -> Dict[str, Any]:
90 """
91 Calculate chamber intrinsic properties.
92
93 Parameters:
94 -----------
95 Pc : float
96 Chamber pressure [Pa]
97 Tc : float
98 Chamber temperature [K]
99 mdot_total : float
100 Total mass flow [kg/s]
101 gamma : float
102 Specific heat ratio
103 R : float
104 Gas constant [J/(kg·K)]
105 V_chamber : float
106 Chamber volume [m³]
107 A_throat : float
108 Throat area [m²]
109 Lstar : float
110 Characteristic length [m]
111 MR : float
112 Mixture ratio
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.
116
117 Returns:
118 --------
119 intrinsics : dict
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
128 """
129 # Gas density
130 rho, rho_valid = NumericalStability.safe_divide(Pc, R * Tc, 1.0, "rho")
131 if not rho_valid.passed:
132 rho = 1.0 # Fallback
133
134 # Sound speed
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:
138 sound_speed = 1000.0 # Fallback
139
140 # Mean gas velocity: u = mdot / (rho * A_chamber).
141 #
142 # This used to compute A_avg = V_chamber / Lstar, with a comment claiming that is "exact for
143 # constant area". It is not: L* is DEFINED as V_chamber / A_throat, so that expression
144 # collapses identically to A_throat for every geometry --
145 # A_avg = V / L* = V / (V/At) = At
146 # -- and the "mean chamber velocity" was really the throat velocity. On the methalox anchor
147 # (contraction ratio 12.9) it reported 767.5 m/s and a chamber Mach of 0.62, i.e. a nearly
148 # choked chamber; the true values are 59.5 m/s and M = 0.048. The thrust path was never
149 # affected (combustion_physics and the accel kernel both use the real Ac), but this fed the
150 # reported Mach and Reynolds number, and tripped the "velocity unusually high" diagnostic on
151 # literally every design, which is how a real warning gets trained into noise.
152 if A_chamber is not None and np.isfinite(A_chamber) and A_chamber > 0:
153 A_avg = float(A_chamber)
154 else:
155 # No chamber area supplied: fall back to a typical contraction ratio rather than to
156 # A_throat, which would silently reproduce the old bug.
157 A_avg = A_throat * 3.0
158
159 velocity_mean, v_valid = NumericalStability.safe_divide(
160 mdot_total, rho * A_avg, 0.0, "velocity_mean"
161 )
162 if not v_valid.passed:
163 velocity_mean = 50.0 # Fallback [m/s]
164
165 # CRITICAL PHYSICS: Throat velocity (sonic)
166 # At the throat, flow is ALWAYS choked (M_throat = 1.0 by definition)
167 # This is the fundamental physics: the throat is defined as the location where M = 1.0
168 #
169 # Isentropic relations at throat (M = 1.0):
170 # - T_throat/Tc = 2/(γ+1) (critical temperature ratio)
171 # - P_throat/Pc = [2/(γ+1)]^(γ/(γ-1)) (critical pressure ratio)
172 # - v_throat = a_throat = sqrt(γ × R × T_throat)
173 #
174 # Therefore: v_throat = sqrt(γ × R × Tc × [2/(γ+1)])
175 # This is the sonic velocity at the throat
176 throat_velocity_factor, factor_valid = NumericalStability.safe_divide(
177 gamma, gamma + 1.0, 0.5, "gamma/(gamma+1)"
178 )
179 if factor_valid.passed:
180 # v_throat^2 = gamma * R * Tc * [2/(gamma+1)]
181 # This is the exact sonic velocity at throat (M = 1.0)
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"
185 )
186 if not throat_v_valid.passed:
187 velocity_throat = sound_speed # Fallback (sonic = sound speed)
188 else:
189 velocity_throat = sound_speed # Fallback
190
191 # Calculate throat sound speed for verification
192 T_throat = Tc * (2.0 / (gamma + 1.0)) # Critical temperature ratio
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)) # Fallback
197
198 # VERIFICATION: Calculate M_throat from v_throat / a_throat to verify consistency
199 # This should equal 1.0 exactly (within numerical precision)
200 M_throat_verified, M_verify_valid = NumericalStability.safe_divide(
201 velocity_throat, a_throat, 1.0, "M_throat_verified"
202 )
203 if not M_verify_valid.passed:
204 M_throat_verified = 1.0 # Fallback to expected value
205
206 # Warn if verification deviates significantly from 1.0 (indicates numerical error)
207 if abs(M_throat_verified - 1.0) > 1e-6:
208 import warnings
209 warnings.warn(
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.",
213 RuntimeWarning
214 )
215
216 # Check if flow is choked using pressure ratio criterion
217 # Choking occurs when: P_back/Pc <= (2/(γ+1))^(γ/(γ-1))
218 # Use provided P_back, or fallback to 0.9 * 1 atm if not provided
219 P_back_used = P_back if P_back is not None else 0.9 * 101325.0 # Fallback: 0.9 atm
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
223
224 is_choked = True
225 actual_pressure_ratio = None
226 if Pc > 0:
227 actual_pressure_ratio = P_back_used / Pc
228 # Flow is choked if P_back/Pc <= critical ratio
229 is_choked = actual_pressure_ratio <= critical_pressure_ratio
230
231 if not is_choked:
232 import warnings
233 warnings.warn(
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.",
238 RuntimeWarning
239 )
240
241 # CRITICAL: M_throat = 1.0 (always, by definition of throat)
242 # This is NOT the chamber Mach number (which is subsonic)
243 # This is NOT the exit Mach number (which is supersonic)
244
245 # CRITICAL: This is the CHAMBER Mach number (mean flow in chamber), NOT throat or exit
246 # - Chamber: Subsonic (typically 0.01-0.1) - this is what we're calculating
247 # - Throat: Always M = 1.0 (sonic) - defined by throat geometry
248 # - Exit: Supersonic (M > 1.0) - calculated from area ratio in nozzle.py
249 mach_number, mach_valid = NumericalStability.safe_divide(
250 velocity_mean, sound_speed, 0.01, "mach_number_chamber"
251 )
252 if not mach_valid.passed:
253 mach_number = 0.01 # Fallback
254
255 # Residence time
256 # tau = V_chamber / (mdot_total / rho) = V_chamber * rho / mdot_total
257 residence_time, tau_valid = NumericalStability.safe_divide(
258 V_chamber * rho, mdot_total, 0.001, "residence_time"
259 )
260 if not tau_valid.passed:
261 residence_time = 0.001 # Fallback [s]
262
263 # Reynolds number (use mean properties)
264 # Re = rho * u * L / mu
265 # Estimate viscosity from temperature (Sutherland's law approximation)
266 mu_ref = 1.8e-5 # Pa·s at 273 K (air, approximate for combustion gases)
267 T_ref = 273.0 # K
268 S = 110.4 # Sutherland constant [K] (for air, approximate)
269 viscosity = mu_ref * ((Tc / T_ref) ** 1.5) * ((T_ref + S) / (Tc + S))
270
271 # Characteristic length for Re (use Lstar)
272 reynolds_number, Re_valid = NumericalStability.safe_divide(
273 rho * velocity_mean * Lstar, viscosity, 1000.0, "reynolds_number"
274 )
275 if not Re_valid.passed:
276 reynolds_number = 10000.0 # Fallback
277
278 # Validate all values
279 residence_time = max(residence_time, 1e-6) # Minimum 1 µs
280 # CRITICAL: Chamber Mach number is subsonic (M < 1.0)
281 # Typical range: 0.01 to 0.1 for rocket chambers
282 # This is NOT the throat Mach number (which is always M = 1.0)
283 # This is NOT the exit Mach number (which is supersonic, M > 1.0)
284 mach_number = max(min(mach_number, 0.99), 0.001) # Subsonic chamber flow
285 reynolds_number = max(reynolds_number, 100.0) # Minimum Re
286
287 return {
288 "Lstar": float(Lstar),
289 "residence_time": float(residence_time),
290 "velocity_mean": float(velocity_mean),
291 "velocity_throat": float(velocity_throat), # Sonic velocity (M_throat = 1.0)
292 "density": float(rho),
293 "sound_speed": float(sound_speed),
294 "mach_number": float(mach_number), # CHAMBER Mach number (subsonic, ~0.01-0.1)
295 "mach_number_chamber": float(mach_number), # Explicit: this is chamber Mach number
296 "mach_number_throat": 1.0, # Throat is always M = 1.0 (sonic) - by definition
297 "mach_number_throat_verified": float(M_throat_verified), # Calculated from v/a (verification)
298 "is_choked": bool(is_choked), # Whether flow is choked (P_back/Pc <= critical ratio)
299 "critical_pressure_ratio": float(critical_pressure_ratio), # Critical P_back/Pc for choking
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),
306 }
307
308
310 L_chamber: float,
311 D_chamber_initial: float,
312 ablative_config: AblativeCoolingConfig,
313 recession_thickness_chamber: float = 0.0,
314 n_points: int = 50,
315) -> Dict[str, Any]:
316 """
317 Calculate ablative liner geometry profile along chamber length.
318
319 Parameters:
320 -----------
321 L_chamber : float
322 Chamber length [m]
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)
329 n_points : int
330 Number of points along chamber (default: 50)
331
332 Returns:
333 --------
334 profile : dict
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]
340 """
341 if not ablative_config.enabled:
342 return {
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,
348 }
349
350 positions = np.linspace(0.0, L_chamber, n_points)
351
352 # Current ablative thickness (initial - recession)
353 ablative_thickness_current = max(
354 ablative_config.initial_thickness - recession_thickness_chamber,
355 0.0
356 )
357
358 # Physics: As ablative recedes, chamber inner diameter grows
359 # For radial recession: D_new = D_initial + 2 * recession (diameter increases by 2*recession)
360 # Coverage fraction accounts for partial coverage (not all surfaces have ablative)
361 D_chamber_inner = D_chamber_initial + 2.0 * recession_thickness_chamber * ablative_config.coverage_fraction
362
363 # Ablative outer diameter = gas-side diameter + 2 * remaining ablative thickness
364 # This defines the outer surface of the ablative liner
365 D_ablative_outer = D_chamber_inner + 2.0 * ablative_thickness_current
366
367 # For now, assume uniform recession along length
368 # In reality, recession varies with heat flux (higher near throat)
369 recession_array = np.full(n_points, recession_thickness_chamber)
370 ablative_thickness_array = np.full(n_points, ablative_thickness_current)
371
372 return {
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(),
378 }
379
380
382 D_throat_initial: float,
383 graphite_config: GraphiteInsertConfig,
384 recession_thickness_graphite: float = 0.0,
385) -> Dict[str, float]:
386 """
387 Calculate graphite insert geometry at throat.
388
389 Parameters:
390 -----------
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)
397
398 Returns:
399 --------
400 geometry : dict
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]
406 """
407 if not graphite_config.enabled:
408 return {
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,
413 "recession": 0.0,
414 }
415
416 # Physics: Graphite recession increases throat diameter
417 # As graphite erodes radially, throat diameter grows: D_new = D_initial + 2 * recession
418 # Coverage fraction accounts for partial coverage
419 D_throat_current = D_throat_initial + 2.0 * recession_thickness_graphite * graphite_config.coverage_fraction
420
421 # Remaining graphite thickness = initial - cumulative recession
422 # This cannot go negative (graphite is fully consumed at 0)
423 graphite_thickness_remaining = max(
424 graphite_config.initial_thickness - recession_thickness_graphite,
425 0.0
426 )
427
428 # Outer diameter of graphite insert = throat diameter + 2 * remaining thickness
429 # This defines the outer surface of the graphite insert
430 D_graphite_outer = D_throat_current + 2.0 * graphite_thickness_remaining
431
432 return {
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),
438 }
439
440
442 V_chamber: float,
443 A_throat: float,
444 L_chamber: float,
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,
452 n_points: int = 50,
453) -> Dict[str, Any]:
454 """
455 Calculate complete chamber geometry including ablative, graphite, and stainless steel.
456
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)
460
461 Parameters:
462 -----------
463 V_chamber : float
464 Current chamber volume [m³]
465 A_throat : float
466 Current throat area [m²]
467 L_chamber : float
468 Chamber length [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]
483 n_points : int
484 Number of points along chamber (default: 50)
485
486 Returns:
487 --------
488 geometry : dict
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]
499 """
500 # Calculate current diameters
501 # FIXED: Add safety checks for sqrt operations
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
504
505 positions = np.linspace(0.0, L_chamber, n_points)
506
507 # Chamber geometry (ablative + stainless)
508 if ablative_config and ablative_config.enabled:
509 ablative_thickness = max(
510 ablative_config.initial_thickness - recession_chamber,
511 0.0
512 )
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
515 else:
516 ablative_thickness = 0.0
517 D_gas_chamber = D_chamber_current
518 D_ablative_outer = D_gas_chamber
519
520 # Stainless steel case (behind ablative)
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
524 else:
525 stainless_thickness = 0.0
526 D_stainless_outer = D_ablative_outer
527
528 # Throat geometry (graphite + stainless)
529 graphite_geometry = calculate_graphite_geometry(
530 D_throat_initial,
531 graphite_config if graphite_config else type('obj', (object,), {'enabled': False})(),
532 recession_graphite,
533 )
534
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"]
538
539 # Stainless steel at throat (behind graphite)
540 if stainless_config and stainless_config.enabled:
541 D_stainless_throat_outer = D_graphite_outer + 2.0 * stainless_thickness
542 else:
543 D_stainless_throat_outer = D_graphite_outer
544
545 return {
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),
558 }
559
560
562 geometry: Dict[str, Any],
563 pressure_profile: Optional[Dict[str, Any]] = None,
564 save_path: Optional[str] = None,
565 show_plot: bool = True,
566) -> None:
567 """
568 Visualize complete chamber cross-section with ablative, graphite, and stainless steel.
569
570 Creates a 2D cross-sectional view showing:
571 - Chamber wall layers (ablative → stainless)
572 - Throat region (graphite → stainless)
573 - Pressure profile overlay (optional)
574
575 Parameters:
576 -----------
577 geometry : dict
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)
583 show_plot : bool
584 Whether to display plot (default: True)
585 """
586 try:
587 import matplotlib.pyplot as plt
588 from matplotlib.patches import Circle
589 except ImportError:
590 print("matplotlib not available - skipping visualization")
591 return
592
593 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 8))
594
595 positions = np.array(geometry["positions"])
596 L_chamber = positions[-1]
597
598 # Left plot: Axial cross-section (side view)
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)
604
605 # Plot chamber layers
606 D_gas = np.array(geometry["D_gas_chamber"]) / 2.0 # Convert to radius
607 D_ablative = np.array(geometry["D_ablative_outer"]) / 2.0
608 D_stainless = np.array(geometry["D_stainless_outer"]) / 2.0
609
610 # Gas boundary (centerline to gas surface)
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')
613
614 # Ablative layer
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='--')
618
619 # Stainless steel case
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='--')
623
624 # Throat region (zoomed)
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
628
629 # Draw throat region (at end of chamber)
630 throat_pos = L_chamber
631 ax1.plot([throat_pos, throat_pos], [0, D_stainless_throat], 'k-', linewidth=2)
632
633 # Graphite insert at throat
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'))
639
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)
643
644 # Right plot: Radial cross-section at mid-chamber
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)
650
651 # Get mid-chamber values
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]
656
657 # Draw concentric circles
658 theta = np.linspace(0, 2*np.pi, 100)
659
660 # Gas boundary
661 ax2.plot(R_gas_mid * np.cos(theta), R_gas_mid * np.sin(theta),
662 'r-', linewidth=3, label='Gas Boundary')
663
664 # Ablative layer
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='--')
672
673 # Stainless steel
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='--')
681
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)
686
687 # Add text annotations
688 info_text = (
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"
694 )
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))
698
699 plt.tight_layout()
700
701 if save_path:
702 plt.savefig(save_path, dpi=300, bbox_inches='tight')
703 print(f"Figure saved to {save_path}")
704
705 if show_plot:
706 plt.show()
707 else:
708 plt.close()
709
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Dict[str, Any] calculate_complete_chamber_geometry(float V_chamber, float A_throat, float L_chamber, float D_chamber_initial, float D_throat_initial, Optional[AblativeCoolingConfig] ablative_config=None, Optional[GraphiteInsertConfig] graphite_config=None, Optional[StainlessSteelCaseConfig] stainless_config=None, float recession_chamber=0.0, float recession_graphite=0.0, int n_points=50)
Dict[str, Any] calculate_chamber_pressure_profile(float Pc, float Lstar, float mdot_total, float gamma, float R, float Tc, float A_throat, int n_points=20, Optional[float] chamber_diameter=None, Optional[float] volume=None)
Dict[str, Any] calculate_chamber_intrinsics(float Pc, float Tc, float mdot_total, float gamma, float R, float V_chamber, float A_throat, float Lstar, float MR, Optional[float] P_back=None, Optional[float] A_chamber=None)
Dict[str, float] calculate_graphite_geometry(float D_throat_initial, GraphiteInsertConfig graphite_config, float recession_thickness_graphite=0.0)
Dict[str, Any] calculate_ablative_geometry_profile(float L_chamber, float D_chamber_initial, AblativeCoolingConfig ablative_config, float recession_thickness_chamber=0.0, int n_points=50)
None visualize_chamber_cross_section(Dict[str, Any] geometry, Optional[Dict[str, Any]] pressure_profile=None, Optional[str] save_path=None, bool show_plot=True)