STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
blowdown_solver.py
Go to the documentation of this file.
1"""
2Pure blowdown pressurization solver.
3
4This module simulates propellant tank pressure decay due to ullage expansion
5as propellant is consumed, with no COPV or external pressurant makeup.
6
7Physics model:
8- Fixed pressurant gas mass in each tank
9- Polytropic temperature relation: T(t) = T0 * (V0/V(t))^(n-1)
10- Real gas effects via compressibility factor Z(P,T) in EOS: P = m*Z*R*T/V
11- Coupled simulation: Pressure -> mdot -> Mass Depletion -> Volume Expansion -> Pressure
12"""
13
14import numpy as np
15import pandas as pd
16from typing import Dict, Optional, Tuple, Callable, Any
17from scipy.interpolate import RegularGridInterpolator
18
19
20def split_propellant_by_of(total_kg: float, of_ratio: float) -> Tuple[float, float]:
21 """Split total propellant mass into (oxidizer_kg, fuel_kg) for O/F = of_ratio."""
22 total_kg = float(total_kg)
23 of_ratio = max(float(of_ratio), 1e-9)
24 m_fuel = total_kg / (1.0 + of_ratio)
25 m_oxidizer = total_kg - m_fuel
26 return m_oxidizer, m_fuel
27
28
30 *,
31 tank_volume_m3: Optional[float],
32 ullage_volume_m3: Optional[float],
33 propellant_mass_kg: float,
34 density_kg_m3: float,
35 tank_name: str = "tank",
36) -> float:
37 """Resolve total tank volume from either explicit volume or ullage + liquid fill."""
38 if tank_volume_m3 is not None and ullage_volume_m3 is not None:
39 liquid_vol = propellant_mass_kg / max(density_kg_m3, 1e-9)
40 expected_ullage = float(tank_volume_m3) - liquid_vol
41 rel_err = abs(expected_ullage - float(ullage_volume_m3)) / max(abs(float(ullage_volume_m3)), 1e-9)
42 if rel_err > 0.05:
43 raise ValueError(
44 f"{tank_name}: tank volume and ullage volume are inconsistent "
45 f"(expected ullage {expected_ullage:.6f} m³, got {float(ullage_volume_m3):.6f} m³)"
46 )
47 return float(tank_volume_m3)
48
49 if tank_volume_m3 is not None:
50 vol = float(tank_volume_m3)
51 elif ullage_volume_m3 is not None:
52 liquid_vol = propellant_mass_kg / max(density_kg_m3, 1e-9)
53 vol = float(ullage_volume_m3) + liquid_vol
54 else:
55 raise ValueError(f"{tank_name}: provide tank_volume_m3 or ullage_volume_m3")
56
57 liquid_vol = propellant_mass_kg / max(density_kg_m3, 1e-9)
58 if vol <= liquid_vol:
59 raise ValueError(
60 f"{tank_name}: tank volume {vol:.6f} m³ is too small for "
61 f"{propellant_mass_kg:.4f} kg propellant (liquid volume {liquid_vol:.6f} m³)"
62 )
63 return vol
64
65
67 config: Any,
68 *,
69 total_propellant_kg: Optional[float] = None,
70 of_ratio: Optional[float] = None,
71 lox_tank_volume_m3: Optional[float] = None,
72 lox_ullage_volume_m3: Optional[float] = None,
73 fuel_tank_volume_m3: Optional[float] = None,
74 fuel_ullage_volume_m3: Optional[float] = None,
75 lox_propellant_mass_kg: Optional[float] = None,
76 fuel_propellant_mass_kg: Optional[float] = None,
77) -> Tuple[float, float, float, float, float, float]:
78 """Resolve LOX/fuel (volume_m3, mass_kg, density) for blowdown simulation.
79
80 Falls back to config tank fields when overrides are omitted.
81 """
82 rho_lox = float(config.fluids["oxidizer"].density)
83 rho_fuel = float(config.fluids["fuel"].density)
84
85 if lox_propellant_mass_kg is not None and fuel_propellant_mass_kg is not None:
86 m_lox = float(lox_propellant_mass_kg)
87 m_fuel = float(fuel_propellant_mass_kg)
88 elif total_propellant_kg is not None:
89 if of_ratio is None:
90 dr = getattr(config, "design_requirements", None)
91 of_ratio = float(getattr(dr, "optimal_of_ratio", None) or 2.5)
92 m_lox, m_fuel = split_propellant_by_of(float(total_propellant_kg), float(of_ratio))
93 else:
94 lox_tank = getattr(config, "lox_tank", None)
95 fuel_tank = getattr(config, "fuel_tank", None)
96 m_lox = float(getattr(lox_tank, "mass", None) or 0.0)
97 m_fuel = float(getattr(fuel_tank, "mass", None) or 0.0)
98 if m_lox <= 0 or m_fuel <= 0:
99 raise ValueError(
100 "Blowdown requires total_propellant_kg or positive lox_tank.mass and fuel_tank.mass in config"
101 )
102
103 if lox_tank_volume_m3 is not None or lox_ullage_volume_m3 is not None:
105 tank_volume_m3=lox_tank_volume_m3,
106 ullage_volume_m3=lox_ullage_volume_m3,
107 propellant_mass_kg=m_lox,
108 density_kg_m3=rho_lox,
109 tank_name="LOX tank",
110 )
111 else:
112 V_lox = _config_tank_volume(config, "lox_tank", "lox_h", "lox_radius")
113
114 if fuel_tank_volume_m3 is not None or fuel_ullage_volume_m3 is not None:
115 V_fuel = resolve_tank_volume_m3(
116 tank_volume_m3=fuel_tank_volume_m3,
117 ullage_volume_m3=fuel_ullage_volume_m3,
118 propellant_mass_kg=m_fuel,
119 density_kg_m3=rho_fuel,
120 tank_name="Fuel tank",
121 )
122 else:
123 V_fuel = _config_tank_volume(config, "fuel_tank", "rp1_h", "rp1_radius")
124
125 return V_lox, m_lox, rho_lox, V_fuel, m_fuel, rho_fuel
126
127
128def _config_tank_volume(config: Any, tank_attr: str, h_attr: str, r_attr: str) -> float:
129 tank = getattr(config, tank_attr, None)
130 if tank and hasattr(tank, "tank_volume_m3") and tank.tank_volume_m3 is not None:
131 return float(tank.tank_volume_m3)
132 if tank and hasattr(tank, h_attr) and hasattr(tank, r_attr):
133 return float(np.pi * getattr(tank, r_attr) ** 2 * getattr(tank, h_attr))
134 if hasattr(config, "propellant") and hasattr(config.propellant, "tank_volume_m3"):
135 return float(config.propellant.tank_volume_m3)
136 raise ValueError(f"{tank_attr}: tank volume not specified in config or request")
137
138
139def load_Z_lookup_table(csv_path: str) -> Tuple[RegularGridInterpolator, np.ndarray, np.ndarray]:
140 """Load compressibility factor Z(P,T) lookup table from CSV."""
141 df_z = pd.read_csv(csv_path)
142 T_vals = np.unique(np.sort(df_z["T_K"].values))
143 P_vals = np.unique(np.sort(df_z["P_Pa"].values))
144 pivot = df_z.pivot(index="T_K", columns="P_Pa", values="Z")
145 pivot = pivot.reindex(index=T_vals, columns=P_vals)
146 Z_grid = pivot.values
147 interp = RegularGridInterpolator(
148 (T_vals, P_vals),
149 Z_grid,
150 bounds_error=False,
151 fill_value=None
152 )
153 return interp, T_vals, P_vals
154
155
157 T_K: float | np.ndarray,
158 P_Pa: float | np.ndarray,
159 interp: RegularGridInterpolator,
160 default_Z: float = 1.0
161) -> float | np.ndarray:
162 """Lookup compressibility factor Z at given temperature and pressure."""
163 T_arr = np.atleast_1d(T_K).astype(float)
164 P_arr = np.atleast_1d(P_Pa).astype(float)
165
166 if T_arr.size == 1 and P_arr.size > 1:
167 T_arr = np.full_like(P_arr, T_arr[0])
168 elif P_arr.size == 1 and T_arr.size > 1:
169 P_arr = np.full_like(T_arr, P_arr[0])
170 elif T_arr.size != P_arr.size:
171 raise ValueError("Temperature and pressure arrays must have matching sizes.")
172
173 pts = np.column_stack([T_arr, P_arr])
174 Z_vals = interp(pts)
175 Z_vals = np.where(np.isnan(Z_vals), default_Z, Z_vals)
176
177 if np.isscalar(T_K) and np.isscalar(P_Pa):
178 return float(Z_vals[0])
179 return Z_vals
180
181
183 """Helper class to track individual tank state during blowdown."""
185 self,
186 vol_total: float,
187 m_prop_initial: float,
188 rho_prop: float,
189 P_initial: float,
190 T_gas_initial: float,
191 R_gas: float,
192 Z_interp: Optional[RegularGridInterpolator],
193 n_poly: float = 1.2
194 ):
195 self.V_total = vol_total
196 self.m_prop = m_prop_initial
197 self.rho = rho_prop
198 self.n_poly = n_poly
199 self.R = R_gas
200 self.Z_interp = Z_interp
201 self.T0 = T_gas_initial # Store T0 for polytropic calc
202
203 # Initial geometry
204 self.V_ullage_0 = self.V_total - (self.m_prop / self.rho)
205 if self.V_ullage_0 <= 0:
206 raise ValueError(f"Tank overfilled: V_tank={self.V_total:.4f}, m_prop={self.m_prop:.4f}, rho={self.rho}")
207
209
210 # Initial Gas State
211 self.T_gas = T_gas_initial
212 self.P_gas = P_initial
213
214 # Calculate fixed gas mass
215 Z = self.get_Z(self.T_gas, self.P_gas)
216 self.m_gas = (self.P_gas * self.V_ullage_0) / (Z * self.R * self.T_gas)
217
218 def get_Z(self, T, P):
219 if self.Z_interp:
220 return Z_lookup(T, P, self.Z_interp)
221 return 1.0
222
223 def step(self, mdot: float, dt: float, mdot_gas: float = 0.0) -> float:
224 """Advance tank state by dt.
225
226 Args:
227 mdot: Liquid propellant mass flow rate [kg/s] (leaving tank)
228 dt: Time step [s]
229 mdot_gas: Gas/Pressurant mass flow rate [kg/s] (leaving tank, venting)
230
231 Returns:
232 New Pressure [Pa]
233 """
234 if dt <= 0:
235 return self.P_gas
236
237 # 1. Update Propellant Mass (Depletion check)
238 dm = mdot * dt
239 self.m_prop = max(0.0, self.m_prop - dm)
240
241 # Explicit clamp to 0.0 for very small values to ensure consistent empty state
242 if self.m_prop < 1e-9:
243 self.m_prop = 0.0
244
245 # 2. Update Ullage Volume
246 # If prop is depleted, V_ullage assumes full tank volume
247 if self.m_prop <= 0.0:
248 self.V_ullage = self.V_total
249 else:
250 self.V_ullage = max(1e-9, self.V_total - (self.m_prop / self.rho))
251
252 # 3. Update Gas Mass (Venting)
253 if mdot_gas > 0:
254 dm_gas = mdot_gas * dt
255 self.m_gas = max(1e-9, self.m_gas - dm_gas)
256
257 # 4. Polytropic Temperature Update
258 # T = T0 * (V0 / V)^(n-1)
259 # Note: This simple polytropic relation assumes constant mass expansion.
260 # With venting (variable mass), we should ideally use first law:
261 # dU = dQ - dW + h_in*dm_in - h_out*dm_out
262 #
263 # For simple blowdown venting, isentropic expansion of remaining gas is a decent approx
264 # T2 = T1 * (P2/P1)^((g-1)/g)
265 # But we don't have P2 yet.
266 #
267 # Let's stick to the polytropic volume expansion for the 'expansion' part,
268 # but we also have mass loss.
269 # P = m*Z*R*T / V
270 # If we vent, m decreases.
271 # Simple approach for gas venting phase:
272 # Assume isentropic/polytropic expansion from LOSS of density?
273 # rho_gas = m_gas / V_ullage
274 # T = T_initial * (rho_gas / rho_gas_initial)^(n-1)
275 #
276 # Re-derive T based on current state relative to initial state?
277 # self.T_gas = self.T0 * ( (self.m_gas/self.V_ullage) / (self.m_gas_initial/self.V_ullage_0) ) ** (self.n_poly - 1.0)
278 # We need to track initial gas mass for this.
279
280 # Store initial gas mass if not present
281 if not hasattr(self, 'm_gas_0'):
282 self.m_gas_0 = self.m_gas
283
284 rho_gas_0 = self.m_gas_0 / self.V_ullage_0
285 rho_gas_current = self.m_gas / self.V_ullage
286
287 self.T_gas = self.T0 * (rho_gas_current / rho_gas_0) ** (self.n_poly - 1.0)
288
289 # 5. Solves Pressure Iteratively (since Z depends on P)
290 self.P_gas = self.solve_pressure(self.T_gas, self.V_ullage, self.P_gas)
291
292 return self.P_gas
293
294 def solve_pressure(self, T, V, P_guess):
295 P = P_guess
296 # Fixed point iteration with max steps
297 for _ in range(10):
298 Z = self.get_Z(T, P)
299 P_new = (self.m_gas * Z * self.R * T) / V
300
301 # Check convergence
302 if abs(P_new - P) / max(1.0, P_new) < 1e-6:
303 return P_new
304
305 P = P_new
306
307 return P
308
309
311 P_up: float,
312 P_down: float,
313 T_up: float,
314 Area: float,
315 gamma: float,
316 R: float,
317 Cd: float = 0.8
318) -> float:
319 """Calculate compressible gas mass flow through an orifice (venting)."""
320 if P_up <= P_down:
321 return 0.0
322
323 pr_ratio = P_down / P_up
324
325 # Critical pressure ratio for choked flow
326 pr_crit = (2 / (gamma + 1)) ** (gamma / (gamma - 1))
327
328 if pr_ratio < pr_crit:
329 # Choked flow
330 # mdot = Cd * A * P_up * sqrt(gamma/(R*T_up)) * (2/(gamma+1))^((gamma+1)/(2(gamma-1)))
331 # Simplified choked flow constant
332 C_star = np.sqrt(gamma * (2/(gamma+1)) ** ((gamma+1)/(gamma-1)))
333 mdot = Cd * Area * P_up / np.sqrt(R * T_up) * C_star
334 else:
335 # Subsonic flow
336 # mdot = Cd * A * P_up * sqrt(2*gamma/((gamma-1)RT)) * sqrt(pr^(2/gamma) - pr^((gamma+1)/gamma))
337 term1 = (2 * gamma) / ((gamma - 1) * R * T_up)
338 term2 = pr_ratio ** (2 / gamma) - pr_ratio ** ((gamma + 1) / gamma)
339 mdot = Cd * Area * P_up * np.sqrt(term1 * term2)
340
341 return max(0.0, mdot)
342
343
345 times: np.ndarray,
346 evaluate_engine_fn: Callable[[float, float], Tuple[float, float]],
347 P_lox_initial_Pa: float,
348 P_fuel_initial_Pa: float,
349 config,
350 *,
351 R_pressurant: float = 296.803,
352 T_lox_gas_K: float = 250.0,
353 T_fuel_gas_K: float = 293.0,
354 n_polytropic: float = 1.2,
355 use_real_gas: bool = True,
356 n2_Z_csv: str = "n2_Z_lookup.csv",
357 total_propellant_kg: Optional[float] = None,
358 of_ratio: Optional[float] = None,
359 lox_tank_volume_m3: Optional[float] = None,
360 lox_ullage_volume_m3: Optional[float] = None,
361 fuel_tank_volume_m3: Optional[float] = None,
362 fuel_ullage_volume_m3: Optional[float] = None,
363 lox_propellant_mass_kg: Optional[float] = None,
364 fuel_propellant_mass_kg: Optional[float] = None,
365) -> Dict[str, Dict[str, np.ndarray]]:
366 """
367 Simulate coupled blowdown for both LOX and fuel tanks.
368
369 Parameters
370 ----------
371 times : np.ndarray
372 Time points (s)
373 evaluate_engine_fn : function
374 Callback (P_lox_Pa, P_fuel_Pa) -> (mdot_lox_kg_s, mdot_fuel_kg_s)
375 P_lox_initial_Pa, P_fuel_initial_Pa : float
376 Initial tank pressures
377 config : EngineConfig
378 Configuration object with tank geometry
379
380 Returns
381 -------
382 dict with full time-series traces for both tanks
383 """
384 # Load Z lookup
385 Z_interp = None
386 if use_real_gas:
387 Z_interp, _, _ = load_Z_lookup_table(n2_Z_csv)
388
389 # Helpers to extract config (legacy fallback when no overrides supplied)
390 def get_tank_params(fluid_key, tank_attr, h_attr, r_attr):
391 rho = float(config.fluids[fluid_key].density)
392
393 tank = getattr(config, tank_attr, None)
394 if tank and hasattr(tank, 'tank_volume_m3') and tank.tank_volume_m3 is not None:
395 V = float(tank.tank_volume_m3)
396 elif tank and hasattr(tank, h_attr) and hasattr(tank, r_attr):
397 V = float(np.pi * getattr(tank, r_attr)**2 * getattr(tank, h_attr))
398 elif hasattr(config, 'propellant') and hasattr(config.propellant, 'tank_volume_m3'):
399 V = float(config.propellant.tank_volume_m3)
400 else:
401 V = 0.01
402
403 if tank and hasattr(tank, 'mass'):
404 m = float(tank.mass)
405 elif hasattr(config, 'propellant') and hasattr(config.propellant, 'initial_mass_kg'):
406 m = float(config.propellant.initial_mass_kg)
407 else:
408 m = 0.0
409
410 return rho, V, m
411
412 has_tank_overrides = any(
413 v is not None
414 for v in (
415 total_propellant_kg,
416 lox_tank_volume_m3,
417 lox_ullage_volume_m3,
418 fuel_tank_volume_m3,
419 fuel_ullage_volume_m3,
420 lox_propellant_mass_kg,
421 fuel_propellant_mass_kg,
422 )
423 )
424 if has_tank_overrides:
425 V_lox, m_lox, rho_lox, V_fuel, m_fuel, rho_fuel = resolve_blowdown_tank_state(
426 config,
427 total_propellant_kg=total_propellant_kg,
428 of_ratio=of_ratio,
429 lox_tank_volume_m3=lox_tank_volume_m3,
430 lox_ullage_volume_m3=lox_ullage_volume_m3,
431 fuel_tank_volume_m3=fuel_tank_volume_m3,
432 fuel_ullage_volume_m3=fuel_ullage_volume_m3,
433 lox_propellant_mass_kg=lox_propellant_mass_kg,
434 fuel_propellant_mass_kg=fuel_propellant_mass_kg,
435 )
436 else:
437 rho_lox, V_lox, m_lox = get_tank_params('oxidizer', 'lox_tank', 'lox_h', 'lox_radius')
438 rho_fuel, V_fuel, m_fuel = get_tank_params('fuel', 'fuel_tank', 'rp1_h', 'rp1_radius')
439
440 # Helper to calculate approximate injector discharge area
441 # NOTE: For gas venting after propellant depletion, we use a LARGER effective area
442 # because: (1) gas flows much faster than liquid, (2) real systems have relief valves
443 GAS_VENTING_AREA_MULTIPLIER = 10.0 # Gas venting uses 10x larger effective area
444
445 def get_injector_area(fluid_type: str, for_gas_venting: bool = False) -> float:
446 try:
447 inj = config.injector
448 geom = inj.geometry
449 if inj.type == 'pintle':
450 if fluid_type == 'oxidizer':
451 # LOX flow: n_orifices * A_entry
452 liquid_area = float(geom.lox.n_orifices * geom.lox.A_entry)
453 else:
454 # Fuel flow: Gap area is likely the choke point
455 # A_gap = pi * d_tip * h_gap
456 liquid_area = float(np.pi * geom.fuel.d_pintle_tip * geom.fuel.h_gap)
457 elif inj.type == 'coaxial':
458 if fluid_type == 'oxidizer':
459 # Core flow: n_elements * A_port
460 liquid_area = float(geom.core.n_ports * np.pi * (geom.core.d_port/2)**2)
461 else:
462 # Annulus flow
463 liquid_area = float(geom.core.n_ports * np.pi * ((geom.annulus.inner_diameter + 2*geom.annulus.gap_thickness)/2)**2 - np.pi*(geom.annulus.inner_diameter/2)**2)
464 else:
465 # Default fallback
466 liquid_area = 1e-4
467
468 # For gas venting, use larger effective area for rapid pressure drop
469 if for_gas_venting:
470 return max(liquid_area * GAS_VENTING_AREA_MULTIPLIER, 1e-3) # Min 10 cm² for gas venting
471 return max(liquid_area, 1e-5)
472 except Exception:
473 return 1e-3 if for_gas_venting else 1e-4
474
475 A_inj_lox = get_injector_area('oxidizer')
476 A_inj_fuel = get_injector_area('fuel')
477
478 # Initialize Tank States
479 lox_tank = TankState(V_lox, m_lox, rho_lox, P_lox_initial_Pa, T_lox_gas_K, R_pressurant, Z_interp, n_polytropic)
480 fuel_tank = TankState(V_fuel, m_fuel, rho_fuel, P_fuel_initial_Pa, T_fuel_gas_K, R_pressurant, Z_interp, n_polytropic)
481
482 # Arrays to store history
483 N = len(times)
484 history = {
485 'lox': {k: np.zeros(N) for k in ['P_Pa', 'T_K', 'V_ullage_m3', 'mdot_kg_s', 'm_prop_kg']},
486 'fuel': {k: np.zeros(N) for k in ['P_Pa', 'T_K', 'V_ullage_m3', 'mdot_kg_s', 'm_prop_kg']}
487 }
488 # Add depletion flags for flameout masking
489 history['lox']['is_depleted'] = np.zeros(N, dtype=bool)
490 history['fuel']['is_depleted'] = np.zeros(N, dtype=bool)
491
492 # Initial Conditions (t=0)
493 # We need initial mdot based on initial Pressure
494 mdot_lox_0, mdot_fuel_0 = evaluate_engine_fn(P_lox_initial_Pa, P_fuel_initial_Pa)
495
496 # Store t=0
497 history['lox']['P_Pa'][0] = lox_tank.P_gas
498 history['lox']['T_K'][0] = lox_tank.T_gas
499 history['lox']['V_ullage_m3'][0] = lox_tank.V_ullage
500 history['lox']['mdot_kg_s'][0] = mdot_lox_0
501 history['lox']['m_prop_kg'][0] = lox_tank.m_prop
502
503 history['fuel']['P_Pa'][0] = fuel_tank.P_gas
504 history['fuel']['T_K'][0] = fuel_tank.T_gas
505 history['fuel']['V_ullage_m3'][0] = fuel_tank.V_ullage
506 history['fuel']['mdot_kg_s'][0] = mdot_fuel_0
507 history['fuel']['m_prop_kg'][0] = fuel_tank.m_prop
508
509 # ambient pressure for venting
510 P_amb = 101325.0
511 gamma_gas = 1.4 # Nitrogen
512
513 # Time Stepping Loop
514 for i in range(1, N):
515 dt = times[i] - times[i-1]
516
517 # Get flows from PREVIOUS step
518 mdot_lox_liquid_prev = history['lox']['mdot_kg_s'][i-1]
519 mdot_fuel_liquid_prev = history['fuel']['mdot_kg_s'][i-1]
520
521 # Determine if we are in gas venting phase for either tank
522 # LOX - track depletion state
523 lox_depleted = lox_tank.m_prop <= 1e-4 # effectively empty
524 if lox_depleted:
525 mdot_lox_liquid = 0.0
526 # Use LARGER venting area for rapid pressure drop
527 A_vent_lox = get_injector_area('oxidizer', for_gas_venting=True)
528 mdot_lox_gas = calculate_compressible_gas_flow(
529 lox_tank.P_gas, P_amb, lox_tank.T_gas, A_vent_lox, gamma_gas, R_pressurant
530 )
531 else:
532 mdot_lox_liquid = mdot_lox_liquid_prev
533 mdot_lox_gas = 0.0
534
535 # Fuel - track depletion state
536 fuel_depleted = fuel_tank.m_prop <= 1e-4
537 if fuel_depleted:
538 mdot_fuel_liquid = 0.0
539 # Use LARGER venting area for rapid pressure drop
540 A_vent_fuel = get_injector_area('fuel', for_gas_venting=True)
541 mdot_fuel_gas = calculate_compressible_gas_flow(
542 fuel_tank.P_gas, P_amb, fuel_tank.T_gas, A_vent_fuel, gamma_gas, R_pressurant
543 )
544 else:
545 mdot_fuel_liquid = mdot_fuel_liquid_prev
546 mdot_fuel_gas = 0.0
547
548 # Step Tanks with appropriate flows
549 P_lox_new = lox_tank.step(mdot_lox_liquid, dt, mdot_lox_gas)
550 P_fuel_new = fuel_tank.step(mdot_fuel_liquid, dt, mdot_fuel_gas)
551
552 # Evaluate Engine with NEW pressures to get NEW LIQUID mdot
553 # CRITICAL: If EITHER tank is depleted, engine flame-out occurs -> zero flow
554 if lox_depleted or fuel_depleted:
555 # Flameout condition: no combustion without both propellants
556 mdot_lox_new = 0.0
557 mdot_fuel_new = 0.0
558 elif lox_tank.m_prop > 0 and fuel_tank.m_prop > 0:
559 mdot_lox_new, mdot_fuel_new = evaluate_engine_fn(P_lox_new, P_fuel_new)
560 else:
561 # Safety fallback
562 mdot_lox_new = 0.0
563 mdot_fuel_new = 0.0
564
565 # Store History
566 history['lox']['P_Pa'][i] = P_lox_new
567 history['lox']['T_K'][i] = lox_tank.T_gas
568 history['lox']['V_ullage_m3'][i] = lox_tank.V_ullage
569 history['lox']['mdot_kg_s'][i] = mdot_lox_new # This tracks LIQUID flow for engine coupling
570 history['lox']['m_prop_kg'][i] = lox_tank.m_prop
571 history['lox']['is_depleted'][i] = lox_depleted
572
573 history['fuel']['P_Pa'][i] = P_fuel_new
574 history['fuel']['T_K'][i] = fuel_tank.T_gas
575 history['fuel']['V_ullage_m3'][i] = fuel_tank.V_ullage
576 history['fuel']['mdot_kg_s'][i] = mdot_fuel_new
577 history['fuel']['m_prop_kg'][i] = fuel_tank.m_prop
578 history['fuel']['is_depleted'][i] = fuel_depleted
579
580 return history
#define max(a, b)
Definition Arduino.h:43
float step(self, float mdot, float dt, float mdot_gas=0.0)
solve_pressure(self, T, V, P_guess)
__init__(self, float vol_total, float m_prop_initial, float rho_prop, float P_initial, float T_gas_initial, float R_gas, Optional[RegularGridInterpolator] Z_interp, float n_poly=1.2)
Dict[str, Dict[str, np.ndarray]] simulate_coupled_blowdown(np.ndarray times, Callable[[float, float], Tuple[float, float]] evaluate_engine_fn, float P_lox_initial_Pa, float P_fuel_initial_Pa, config, *, float R_pressurant=296.803, float T_lox_gas_K=250.0, float T_fuel_gas_K=293.0, float n_polytropic=1.2, bool use_real_gas=True, str n2_Z_csv="n2_Z_lookup.csv", Optional[float] total_propellant_kg=None, Optional[float] of_ratio=None, Optional[float] lox_tank_volume_m3=None, Optional[float] lox_ullage_volume_m3=None, Optional[float] fuel_tank_volume_m3=None, Optional[float] fuel_ullage_volume_m3=None, Optional[float] lox_propellant_mass_kg=None, Optional[float] fuel_propellant_mass_kg=None)
float|np.ndarray Z_lookup(float|np.ndarray T_K, float|np.ndarray P_Pa, RegularGridInterpolator interp, float default_Z=1.0)
float _config_tank_volume(Any config, str tank_attr, str h_attr, str r_attr)
Tuple[RegularGridInterpolator, np.ndarray, np.ndarray] load_Z_lookup_table(str csv_path)
Tuple[float, float] split_propellant_by_of(float total_kg, float of_ratio)
Tuple[float, float, float, float, float, float] resolve_blowdown_tank_state(Any config, *, Optional[float] total_propellant_kg=None, Optional[float] of_ratio=None, Optional[float] lox_tank_volume_m3=None, Optional[float] lox_ullage_volume_m3=None, Optional[float] fuel_tank_volume_m3=None, Optional[float] fuel_ullage_volume_m3=None, Optional[float] lox_propellant_mass_kg=None, Optional[float] fuel_propellant_mass_kg=None)
float calculate_compressible_gas_flow(float P_up, float P_down, float T_up, float Area, float gamma, float R, float Cd=0.8)
float resolve_tank_volume_m3(*, Optional[float] tank_volume_m3, Optional[float] ullage_volume_m3, float propellant_mass_kg, float density_kg_m3, str tank_name="tank")