STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
handcheck.py
Go to the documentation of this file.
1"""Re-derive a design's headline numbers by hand and set them beside the model's.
2
3Nothing here calls the engine's physics: thermochemistry comes from NASA CEA through rocketcea (or,
4where rocketcea is not installed, c* from the committed wide CEA table and nothing else),
5everything else from the textbook relation named on its row. ``handcheck`` takes a solved result
6(``PintleEngineRunner.evaluate``) and returns the rows; scripts/design_handcheck.py prints them and
7forward mode shows them in its independent-check box.
8
9A row is flagged when the two disagree by more than its tolerance, or when a design-practice bound
10is not met.
11"""
12from __future__ import annotations
13
14import math
15from typing import Any, Dict, List, Mapping
16
17PSI = 6894.757
18G0 = 9.80665
19
20
21#: Said on the report when the deployment has no rocketcea (the API image leaves it out: it builds
22#: CEA from Fortran). The textbook rows need no CEA; c* is checked against the committed wide table.
23NO_ROCKETCEA = ("rocketcea is not installed here: c* is checked against the committed wide CEA "
24 "table; Tc, γ, Cf and exit pressure need live CEA and are not checked")
25
26
27def _cea_names(cfg: Any) -> tuple:
28 cea = getattr(cfg.combustion, "cea", None)
29 return (getattr(cea, "ox_name", "LOX"), getattr(cea, "fuel_name", "Ethanol"))
30
31
32def _live_cea(cfg: Any):
33 """rocketcea's CEA_Obj for the design's propellants, or None where rocketcea is not installed."""
34 try:
35 from rocketcea.cea_obj import CEA_Obj
36 except ImportError:
37 return None
38 ox, fu = _cea_names(cfg)
39 return CEA_Obj(oxName=ox, fuelName=fu)
40
41
42def _table_cstar(cfg: Any, Pc: float, MR: float):
43 """c* [m/s] from the committed wide CEA table (O/F 0.1–20, its own grid and interpolation), or
44 None if the pair has none. Built from CEA's equilibrium chamber like the trilinear cache the
45 model reads, but on a different grid -- so it catches a stale cache or a clamped O/F."""
46 import numpy as np
47 from engine.pipeline.combustion_physics import get_cstar_wide_table
48
49 w = get_cstar_wide_table(*_cea_names(cfg))
50 if w is None or not (w.MR_min <= MR <= w.MR_max):
51 return None
52 return float(np.interp(math.log(MR), np.log(w.MR), w.row(Pc)))
53
54
55def handcheck(cfg: Any, r: Mapping[str, Any], PO: float, PF: float) -> Dict[str, Any]:
56 """Rows ``{quantity, model, hand, diff, ok, source, kind}`` (kind: "compare" or "bound") and the
57 flags, for result ``r`` of ``cfg`` at tank pressures PO, PF [Pa].
58
59 Without rocketcea (the deployed API image) the textbook rows still run, c* is checked against
60 the committed wide table, and the rows that need live CEA say so; ``note`` carries why.
61 """
62 cea = _live_cea(cfg)
63
64 rows: List[Dict[str, Any]] = []
65 flags: List[str] = []
66
67 def compare(name, model, hand, tol, source, unit=""):
68 rel = abs(model - hand) / max(abs(hand), 1e-300)
69 ok = rel <= tol
70 rows.append({"quantity": name, "model": f"{model:.5g} {unit}".strip(), "hand": f"{hand:.5g} {unit}".strip(),
71 "diff": f"{rel:.2%}", "ok": ok, "source": source, "kind": "compare", "rel": rel, "tol": tol})
72 if not ok:
73 flags.append(f"{name}: model {model:.5g} vs hand {hand:.5g} ({rel:.2%} > {tol:.2%})")
74
75 def bound(name, value, lo, hi, source, unit=""):
76 if value is None or not math.isfinite(value):
77 rows.append({"quantity": name, "model": "n/a", "hand": "", "diff": "n/a", "ok": True,
78 "source": source, "kind": "info"})
79 return
80 ok = (lo is None or value >= lo) and (hi is None or value <= hi)
81 band = f"{'' if lo is None else f'{lo:g}'}..{'' if hi is None else f'{hi:g}'}"
82 rows.append({"quantity": name, "model": f"{value:.4g} {unit}".strip(), "hand": band,
83 "diff": "ok" if ok else "OUT", "ok": ok, "source": source, "kind": "bound"})
84 if not ok:
85 flags.append(f"{name} = {value:.4g} outside {band}")
86
87 def info(name, value, note, source):
88 rows.append({"quantity": name, "model": value, "hand": note, "diff": "info", "ok": True,
89 "source": source, "kind": "info"})
90
91 d = r["diagnostics"]
92 cg = cfg.chamber_geometry
93 fl = cfg.fluids
94 geo = cfg.injector.geometry
95 Pc, Pc_ns, MR = r["Pc"], d.get("Pc_ns", r["Pc"]), r["MR"]
96 At, eps, Pa = r["A_throat"], r["eps"], r["P_ambient"]
97 mdot, F = r["mdot_total"], r["F"]
98
99 # ---- thermochemistry: CEA (infinite-area combustor, equilibrium)
100 pc_psia = Pc / PSI
101 zeta = cg.nozzle_efficiency
102 needs_cea = "needs rocketcea"
103 if cea is not None:
104 cstar_cea = cea.get_Cstar(pc_psia, MR) * 0.3048
105 cstar_src = "CEA (rocketcea)"
106 Tc_cea = cea.get_Tcomb(pc_psia, MR) / 1.8
107 _, g_ch = cea.get_Chamber_MolWt_gamma(pc_psia, MR, eps)
108 Isp_vac_cea = cea.get_Isp(pc_psia, MR, eps)
109 else:
110 cstar_cea = _table_cstar(cfg, Pc, MR)
111 cstar_src = "CEA wide c* table (committed)"
112 if cstar_cea is not None:
113 compare("c* ideal", r["cstar_ideal"], cstar_cea, 0.005, cstar_src, "m/s")
114 else:
115 info("c* ideal", f"{r['cstar_ideal']:.5g} m/s", needs_cea, "CEA")
116 if cea is not None:
117 compare("Tc ideal", d.get("Tc_ideal", r["Tc"]), Tc_cea, 0.005, "CEA", "K")
118 compare("gamma chamber", r["gamma"], g_ch, 0.01, "CEA")
119 else:
120 info("Tc ideal", f"{d.get('Tc_ideal', r['Tc']):.5g} K", needs_cea, "CEA")
121 info("gamma chamber", f"{r['gamma']:.5g}", needs_cea, "CEA")
122
123 # ---- mass balance: c* = p1 At / mdot (Sutton eq 3-32), at the nozzle stagnation pressure
124 compare("mdot = Pc_ns At / c*", mdot, Pc_ns * At / r["cstar_actual"], 0.003, "Sutton 3-32", "kg/s")
125 if cstar_cea is not None:
126 compare("eta c*", r["eta_cstar"], r["cstar_actual"] / cstar_cea, 0.005, f"c*_act / c*_CEA ({cstar_src})")
127
128 # ---- thrust coefficient: F = Cf p1 At; Cf = zeta_n Cf_vac - pa eps / p1
129 if cea is not None:
130 Cf_vac_cea = Isp_vac_cea * G0 / cstar_cea
131 Cf_hand = zeta * Cf_vac_cea - Pa * eps / Pc_ns
132 compare("Cf (zeta_n on vacuum Cf)", r["Cf"], Cf_hand, 0.01, f"Sutton 3-30, CEA Cf_vac, zeta_n={zeta}")
133 else:
134 info("Cf (zeta_n on vacuum Cf)", f"{r['Cf']:.5g}", needs_cea, "Sutton 3-30, CEA Cf_vac")
135 compare("F = Cf Pc_ns At", F, r["Cf"] * Pc_ns * At, 0.002, "Sutton 3-31", "N")
136 compare("Isp = F / (mdot g0)", r["Isp"], F / (mdot * G0), 0.002, "definition", "s")
137 # exit state: CEA shifting equilibrium at this area ratio
138 if cea is not None:
139 pe_cea = Pc / cea.get_PcOvPe(pc_psia, MR, eps)
140 compare("exit pressure", r["P_exit"], pe_cea, 0.02, "CEA Pc/Pe at eps", "Pa")
141 else:
142 info("exit pressure", f"{r['P_exit']:.5g} Pa", needs_cea, "CEA Pc/Pe at eps")
143 bound("pe / pa (Summerfield > 0.4)", r["P_exit"] / Pa, 0.4, None, "Sutton 3.3 flow separation")
144
145 # ---- injector: mdot = Cd A sqrt(2 rho dP) per stream (Sutton eq 8-1)
146 for s, key, rho in (("O", "oxidizer", fl["oxidizer"].density), ("F", "fuel", fl["fuel"].density)):
147 el = getattr(geo, key)
148 A = el.n_elements * math.pi * el.d_jet ** 2 / 4.0
149 dp = d[f"delta_p_injector_{s}"]
150 m_hand = d[f"Cd_{s}"] * A * math.sqrt(2.0 * rho * dp)
151 if d.get("manifold_model") == "ring_network":
152 # dP is port-to-chamber; the ring-manifold solve spends a little of it in the channel
153 # (entry + friction), so the holes see slightly less than dP.
154 compare(f"mdot_{s} = Cd A sqrt(2 rho dP) (port head)", r[f"mdot_{s}"], m_hand, 0.02,
155 "Sutton 8-1; channel loss in the ring-manifold solve", "kg/s")
156 else:
157 compare(f"mdot_{s} = Cd A sqrt(2 rho dP)", r[f"mdot_{s}"], m_hand, 1e-6, "Sutton 8-1", "kg/s")
158 compare(f"P_inj_{s} = P_tank - dP_feed", d[f"P_injector_{s}"], (PO if s == "O" else PF) - d[f"delta_p_feed_{s}"], 1e-9, "definition", "Pa")
159 bound(f"dP_inj_{s} / Pc", dp / Pc, 0.15, None, "Huzel & Huang 4.2; Sutton 8.1 (chug margin)")
160 v = r[f"mdot_{s}"] / (rho * A)
161 bound(f"jet velocity {s}", v, 10.0, 60.0, "bulk mdot/(rho A); typical doublet range", "m/s")
162
163 # ---- unlike doublet mixing and momentum (Rupe 1953; Sutton 8.3)
164 rO, rF = fl["oxidizer"].density, fl["fuel"].density
165 vO = r["mdot_O"] / (rO * geo.oxidizer.n_elements * math.pi * geo.oxidizer.d_jet ** 2 / 4.0)
166 vF = r["mdot_F"] / (rF * geo.fuel.n_elements * math.pi * geo.fuel.d_jet ** 2 / 4.0)
167 M = (rO * vO ** 2 * geo.oxidizer.d_jet) / (rF * vF ** 2 * geo.fuel.d_jet)
168 compare("Rupe M", d["rupe_M"], M, 1e-6, "rho_O v_O^2 d_O / (rho_F v_F^2 d_F)")
169 bound("Rupe M (best mixing near 1)", M, 0.8, 1.25, "Rupe 1953; Elverum & Morey 1959")
170 thO, thF = math.radians(geo.oxidizer.impingement_angle), math.radians(geo.fuel.impingement_angle)
171 pO, pF = r["mdot_O"] * vO, r["mdot_F"] * vF
172 beta = math.degrees(math.atan2(pO * math.sin(thO) - pF * math.sin(thF), pO * math.cos(thO) + pF * math.cos(thF)))
173 # Its limit is the reach-based wall allowance design_audit.py gates on, not a fixed number.
174 info("resultant spray angle", f"{beta:.2f} deg", "see design_audit", "Sutton eq 8-7 (momentum resultant)")
175 bound("included angle", geo.oxidizer.impingement_angle + geo.fuel.impingement_angle, 60.0, 100.0,
176 "Sutton 8.3 / Huzel 4.2 practice", "deg")
177 if d.get("L_imp"):
178 bound("free-jet length / d_O", d["L_imp"] / geo.oxidizer.d_jet, 2.0, 7.0, "jet coherence before impact")
179
180 # ---- chamber
181 ch = r["chamber_intrinsics"]
182 rho_c = Pc / (r["R"] * r["Tc"])
183 compare("stay time = L* At rho_c / mdot", ch["residence_time"], ch["Lstar"] * At * rho_c / mdot, 0.02, "Sutton 8-9", "s")
184 Dt = math.sqrt(4 * At / math.pi)
185 CR = (cg.chamber_diameter / Dt) ** 2
186 bound("contraction ratio", CR, 3.0, 15.0, "Huzel & Huang 4.3 practice")
187 bound("chamber Mach", ch["mach_number_chamber"], None, 0.3, "Rayleigh loss small below ~0.3")
188
189 out = {"rows": rows, "flags": flags,
190 "summary": {"Pc_psia": Pc / PSI, "OF": MR, "F": F, "Isp": r["Isp"], "Pa": Pa}}
191 if cea is None:
192 out["note"] = NO_ROCKETCEA
193 return out
#define max(a, b)
Definition Arduino.h:43
static board_sleep_info_t info
Definition board_sleep.c:14
_table_cstar(Any cfg, float Pc, float MR)
Definition handcheck.py:42
tuple _cea_names(Any cfg)
Definition handcheck.py:27