STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
time_varying_solver.py
Go to the documentation of this file.
1"""Time-varying engine solve over a burn.
2
3Each step advances the wall -- liner stations along the chamber, the throat (graphite insert
4or liner) and the exit when it ablates -- by transient conduction under the gas-side loads of
5the previous step (Bartz convection, H2O/CO2 radiation, carbon oxidation at the throat), then
6rebuilds the geometry from the cumulative recession and solves Pc and thrust on it. Reaction
7progress and stability are evaluated at every step on the same geometry.
8"""
9
10from __future__ import annotations
11
12from typing import Dict, List, Optional, Any
13from dataclasses import dataclass
14import numpy as np
15import copy
16
17from engine.pipeline.config_schemas import PintleEngineConfig, ensure_chamber_geometry
18from engine.core.chamber_solver import ChamberSolver
19from engine.core.nozzle import calculate_thrust
21 calculate_chamber_reaction_progress,
22)
23from engine.pipeline.thermal import gas_side
24from engine.pipeline.thermal.graphite_cooling import carbon_oxidation
25from engine.pipeline.thermal.graphite_properties import SPECIFIC_HEAT_MODELS
26from engine.pipeline.thermal.wall_conduction import Layer, WallModel
27from engine.pipeline.constants import STEFAN_BOLTZMANN_W_M2_K4
29 calculate_chugging_frequency,
30 calculate_acoustic_modes,
31)
32
33#: Implicit sub-step of the wall conduction [s]. The liner surface reaches its ablation
34#: temperature in a few tenths of a second; this resolves that rise to ~1 % (checked against
35#: the constant-flux semi-infinite solution in tests/test_wall_conduction.py).
36WALL_SUBSTEP_S = 0.01
37
38
39@dataclass
41 """Complete state of the engine at a given time."""
42 time: float # [s]
43
44 # Geometry (evolving)
45 V_chamber: float # [m³]
46 A_throat: float # [m²] # CRITICAL: Stays constant with graphite insert
47 A_exit: float # [m²]
48 Lstar: float # [m]
49 D_chamber: float # [m]
50 D_throat: float # [m] # CRITICAL: Stays constant with graphite insert
51 D_exit: float # [m]
52 eps: float # Expansion ratio
53
54 # Cumulative recession
55 recession_chamber: float # [m]
56 recession_throat: float # [m] # Ablative recession at throat (if no graphite)
57 recession_exit: float # [m]
58 recession_graphite: float # [m] # Graphite insert recession
59 graphite_thickness_remaining: float # [m] # Remaining graphite thickness
60
61 # Reaction chemistry
62 reaction_progress: Dict[str, float] # progress_injection, progress_mid, progress_throat
63 tau_residence: float # [s]
64 tau_effective: float # [s]
65
66 # Performance
67 Pc: float # [Pa]
68 Tc: float # [K]
69 MR: float
70 mdot_total: float # [kg/s]
71 F: float # [N]
72 Isp: float # [s]
73 v_exit: float # [m/s]
74 P_exit: float # [Pa]
75 T_exit: float # [K]
76 M_exit: float
77
78 # Thermodynamics
79 gamma_chamber: float
80 gamma_exit: float
81 R_chamber: float
82 R_exit: float
83 equilibrium_factor: float
84
85 # Stability
86 chugging_frequency: float # [Hz]
87 chugging_stability_margin: float
88 stability_state: str # "stable", "marginal", or "unstable"
89 stability_score: float # 0-1 score from comprehensive analysis
90 acoustic_modes: Dict[str, float]
91 feed_stability: Dict[str, float]
92
93 # Heat flux and cooling
94 heat_flux_chamber: float # [W/m²]
95 heat_flux_throat: float # [W/m²]
96 ablative_recession_rate: float # [m/s]
97 graphite_recession_rate: float # [m/s]
98 # Throat recession breakdown (graphite model)
99 throat_oxidation_recession_rate: float # [m/s]
100 throat_ablation_recession_rate: float # [m/s] (thermal/sublimation component)
101
102 # Chamber intrinsics (TIME-VARYING - these change as geometry evolves)
103 mach_number: float # Chamber Mach number (should change over time, not hardcoded)
104 eta_cstar: float # Combustion efficiency n* (should change over time)
105 reynolds_number: float # Reynolds number
106 residence_time: float # [s]
107
108 # Wall thermal state (transient conduction, engine.pipeline.thermal.wall_conduction)
109 T_liner_surface: float # [K] barrel char surface
110 char_depth_chamber: float # [m] pyrolysis isotherm below the barrel surface
111 T_bondline: float # [K] back of the liner, barrel station
112 recession_liner_peak: float # [m] cumulative, liner station that has receded most
113 char_depth_peak: float # [m] pyrolysis depth at that station
114 T_bondline_peak: float # [K] hottest liner back face along the liner
115 x_liner_peak: float # [m] throat-frame axial position of that station
116 q_conv_chamber: float # [W/m^2] barrel, at the current surface temperature
117 q_rad_chamber: float # [W/m^2]
118 q_conv_throat: float # [W/m^2] throat, blown, at the current surface temperature
119 q_rad_throat: float # [W/m^2]
120 q_chem_throat: float # [W/m^2] absorbed by the carbon-oxidiser reactions
121 T_graphite_surface: float # [K]
122 T_graphite_back: float # [K] insert back face (adiabatic: backing undeclared)
123 cstar_ideal: float # [m/s]
124 cstar_actual: float # [m/s]
125
126 # Full diagnostics from ChamberSolver (includes ablative heat flux profiles)
127 # Must be at end since it has a default value
128 diagnostics: Optional[Dict[str, Any]] = None
129
130 # Every wall station's state at this time (TimeVaryingCoupledSolver._station_report):
131 # recession, thickness left, surface/back/interface temperatures and the gas-side flux at the
132 # current surface temperature. A read-out only; nothing in it feeds the solve.
133 stations: Optional[Dict[str, Dict[str, Any]]] = None
134
135
137 """
138 Fully-coupled time-varying solver for complete engine analysis.
139
140 Integrates all systems simultaneously:
141 - Reaction chemistry → shifting equilibrium
142 - Geometry evolution → chamber dynamics
143 - Ablative/graphite recession → geometry
144 - Stability analysis → all time-varying effects
145 """
146
148 self,
149 config: PintleEngineConfig,
150 cea_cache: Any,
151 P_ambient: Optional[float] = None,
152 chug_eroded_geometry: bool = False,
153 ):
154 """
155 Initialize the coupled time-varying solver.
156
157 ``chug_eroded_geometry`` (default off: the previous behaviour exactly) hands the stability
158 analysis the eroded geometry of each step -- throat area, chamber volume, L*, bore, exit
159 and expansion ratio, the copy the chamber was solved on -- instead of the as-built design.
160 Off, the chug loop's K_c and theta_c rest on the design-point A_t and L* while the chamber
161 it describes has eroded (Layer X audit D7-D, coupling-map row 19).
162
163 ``P_ambient`` is the back pressure the nozzle fires into, in Pa. Explicit wins;
164 otherwise it comes from ``environment.elevation`` through the same standard
165 atmosphere the steady solve uses; only with neither is it sea level. This used to be
166 hardcoded to 101325 Pa inside ``solve_time_step`` while ``PintleEngineRunner.evaluate``
167 derived it from the site, so the two paths disagreed about the same engine by exactly
168 ``(101325 - P_a) * A_exit`` -- 61.35 N on the 6.5 kN ethalox at 626.67 m -- and every
169 time-series thrust, impulse and burn time was low by the pad's altitude.
170
171 Parameters:
172 -----------
173 config : PintleEngineConfig
174 Engine configuration
175 cea_cache : CEACache
176 CEA cache for thermochemical properties
177 """
178 self.config = config
179 self.cea_cache = cea_cache
180 self.chug_eroded_geometry = bool(chug_eroded_geometry)
181 if P_ambient is not None:
182 self.P_ambient = float(P_ambient)
183 else:
184 self.P_ambient = 101325.0
185 env = getattr(config, "environment", None)
186 elevation = getattr(env, "elevation", None) if env is not None else None
187 if elevation is not None and elevation >= 0:
188 from engine.core.runner import compute_ambient_pressure_from_elevation
189 self.P_ambient = float(compute_ambient_pressure_from_elevation(elevation))
190
191 # Ensure chamber_geometry exists
192 cg = ensure_chamber_geometry(config)
193
194 # Store initial geometry
195 self.V_chamber_initial = cg.volume
196 self.A_throat_initial = cg.A_throat
197 self.A_exit_initial = cg.A_exit
198 self.L_chamber = cg.length if cg.length else 0.18
199 self.L_cylindrical = cg.length_cylindrical if cg.length_cylindrical else 0.12
200 self.L_contraction = cg.length_contraction if cg.length_contraction else 0.06
201 # FIXED: Use chamber_diameter from unified config
202 self.D_chamber_initial = cg.chamber_diameter if cg.chamber_diameter and cg.chamber_diameter > 0 else 0.08
203 self.D_throat_initial = np.sqrt(max(0, 4 * self.A_throat_initial / np.pi)) if self.A_throat_initial > 0 else 0.015
204 self.D_exit_initial = np.sqrt(max(0, 4 * self.A_exit_initial / np.pi)) if self.A_exit_initial > 0 else 0.1
205
206 # Initialize state history
207 self.state_history: List[TimeVaryingState] = []
208 self._contour0 = gas_side.contour_for(cg)
209 self._walls: Optional[Dict[str, Dict[str, Any]]] = None
210 self._loads: Optional[Dict[str, Any]] = None
211
212 # ------------------------------------------------------------------ walls
213 def _liner_layers(self) -> List[Layer]:
214 abl = self.config.ablative_cooling
215 layers = [Layer(abl.initial_thickness, abl.thermal_conductivity, abl.material_density,
216 abl.specific_heat, "liner")]
217 case = getattr(self.config, "stainless_steel_case", None)
218 if case is not None and case.enabled:
219 layers.append(Layer(case.thickness, case.thermal_conductivity, case.density,
220 case.specific_heat, "case"))
221 return layers
222
223 def _graphite_layers(self) -> List[Layer]:
224 gr = self.config.graphite_insert
225 layers = [Layer(gr.initial_thickness, gr.thermal_conductivity, gr.material_density,
226 gr.specific_heat, "graphite",
227 cp_of=SPECIFIC_HEAT_MODELS[gr.specific_heat_model])]
228 case = getattr(self.config, "stainless_steel_case", None)
229 if case is not None and case.enabled:
230 backing = self._insert_backing()
231 if backing is not None:
232 layers.append(backing)
233 layers.append(Layer(case.thickness, case.thermal_conductivity, case.density,
234 case.specific_heat, "case"))
235 return layers
236
237 def _insert_backing(self) -> Optional[Layer]:
238 """What sits between the graphite insert and the case at the throat.
239
240 The case is a cylinder at the chamber bore plus the liner (``D_chamber/2 + t_liner``); the
241 insert's outside is the throat radius plus its thickness. The gap between them is filled with
242 the liner's material, the only other material the config declares (assumed: the config does
243 not say what backs the insert). Without it the graphite sat directly on the steel, which
244 sank its heat into the case: LE4's throat growth fell from ~4 % to ~1.5 % (2026-10-03). None
245 when there is no liner or no gap."""
246 abl = getattr(self.config, "ablative_cooling", None)
247 gr = self.config.graphite_insert
248 if abl is None or not abl.enabled:
249 return None
250 gap = 0.5 * float(self.D_chamber_initial) + float(abl.initial_thickness) \
251 - (0.5 * float(self.D_throat_initial) + float(gr.initial_thickness))
252 if gap <= 0.0:
253 return None
254 return Layer(gap, abl.thermal_conductivity, abl.material_density, abl.specific_heat, "backing")
255
256 def liner_end(self) -> float:
257 """Throat-frame x where the liner meets the graphite insert (0 without one)."""
258 gr = getattr(self.config, "graphite_insert", None)
259 if gr is None or not gr.enabled:
260 return 0.0
261 return -float(gr.axial_half_length or gr.axial_half_length_ratio * 2.0 * self._contour0.R_t)
262
263 def _build_walls(self) -> Dict[str, Dict[str, Any]]:
264 """Wall stations: liner along the chamber, the throat, and the exit if it ablates."""
265 c = self._contour0
266 abl = self.config.ablative_cooling
267 gr = getattr(self.config, "graphite_insert", None)
268 walls: Dict[str, Dict[str, Any]] = {}
269 if abl is not None and abl.enabled:
270 x_end = self.liner_end()
271 xs = [0.5 * (c.x_face + c.x_cone_start), c.x_cone_start,
272 0.5 * (c.x_cone_start + x_end), x_end]
273 for i, x in enumerate(xs):
274 walls[f"liner{i}"] = {"x": float(x), "kind": "liner",
275 "model": WallModel(self._liner_layers(), abl.ambient_temperature)}
276 if gr is not None and gr.enabled:
277 walls["throat"] = {"x": 0.0, "kind": "graphite",
278 "model": WallModel(self._graphite_layers(), gr.ambient_temperature or 300.0,
279 n_first=40, first_cell=2e-5)}
280 elif abl is not None and abl.enabled:
281 walls["throat"] = {"x": 0.0, "kind": "liner",
282 "model": WallModel(self._liner_layers(), abl.ambient_temperature)}
283 if abl is not None and abl.enabled and abl.nozzle_ablative:
284 walls["exit"] = {"x": float(c.x[-1]), "kind": "liner",
285 "model": WallModel(self._liner_layers(), abl.ambient_temperature)}
286 return walls
287
288 def _station_loads(self, gas: "gas_side.HotGasState", contour, blow: float,
289 throat_comp: Optional[Dict[str, float]]) -> Dict[str, Any]:
290 """Gas-side load functions q_in(T_s) per wall station, frozen over the next interval."""
291 abl = self.config.ablative_cooling
292 gr = getattr(self.config, "graphite_insert", None)
293 loads: Dict[str, Any] = {}
294 for name, w in (self._walls or {}).items():
295 model = w["model"]
296 Ts_now = float(model.T[0])
297 eps_w = (gr.emissivity or 0.8) if w["kind"] == "graphite" else abl.surface_emissivity
298 st = gas_side.station_flux(gas, contour, w["x"], Ts_now, eps_w)
299 M = st["M"]
300 h_ref = st["h"] / gas_side.bartz_sigma(Ts_now, gas.T0, gas.gamma, M)
301 Taw, Tg, eg, ag = st["Taw"], st["T_static"], st["eps_gas"], st["alpha_gas"]
302 F_rad = eps_w / (1.0 - (1.0 - eps_w) * (1.0 - ag))
303 sig = STEFAN_BOLTZMANN_W_M2_K4
304
305 def h_of(s, h_ref=h_ref, M=M):
306 return h_ref * gas_side.bartz_sigma(s, gas.T0, gas.gamma, M)
307
308 def q_rad(s, F_rad=F_rad, eg=eg, ag=ag, Tg=Tg):
309 return F_rad * sig * (eg * Tg ** 4 - ag * s ** 4)
310
311 if w["kind"] == "graphite":
312 P_t = st["P_static"]
313
314 def chem(s, h_of=h_of, P_t=P_t):
315 return carbon_oxidation(s, P_t, throat_comp, throat_comp["MW"], h_of(s) / gas.cp, gr)
316
317 def q_in(s, h_of=h_of, Taw=Taw, q_rad=q_rad, chem=chem):
318 ox = chem(s)
319 return ox["blowing_factor"] * h_of(s) * (Taw - s) + q_rad(s) - ox["q_chem"]
320
321 loads[name] = {"q_in": q_in, "chem": chem, "h_of": h_of, "Taw": Taw, "q_rad": q_rad}
322 else:
323 def q_in(s, h_of=h_of, Taw=Taw, q_rad=q_rad):
324 return blow * h_of(s) * (Taw - s) + q_rad(s)
325
326 loads[name] = {"q_in": q_in, "h_of": h_of, "Taw": Taw, "q_rad": q_rad}
327 return loads
328
329 def _advance_walls(self, dt: float) -> Dict[str, float]:
330 """Advance every wall station over dt under the loads of the previous state."""
331 rates: Dict[str, float] = {}
332 if not self._walls or self._loads is None or dt <= 0:
333 return {name: 0.0 for name in (self._walls or {})}
334 abl = self.config.ablative_cooling
335 gr = getattr(self.config, "graphite_insert", None)
336 n_sub = int(np.ceil(dt / WALL_SUBSTEP_S - 1e-9))
337 for name, w in self._walls.items():
338 L = self._loads[name]
339 model = w["model"]
340 if w["kind"] == "graphite":
341 if gr.sizing_only_mode:
342 out = model.advance(dt, n_sub, q_in=L["q_in"])
343 else:
344 out = model.advance(dt, n_sub, q_in=L["q_in"],
345 chemical_mass_flux=lambda s, L=L: L["chem"](s)["mass_flux"],
346 rho_surface=gr.material_density)
347 rates[name] = out["mass_flux"] / gr.material_density
348 else:
349 out = model.advance(dt, n_sub, q_in=L["q_in"], T_ablation=abl.ablation_surface_temperature,
350 H_surface=abl.heat_of_ablation, rho_surface=abl.material_density)
351 rates[name] = out["mass_flux"] / abl.material_density
352 return rates
353
354 def _geometry(self) -> Dict[str, float]:
355 """Geometry from the cumulative wall recession: bore, volume, throat and exit."""
356 c = self._contour0
357 abl = self.config.ablative_cooling
358 walls = self._walls or {}
359 liner = sorted([(w["x"], w["model"].receded) for n, w in walls.items() if n.startswith("liner")])
360 s_throat = walls["throat"]["model"].receded if "throat" in walls else 0.0
361 s_exit = walls["exit"]["model"].receded if "exit" in walls else 0.0
362 dV = 0.0
363 s_barrel = 0.0
364 if liner:
365 xs = np.array([p[0] for p in liner])
366 ss = np.array([p[1] for p in liner])
367 s_barrel = float(ss[0])
368 dA = c.area_elements()
369 x_end = self.liner_end()
370 lined = (c.x <= x_end + 1e-12)
371 cov = float(np.clip(abl.coverage_fraction, 0.0, 1.0))
372 dV += float(np.sum(np.interp(c.x[lined], xs, ss) * dA[lined])) * cov
373 insert = (c.x > x_end + 1e-12) & (c.x <= 0.0)
374 dV += float(np.sum(dA[insert])) * s_throat
375 D_chamber = self.D_chamber_initial + 2.0 * s_barrel
376 D_throat = self.D_throat_initial + 2.0 * s_throat
377 A_throat = np.pi * D_throat ** 2 / 4.0
378 D_exit = self.D_exit_initial + 2.0 * s_exit
379 A_exit = np.pi * D_exit ** 2 / 4.0 if s_exit > 0 else self.A_exit_initial
380 V = self.V_chamber_initial + dV
381 return {"V_chamber": V, "A_throat": A_throat, "A_exit": A_exit, "D_chamber": D_chamber,
382 "D_throat": D_throat, "D_exit": D_exit, "recession_chamber": s_barrel,
383 "recession_throat": s_throat, "recession_exit": s_exit}
384
386 self,
387 time: float,
388 dt: float,
389 P_tank_O: float,
390 P_tank_F: float,
391 previous_state: Optional[TimeVaryingState] = None,
392 ) -> TimeVaryingState:
393 """
394 Solve one time step (explicit in the wall, exact in the chamber):
395
396 1. advance the wall conduction over [t - dt, t] under the previous state's gas loads;
397 2. rebuild the geometry from the cumulative recession;
398 3. solve Pc, and compute thrust, on that same geometry;
399 4. evaluate the gas-side loads at this state for the next interval.
400
401 ``previous_state`` None starts the walls cold (ambient) at the initial geometry.
402 """
403 if previous_state is None or self._walls is None:
404 self._walls = self._build_walls()
405 self._loads = None
406 dt = 0.0
407 rates = self._advance_walls(dt)
408 geo = self._geometry()
409 A_throat = geo["A_throat"]
410 A_exit = geo["A_exit"]
411 V_chamber = geo["V_chamber"]
412 eps = A_exit / A_throat
413 Lstar = V_chamber / A_throat
414
415 config_current = copy.deepcopy(self.config)
416 cg = ensure_chamber_geometry(config_current)
417 cg.volume = V_chamber
418 cg.A_throat = A_throat
419 cg.A_exit = A_exit
420 cg.expansion_ratio = eps
421 cg.Lstar = Lstar
422 cg.chamber_diameter = geo["D_chamber"]
423
424 solver = ChamberSolver(config_current, self.cea_cache)
425 Pc, diagnostics = solver.solve(P_tank_O, P_tank_F, Pc_guess=None)
426
427 MR = diagnostics["MR"]
428 mdot_total = diagnostics["mdot_total"]
429 Tc = diagnostics["Tc"]
430 gamma_chamber = diagnostics["gamma"]
431 R_chamber = diagnostics["R"]
432 cstar_actual = diagnostics["cstar_actual"]
433 cstar_ideal = diagnostics["cstar_ideal"]
434 eta_cstar = diagnostics["eta_cstar"]
435
436 from engine.core.chamber_profiles import calculate_chamber_intrinsics
437 chamber_intrinsics = calculate_chamber_intrinsics(
438 Pc=Pc, Tc=Tc, mdot_total=mdot_total, gamma=gamma_chamber, R=R_chamber,
439 V_chamber=V_chamber, A_throat=A_throat, Lstar=Lstar, MR=MR, P_back=self.P_ambient,
440 )
441 mach_number = chamber_intrinsics["mach_number"]
442
443 # Residence time at the ideal Tc (shortest), kinetics at the effective Tc (slowest).
444 reaction_progress_dict = calculate_chamber_reaction_progress(
445 Lstar, Pc, diagnostics["Tc_ideal"], cstar_ideal, gamma_chamber, R_chamber, MR,
446 self.config, spray_diagnostics=diagnostics.get("spray_diagnostics"), Tc_kinetics=Tc,
447 )
448
449 # Thrust on the geometry the Pc was solved with.
450 thrust_results = calculate_thrust(Pc, MR, mdot_total, self.cea_cache, config_current,
451 self.P_ambient, reaction_progress=reaction_progress_dict)
452
453 # Gas-side loads at this state: reported at the current wall temperatures, and frozen
454 # as the loads for the next interval.
455 cool = diagnostics.get("cooling", {}) or {}
456 abl_diag = cool.get("ablative", {}) or {}
457 blow = float(abl_diag.get("blowing_reduction", 1.0))
458 tr = self.cea_cache.aux.transport(MR, Pc, "chamber")
459 comp_c = self.cea_cache.aux.composition(MR, Pc, "chamber")
460 comp_t = self.cea_cache.aux.composition(MR, Pc, "throat")
461 gas = gas_side.HotGasState(T0=float(diagnostics["Tc_ideal"]), P0=float(Pc), gamma=float(gamma_chamber),
462 mass_flux_throat=float(mdot_total) / A_throat, mu=tr["mu"],
463 cp=tr["cp"], Pr=tr["Pr"], x_H2O=comp_c["H2O"], x_CO2=comp_c["CO2"])
464 contour_now = gas_side.contour_for(cg)
465 self._loads = self._station_loads(gas, contour_now, blow, comp_t)
466 report = self._wall_report(rates)
467
468 # Hand the transient rates to the per-step diagnostics the UI reads.
469 if abl_diag:
470 abl_diag["recession_rate_quasi_steady"] = abl_diag.get("recession_rate")
471 abl_diag["recession_rate"] = report["rate_barrel"]
472 gr_cfg = getattr(self.config, "graphite_insert", None)
473 if gr_cfg is not None and gr_cfg.enabled:
474 cool["graphite"] = {"enabled": True, "oxidation_rate": report["rate_throat"],
475 "recession_rate_thermal": 0.0,
476 "surface_temperature": report["T_graphite_surface"],
477 "q_chemical": report["q_chem_throat"]}
478 diagnostics["cooling"] = cool
479
480 chugging = calculate_chugging_frequency(V_chamber, A_throat, cstar_actual, gamma_chamber, Pc,
481 R=R_chamber, Tc=Tc)
482 chugging_freq = chugging["frequency"]
483 stability_margin = float("nan")
484 acoustic = calculate_acoustic_modes(self.L_chamber, geo["D_chamber"], Tc, gamma_chamber, R_chamber)
485 feed_stability = {"pogo_frequency": np.nan, "surge_frequency": np.nan, "stability_margin": np.nan}
486 comprehensive_stability = None
487 try:
488 from engine.pipeline.stability.analysis import comprehensive_stability_analysis
489 # The closure's own injector and feed drops and SMDs: without them the stability
490 # analysis falls back to 0.30*Pc / 0.10*Pc and 80/60 um, which put the chug margin 13 %
491 # under forward mode's at the same point (GM 1.290 vs 1.479 on the 6.8 kN engine).
492 stability_diag = {
493 **diagnostics,
494 "mdot_O": mdot_total * MR / (1.0 + MR),
495 "mdot_F": mdot_total / (1.0 + MR),
496 "P_tank_O": P_tank_O,
497 "P_tank_F": P_tank_F,
498 }
499 # Off by default: the design-point geometry, as before. On: the geometry this step's
500 # chamber was solved on (eroded A_t, V, L*, bore, A_e, eps).
501 stability_config = config_current if self.chug_eroded_geometry else self.config
502 comprehensive_stability = comprehensive_stability_analysis(
503 config=stability_config, Pc=Pc, MR=MR, mdot_total=mdot_total, cstar=cstar_actual,
504 gamma=gamma_chamber, R=R_chamber, Tc=Tc, diagnostics=stability_diag,
505 )
506 stability_margin = comprehensive_stability.get("chugging", {}).get("stability_margin", stability_margin)
507 chugging_freq = comprehensive_stability.get("chugging", {}).get("frequency", chugging_freq)
508 acoustic = comprehensive_stability.get("acoustic", acoustic)
509 feed_stability = comprehensive_stability.get("feed_system", feed_stability)
510 except Exception as e:
511 import warnings
512 warnings.warn(f"Comprehensive stability analysis failed: {e}")
513 comprehensive_stability = None
514
515 gr_thick = (self._walls["throat"]["model"].thickness_first
516 if (self._walls and "throat" in self._walls and self._walls["throat"]["kind"] == "graphite")
517 else 0.0)
518 return TimeVaryingState(
519 time=time,
520 V_chamber=V_chamber,
521 A_throat=A_throat,
522 A_exit=A_exit,
523 Lstar=Lstar,
524 D_chamber=geo["D_chamber"],
525 D_throat=geo["D_throat"],
526 D_exit=geo["D_exit"],
527 eps=eps,
528 recession_chamber=geo["recession_chamber"],
529 recession_throat=geo["recession_throat"],
530 recession_exit=geo["recession_exit"],
531 recession_graphite=geo["recession_throat"] if (gr_cfg is not None and gr_cfg.enabled) else 0.0,
532 graphite_thickness_remaining=gr_thick,
533 reaction_progress={
534 "progress_injection": reaction_progress_dict["progress_injection"],
535 "progress_mid": reaction_progress_dict["progress_mid"],
536 "progress_throat": reaction_progress_dict["progress_throat"],
537 },
538 tau_residence=reaction_progress_dict["tau_residence"],
539 tau_effective=reaction_progress_dict["tau_effective"],
540 Pc=Pc,
541 Tc=Tc,
542 MR=MR,
543 mdot_total=mdot_total,
544 F=thrust_results["F"],
545 Isp=thrust_results["Isp"],
546 v_exit=thrust_results["v_exit"],
547 P_exit=thrust_results["P_exit"],
548 T_exit=thrust_results["T_exit"],
549 M_exit=thrust_results["M_exit"],
550 gamma_chamber=gamma_chamber,
551 gamma_exit=thrust_results["gamma_exit"],
552 R_chamber=R_chamber,
553 R_exit=thrust_results["R_exit"],
554 equilibrium_factor=thrust_results["equilibrium_factor"],
555 chugging_frequency=chugging_freq,
556 chugging_stability_margin=stability_margin,
557 stability_state=comprehensive_stability.get("stability_state", "unstable") if comprehensive_stability else "unstable",
558 stability_score=comprehensive_stability.get("stability_score", 0.0) if comprehensive_stability else 0.0,
559 acoustic_modes=acoustic,
560 feed_stability=feed_stability,
561 heat_flux_chamber=report["q_conv_chamber"] + report["q_rad_chamber"],
562 heat_flux_throat=report["q_conv_throat"] + report["q_rad_throat"],
563 ablative_recession_rate=report["rate_barrel"],
564 graphite_recession_rate=report["rate_throat"] if (gr_cfg is not None and gr_cfg.enabled) else 0.0,
565 throat_oxidation_recession_rate=report["rate_throat"] if (gr_cfg is not None and gr_cfg.enabled) else 0.0,
566 throat_ablation_recession_rate=0.0,
567 mach_number=mach_number,
568 eta_cstar=eta_cstar,
569 reynolds_number=chamber_intrinsics.get("reynolds_number", float("nan")),
570 residence_time=chamber_intrinsics.get("residence_time", float("nan")),
571 T_liner_surface=report["T_liner_surface"],
572 char_depth_chamber=report["char_depth_chamber"],
573 T_bondline=report["T_bondline"],
574 recession_liner_peak=report["recession_liner_peak"],
575 char_depth_peak=report["char_depth_peak"],
576 T_bondline_peak=report["T_bondline_peak"],
577 x_liner_peak=report["x_liner_peak"],
578 q_conv_chamber=report["q_conv_chamber"],
579 q_rad_chamber=report["q_rad_chamber"],
580 q_conv_throat=report["q_conv_throat"],
581 q_rad_throat=report["q_rad_throat"],
582 q_chem_throat=report["q_chem_throat"],
583 T_graphite_surface=report["T_graphite_surface"],
584 T_graphite_back=report["T_graphite_back"],
585 cstar_ideal=cstar_ideal,
586 cstar_actual=cstar_actual,
587 diagnostics=diagnostics,
588 stations=self._station_report(),
589 )
590
591 def _wall_report(self, rates: Dict[str, float]) -> Dict[str, float]:
592 """Wall read-outs at the current time, fluxes at the current surface temperatures."""
593 nan = float("nan")
594 out = {k: nan for k in ("T_liner_surface", "char_depth_chamber", "T_bondline",
595 "recession_liner_peak", "char_depth_peak", "T_bondline_peak",
596 "x_liner_peak", "T_graphite_surface", "T_graphite_back")}
597 out.update({"q_conv_chamber": 0.0, "q_rad_chamber": 0.0, "q_conv_throat": 0.0,
598 "q_rad_throat": 0.0, "q_chem_throat": 0.0, "rate_barrel": 0.0, "rate_throat": 0.0})
599 walls, loads = self._walls or {}, self._loads or {}
600 abl = self.config.ablative_cooling
601 liners = [(n, w) for n, w in walls.items() if n.startswith("liner")]
602 if liners:
603 n0, w0 = liners[0]
604 m0 = w0["model"]
605 Ts = float(m0.T[0])
606 out["T_liner_surface"] = Ts
607 out["char_depth_chamber"] = m0.depth_of_isotherm(abl.pyrolysis_temperature)
608 out["T_bondline"] = m0.interface_temperature(0)
609 out["rate_barrel"] = float(rates.get(n0, 0.0))
610 if n0 in loads:
611 L = loads[n0]
612 out["q_conv_chamber"] = float(L["h_of"](Ts) * (L["Taw"] - Ts))
613 out["q_rad_chamber"] = float(L["q_rad"](Ts))
614 npk, wpk = max(liners, key=lambda nw: nw[1]["model"].receded)
615 out["recession_liner_peak"] = float(wpk["model"].receded)
616 out["char_depth_peak"] = wpk["model"].depth_of_isotherm(abl.pyrolysis_temperature)
617 out["x_liner_peak"] = float(wpk["x"])
618 out["T_bondline_peak"] = max(w["model"].interface_temperature(0) for _, w in liners)
619 if "throat" in walls:
620 m = walls["throat"]["model"]
621 Ts = float(m.T[0])
622 out["rate_throat"] = float(rates.get("throat", 0.0))
623 if walls["throat"]["kind"] == "graphite":
624 out["T_graphite_surface"] = Ts
625 out["T_graphite_back"] = m.T_back
626 if "throat" in loads:
627 L = loads["throat"]
628 q_rad = float(L["q_rad"](Ts))
629 if "chem" in L:
630 ox = L["chem"](Ts)
631 out["q_conv_throat"] = float(ox["blowing_factor"] * L["h_of"](Ts) * (L["Taw"] - Ts))
632 out["q_chem_throat"] = float(ox["q_chem"])
633 else:
634 out["q_conv_throat"] = float(L["h_of"](Ts) * (L["Taw"] - Ts))
635 out["q_rad_throat"] = q_rad
636 return out
637
638 def _station_report(self) -> Dict[str, Dict[str, Any]]:
639 """Every wall station now: recession, first-layer thickness left, surface, back-face and
640 first-interface temperatures, and the gas-side flux at the current surface temperature
641 under the loads just built for the next interval (the same evaluation ``_wall_report``
642 makes for the barrel and throat). Read-only: nothing here feeds the solve.
643
644 ``q_conv`` is the convection the wall model takes (blown: pyrolysis blowing on the liner,
645 the oxidation blowing factor on graphite), ``q_chem`` the heat the carbon-oxidiser
646 reactions absorb (graphite only), ``q_net = q_conv + q_rad - q_chem`` the surface load."""
647 nan = float("nan")
648 walls, loads = self._walls or {}, self._loads or {}
649 out: Dict[str, Dict[str, Any]] = {}
650 for name, w in walls.items():
651 m = w["model"]
652 Ts = float(m.T[0])
653 rec: Dict[str, Any] = {
654 "x": float(w["x"]), "kind": w["kind"], "receded": float(m.receded),
655 "remaining": float(m.thickness_first), "T_surface": Ts, "T_back": float(m.T_back),
656 "T_interface": float(m.interface_temperature(0)),
657 "q_conv": nan, "q_rad": nan, "q_chem": 0.0, "q_net": nan,
658 }
659 L = loads.get(name)
660 if L is not None:
661 q_rad = float(L["q_rad"](Ts))
662 if "chem" in L:
663 ox = L["chem"](Ts)
664 q_conv = float(ox["blowing_factor"] * L["h_of"](Ts) * (L["Taw"] - Ts))
665 q_chem = float(ox["q_chem"])
666 else:
667 q_conv = float(L["q_in"](Ts)) - q_rad
668 q_chem = 0.0
669 rec.update(q_conv=q_conv, q_rad=q_rad, q_chem=q_chem, q_net=q_conv + q_rad - q_chem)
670 out[name] = rec
671 return out
672
673 def wall_layers(self) -> Dict[str, Dict[str, Any]]:
674 """Each wall station's layer stack as built (hot face first) and its position: what the
675 conduction model was given, so a report can list it with its provenance."""
676 out: Dict[str, Dict[str, Any]] = {}
677 for name, w in (self._walls or {}).items():
678 m = w["model"]
679 out[name] = {"x": float(w["x"]), "kind": w["kind"], "T_init": float(m.T_init),
680 "layers": [{"name": lay.name, "thickness": float(lay.thickness), "k": float(lay.k),
681 "rho": float(lay.rho), "cp": float(lay.cp),
682 "cp_of_T": lay.cp_of is not None} for lay in m.layers]}
683 return out
684
685 def soak_back(self, duration: float = 120.0) -> Dict[str, Dict[str, float]]:
686 """Peak back-face and bondline temperatures while each wall's stored heat soaks back
687 after shutdown (hot face adiabatic). Run after solve_time_series; the walls are copied."""
688 out: Dict[str, Dict[str, float]] = {}
689 for name, w in (self._walls or {}).items():
690 m = copy.deepcopy(w["model"])
691 out[name] = {"x": w["x"], **m.soak(duration)}
692 return out
693
694 def soak_duration(self, factor: float = 3.0) -> Dict[str, Any]:
695 """``factor`` times the longest conduction time L^2/alpha over every layer of every wall
696 station, from the wall models' own properties: L the layer's current thickness (the hot
697 layer less its recession), alpha = k/(rho cp). A layer with cp(T) takes the larger of
698 its stated cp and cp at its hottest node, the slower diffusion.
699
700 The slowest mode of a slab with both faces adiabatic decays as exp(-pi^2 alpha t / L^2)
701 (the Fourier-series solution for a slab with insulated faces; Carslaw & Jaeger, Conduction
702 of Heat in Solids, 2nd ed., 1959, ch. III), so after 3 L^2/alpha it is down by
703 exp(-3 pi^2) ~ 1e-13: the stored heat has finished arriving."""
704 best: Dict[str, Any] = {"tau_s": 0.0, "station": None, "layer": None}
705 for name, w in (self._walls or {}).items():
706 m = w["model"]
707 for i, lay in enumerate(m.layers):
708 L = lay.thickness - (m.receded if i == 0 else 0.0)
709 if L <= 0.0 or lay.k <= 0.0:
710 continue
711 cp = float(lay.cp)
712 if lay.cp_of is not None:
713 cp = max(cp, float(np.max(lay.cp_of(np.array([float(np.max(m.T))])))))
714 tau = L * L * lay.rho * cp / lay.k
715 if tau > best["tau_s"]:
716 best = {"tau_s": float(tau), "station": name, "layer": lay.name or f"layer {i}"}
717 best["factor"] = float(factor)
718 best["duration_s"] = float(factor) * best["tau_s"]
719 return best
720
721 def soak_back_history(self, duration: Optional[float] = None, factor: float = 3.0,
722 first_step: float = 1.0e-2, growth: float = 1.05,
723 steps_per_duration: int = 1200, peak_resolution_K: float = 0.05) -> Dict[str, Any]:
724 """Soak-back after shutdown, with the time each peak is reached.
725
726 Each station's wall is copied and continued with its hot face adiabatic (no gas, no
727 re-radiation: an upper bound on what reaches the back) and its back face adiabatic as
728 always (wall_conduction.py: no backing is modelled behind the last layer). Backward Euler
729 on the same grid, with a step that grows geometrically from ``first_step`` to
730 ``duration / steps_per_duration``: the stack is fully implicit, so the long steps are
731 stable, and the short ones resolve the first seconds when a thin insert equilibrates.
732 Backward Euler slows a mode of rate lambda by ~lambda*h/2; at the default 1200 steps over
733 3 L^2/alpha the slowest mode (lambda = pi^2 alpha/L^2) has lambda*h = 0.025, so its clock
734 runs ~1 % slow. With constant properties the stored heat, and so the soaked peak, is
735 conserved exactly (both faces adiabatic); a cp(T) layer (graphite) has its capacity lagged
736 one step, as in the burn, so its energy is conserved to that lag.
737
738 ``soak_duration`` sizes the window from the slowest *single layer*. For a stack of layers
739 the slowest mode is slower than any one layer's: by eigen-decomposition of this grid, a
740 12 mm liner at the schema's default properties (k 0.35, rho 1600, cp 1500) on a 1/4 in
741 steel case (k 16, rho 8000, cp 500, typical 300-series) is 2.3x slower than the liner alone, and 3x the liner's L^2/alpha still leaves ~3e-6 of that mode; a 6 mm
742 graphite insert on the same case, 1.6x and ~2e-8. Inside the window for such stacks.
743
744 ``duration`` None is :meth:`soak_duration` (``factor`` x the longest L^2/alpha).
745
746 Per station: the peak back-face and first-interface temperatures and when they occur
747 (``t_*_peak_s``: the first time within ``peak_resolution_K`` of the peak, so a plateau
748 reached early is dated when it was reached, not by round-off along it), and
749 ``t_back_95_s``, when the back face had made 95 % of its rise. With both faces adiabatic
750 the back face rises monotonically to the stored-heat equilibrium; the 95 % time says when
751 it gets there."""
752 size = self.soak_duration(factor)
753 if duration is None:
754 duration = size["duration_s"]
755 duration = float(duration)
756 out: Dict[str, Any] = {"duration_s": duration, "sizing": size, "stations": {}}
757 if duration <= 0.0:
758 return out
759 h_max = max(duration / max(int(steps_per_duration), 1), first_step)
760 for name, w in (self._walls or {}).items():
761 m = copy.deepcopy(w["model"])
762 t = 0.0
763 ts = [0.0]
764 back = [float(m.T_back)]
765 bond = [float(m.interface_temperature(0))]
766 h = float(first_step)
767 while t < duration - 1e-12:
768 step = min(h, duration - t)
769 m.step(step, q_in=lambda s: 0.0)
770 t += step
771 ts.append(t)
772 back.append(float(m.T_back))
773 bond.append(float(m.interface_temperature(0)))
774 h = min(h * growth, h_max)
775 back_a, bond_a, t_a = np.asarray(back), np.asarray(bond), np.asarray(ts)
776 tol = float(peak_resolution_K)
777 ib = int(np.argmax(back_a >= float(np.max(back_a)) - tol))
778 ii = int(np.argmax(bond_a >= float(np.max(bond_a)) - tol))
779 rise = float(np.max(back_a)) - back_a[0]
780 t95 = float(t_a[int(np.argmax(back_a >= back_a[0] + 0.95 * rise))]) if rise > 0 else 0.0
781 out["stations"][name] = {
782 "x": float(w["x"]), "kind": w["kind"],
783 "T_back_start": float(back_a[0]), "T_back_peak": float(np.max(back_a)), "t_back_peak_s": float(t_a[ib]),
784 "t_back_95_s": t95,
785 "T_interface_start": float(bond_a[0]), "T_interface_peak": float(np.max(bond_a)),
786 "t_interface_peak_s": float(t_a[ii]),
787 "T_surface_end": float(m.T[0]), "steps": len(ts) - 1,
788 }
789 return out
790
792 self,
793 times: np.ndarray,
794 P_tank_O: np.ndarray,
795 P_tank_F: np.ndarray,
796 ) -> List[TimeVaryingState]:
797 """
798 Solve complete time series with full coupling.
799
800 Parameters:
801 -----------
802 times : np.ndarray
803 Time points [s]
804 P_tank_O : np.ndarray
805 Oxidizer tank pressures [Pa]
806 P_tank_F : np.ndarray
807 Fuel tank pressures [Pa]
808
809 Returns:
810 --------
811 states : List[TimeVaryingState]
812 Complete state history
813 """
814 if len(times) != len(P_tank_O) or len(times) != len(P_tank_F):
815 raise ValueError("times, P_tank_O, and P_tank_F must have same length")
816
817 states = []
818 previous_state = None
819
820 for i, t in enumerate(times):
821 dt = times[i] - times[i-1] if i > 0 else 0.0
822
823 state = self.solve_time_step(
824 time=t,
825 dt=dt,
826 P_tank_O=P_tank_O[i],
827 P_tank_F=P_tank_F[i],
828 previous_state=previous_state,
829 )
830
831 states.append(state)
832 previous_state = state
833
834 self.state_history = states
835 return states
836
837 def get_results_dict(self) -> Dict[str, np.ndarray]:
838 """
839 Convert state history to results dictionary (compatible with existing code).
840
841 Returns:
842 --------
843 results : dict
844 Dictionary with arrays of all metrics
845 """
846 if not self.state_history:
847 raise ValueError("No state history - run solve_time_series first")
848
849 n = len(self.state_history)
850
851 results = {
852 "time": np.array([s.time for s in self.state_history]),
853 "Pc": np.array([s.Pc for s in self.state_history]),
854 "Tc": np.array([s.Tc for s in self.state_history]),
855 "MR": np.array([s.MR for s in self.state_history]),
856 "mdot_total": np.array([s.mdot_total for s in self.state_history]),
857 "F": np.array([s.F for s in self.state_history]),
858 "Isp": np.array([s.Isp for s in self.state_history]),
859 "v_exit": np.array([s.v_exit for s in self.state_history]),
860 "P_exit": np.array([s.P_exit for s in self.state_history]),
861 "T_exit": np.array([s.T_exit for s in self.state_history]),
862 "M_exit": np.array([s.M_exit for s in self.state_history]),
863 "gamma_chamber": np.array([s.gamma_chamber for s in self.state_history]),
864 "gamma_exit": np.array([s.gamma_exit for s in self.state_history]),
865 "R_chamber": np.array([s.R_chamber for s in self.state_history]),
866 "R_exit": np.array([s.R_exit for s in self.state_history]),
867 "equilibrium_factor": np.array([s.equilibrium_factor for s in self.state_history]),
868 "Lstar": np.array([s.Lstar for s in self.state_history]),
869 "V_chamber": np.array([s.V_chamber for s in self.state_history]),
870 "A_throat": np.array([s.A_throat for s in self.state_history]),
871 "A_exit": np.array([s.A_exit for s in self.state_history]),
872 "D_chamber": np.array([s.D_chamber for s in self.state_history]),
873 "A_chamber": np.array([np.pi * (s.D_chamber / 2.0)**2 for s in self.state_history]),
874 "contraction_ratio": np.array([(np.pi * (s.D_chamber / 2.0)**2) / s.A_throat if s.A_throat > 0 else 1.0 for s in self.state_history]),
875 "eps": np.array([s.eps for s in self.state_history]),
876 "recession_chamber": np.array([s.recession_chamber for s in self.state_history]),
877 "recession_throat": np.array([s.recession_throat for s in self.state_history]), # Throat recession (tracked even with graphite)
878 "recession_exit": np.array([s.recession_exit for s in self.state_history]),
879 "recession_graphite": np.array([s.recession_graphite for s in self.state_history]),
880 "D_throat": np.array([s.D_throat for s in self.state_history]), # Throat diameter (constant with graphite, grows without)
881 "throat_area_change_pct": np.array([(s.A_throat - self.A_throat_initial) / self.A_throat_initial * 100.0 for s in self.state_history]),
882 "chugging_frequency": np.array([s.chugging_frequency for s in self.state_history]),
883 "chugging_stability_margin": np.array([s.chugging_stability_margin for s in self.state_history]),
884 "stability_state": np.array([s.stability_state for s in self.state_history]),
885 "stability_score": np.array([s.stability_score for s in self.state_history]),
886 "heat_flux_chamber": np.array([s.heat_flux_chamber for s in self.state_history]),
887 "heat_flux_throat": np.array([s.heat_flux_throat for s in self.state_history]),
888 "ablative_recession_rate": np.array([s.ablative_recession_rate for s in self.state_history]),
889 "graphite_recession_rate": np.array([s.graphite_recession_rate for s in self.state_history]),
890 "throat_oxidation_recession_rate": np.array([s.throat_oxidation_recession_rate for s in self.state_history]),
891 "throat_ablation_recession_rate": np.array([s.throat_ablation_recession_rate for s in self.state_history]),
892 # CRITICAL: Add time-varying chamber intrinsics (these change over time!)
893 "mach_number": np.array([s.mach_number for s in self.state_history]), # TIME-VARYING - not hardcoded
894 "eta_cstar": np.array([s.eta_cstar for s in self.state_history]), # TIME-VARYING n* - changes with L*
895 "reynolds_number": np.array([s.reynolds_number for s in self.state_history]),
896 "residence_time": np.array([s.residence_time for s in self.state_history]),
897 "T_liner_surface": np.array([s.T_liner_surface for s in self.state_history]),
898 "char_depth_chamber": np.array([s.char_depth_chamber for s in self.state_history]),
899 "T_bondline": np.array([s.T_bondline for s in self.state_history]),
900 "recession_liner_peak": np.array([s.recession_liner_peak for s in self.state_history]),
901 "char_depth_peak": np.array([s.char_depth_peak for s in self.state_history]),
902 "T_bondline_peak": np.array([s.T_bondline_peak for s in self.state_history]),
903 "x_liner_peak": np.array([s.x_liner_peak for s in self.state_history]),
904 "q_conv_chamber": np.array([s.q_conv_chamber for s in self.state_history]),
905 "q_rad_chamber": np.array([s.q_rad_chamber for s in self.state_history]),
906 "q_conv_throat": np.array([s.q_conv_throat for s in self.state_history]),
907 "q_rad_throat": np.array([s.q_rad_throat for s in self.state_history]),
908 "q_chem_throat": np.array([s.q_chem_throat for s in self.state_history]),
909 "T_graphite_surface": np.array([s.T_graphite_surface for s in self.state_history]),
910 "T_graphite_back": np.array([s.T_graphite_back for s in self.state_history]),
911 "cstar_ideal": np.array([s.cstar_ideal for s in self.state_history]),
912 "cstar_actual": np.array([s.cstar_actual for s in self.state_history]),
913 }
914
915 # Extract reaction progress arrays
916 results["reaction_progress_throat"] = np.array([s.reaction_progress["progress_throat"] for s in self.state_history])
917 results["reaction_progress_mid"] = np.array([s.reaction_progress["progress_mid"] for s in self.state_history])
918 results["reaction_progress_injection"] = np.array([s.reaction_progress["progress_injection"] for s in self.state_history])
919 results["tau_residence"] = np.array([s.tau_residence for s in self.state_history])
920 results["tau_effective"] = np.array([s.tau_effective for s in self.state_history])
921
922 # Include full diagnostics from ChamberSolver (contains ablative heat flux profiles)
923 results["diagnostics"] = [s.diagnostics for s in self.state_history]
924 # Per wall station, per step (a list like diagnostics), and the stacks the walls were
925 # built from (one dict for the run).
926 results["stations"] = [s.stations for s in self.state_history]
927 results["wall_layers"] = self.wall_layers()
928
929 return results
930
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
__init__(self, PintleEngineConfig config, Any cea_cache, Optional[float] P_ambient=None, bool chug_eroded_geometry=False)
Dict[str, Any] soak_back_history(self, Optional[float] duration=None, float factor=3.0, float first_step=1.0e-2, float growth=1.05, int steps_per_duration=1200, float peak_resolution_K=0.05)
Dict[str, Any] _station_loads(self, "gas_side.HotGasState" gas, contour, float blow, Optional[Dict[str, float]] throat_comp)
Dict[str, float] _wall_report(self, Dict[str, float] rates)
TimeVaryingState solve_time_step(self, float time, float dt, float P_tank_O, float P_tank_F, Optional[TimeVaryingState] previous_state=None)
List[TimeVaryingState] solve_time_series(self, np.ndarray times, np.ndarray P_tank_O, np.ndarray P_tank_F)
Dict[str, Dict[str, float]] soak_back(self, float duration=120.0)