STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
eng.py
Go to the documentation of this file.
1"""A Layer X burn as a RASP ``.eng`` thrust curve, for OpenRocket.
2
3The curve is the run's hand-off (``result["timeseries"]``): the thrust the flight in this run flew,
4from Fire to the moment the first tank ran dry. RASP's layout::
5
6 ; comments
7 NAME DIAMETER_mm LENGTH_mm DELAYS PROPELLANT_kg TOTAL_kg MANUFACTURER
8 time_s thrust_N
9 ...
10
11OpenRocket starts every curve from (0, 0) and needs it to end on zero thrust. The model's thrust
12is on at Fire, so the first point sits ``EDGE_S`` after it and a zero point ``EDGE_S`` after the
13last: the two ramps cost and add back half an edge of thrust each, and the file states its own
14impulse against the run's.
15
16Mass, as OpenRocket uses it: it removes the propellant mass in proportion to the impulse delivered.
17``PROPELLANT_kg`` is what the burn consumed; ``TOTAL_kg`` adds the engine's dry mass
18(``rocket.engine_mass``). Tanks, pressurant, bottle and unburned residual are not in it: in an
19OpenRocket model of a liquid vehicle they are airframe components.
20"""
21
22from __future__ import annotations
23
24import math
25import re
26import time
27from typing import Any, Dict, List, Optional, Tuple
28
29#: Time from Fire to the first point, and from the last point to zero thrust [s].
30EDGE_S = 0.001
31
32
33def motor_header(config: Any) -> Dict[str, Any]:
34 """Diameter, length and dry mass of the engine for the ``.eng`` header, each with its basis."""
36 _layer1_bell_length_m,
37 _layer1_chamber_lengths,
38 _layer1_contraction_theta,
39 )
40
41 out: Dict[str, Any] = {"diameter_mm": None, "length_mm": None, "dry_kg": None, "basis": {}}
42 cg = getattr(config, "chamber_geometry", None)
43 req = getattr(config, "design_requirements", None)
44 ab = getattr(config, "ablative_cooling", None)
45 if cg is not None and getattr(cg, "chamber_diameter", None):
46 bore = float(cg.chamber_diameter)
47 liner = float(ab.initial_thickness) if ab is not None and ab.enabled else 0.0
48 metal = float(getattr(req, "metal_wall_thickness_per_side_m", None) or 0.0) if req is not None else 0.0
49 out["diameter_mm"] = (bore + 2.0 * (liner + metal)) * 1e3
50 out["basis"]["diameter"] = "chamber bore + liner + metal wall, both sides"
51 body = getattr(getattr(config, "rocket", None), "radius", None)
52 if body and out["diameter_mm"] > 2.0 * float(body) * 1e3:
53 # OpenRocket refuses a motor wider than its mount; the chamber sits in the boat tail or
54 # proud of the body, which is the airframe's business, not the motor file's.
55 out["chamber_od_mm"] = out["diameter_mm"]
56 out["diameter_mm"] = 2.0 * float(body) * 1e3
57 out["basis"]["diameter"] = (f"the airframe's {out['diameter_mm']:.1f} mm body (rocket.radius): the chamber's "
58 f"{out['chamber_od_mm']:.1f} mm OD is wider than any motor mount OpenRocket "
59 "will accept")
60 if getattr(cg, "A_throat", None) and getattr(cg, "Lstar", None) and getattr(cg, "expansion_ratio", None):
61 lens = _layer1_chamber_lengths(float(cg.A_throat), float(cg.Lstar), bore, _layer1_contraction_theta(req))
62 length = lens["face_to_throat"] + _layer1_bell_length_m(float(cg.A_throat), float(cg.expansion_ratio))
63 out["length_mm"] = length * 1e3
64 out["basis"]["length"] = "injector face to nozzle exit (80 % bell), as Layer 1 measures it"
65 rocket = getattr(config, "rocket", None)
66 if rocket is not None and getattr(rocket, "engine_mass", None):
67 out["dry_kg"] = float(rocket.engine_mass)
68 out["basis"]["dry_mass"] = "rocket.engine_mass"
69 return out
70
71
72def _token(text: str) -> str:
73 """A RASP header field: no whitespace."""
74 return re.sub(r"\s+", "-", text.strip()) or "-"
75
76
77def curve(result: Dict[str, Any]) -> Tuple[List[float], List[float]]:
78 """(time from Fire [s], thrust [N]) of the run's hand-off curve."""
79 ts = result.get("timeseries") or {}
80 data = ts.get("data") or {}
81 t_raw, f_raw = data.get("time") or [], data.get("thrust_kN") or []
82 if len(t_raw) < 2 or len(t_raw) != len(f_raw):
83 raise ValueError("the run has no thrust curve")
84 if any(v is None or not math.isfinite(float(v)) for v in list(t_raw) + list(f_raw)):
85 raise ValueError("the run's thrust curve has missing samples; run the burn again")
86 t = [float(v) for v in t_raw]
87 f = [float(v) * 1e3 for v in f_raw]
88 t0 = t[0]
89 return [v - t0 for v in t], f
90
91
92def _psia(value: Any, digits: int = 1) -> Optional[str]:
93 return f"{float(value):.{digits}f}" if isinstance(value, (int, float)) and math.isfinite(float(value)) else None
94
95
96def _t0_words(result: Dict[str, Any], derived: Dict[str, Any], settings: Dict[str, Any]) -> str:
97 """The tanks and the bottle the burn started from, in psia (ADR 0007): the run's own T-0 when it
98 recorded one (a settle a relief or a regulator moved is said as it was), else what it asked for."""
99 s = result.get("summary") or {}
100 ox, fu = _psia((s.get("ox") or {}).get("t0_psia")), _psia((s.get("fuel") or {}).get("t0_psia"))
101 bottle = _psia(s.get("copv_t0_psia"))
102 if ox and fu:
103 tanks = ox if ox == fu else f"LOX {ox} / fuel {fu}"
104 return f"tanks {tanks} psia, bottle {bottle or '-'} psia at T-0"
105 lockup = _psia(derived.get("target_lockup_psia")) or _psia(settings.get("tank_pressure_psia"))
106 fill = (_psia(derived.get("copv_psia")) or _psia(settings.get("copv_pressure_psia"))
107 or (_psia(float(settings["copv_pressure_psig"]) + 101325.0 / 6894.757293168361)
108 if isinstance(settings.get("copv_pressure_psig"), (int, float)) else None))
109 return f"tanks {lockup or '-'} psia, bottle {fill or '-'} psia (as set)"
110
111
112def _trapz(t: List[float], f: List[float]) -> float:
113 return sum(0.5 * (f[i] + f[i + 1]) * (t[i + 1] - t[i]) for i in range(len(t) - 1))
114
115
116def _run_mean(t: List[float], f: List[float]) -> Tuple[float, float]:
117 """``(impulse [N s], mean thrust [N])`` as the run totals them (``delivered.summary``): each sample
118 after Fire carries the step that ends there, so the curve is step-held. The trapezoid of the same
119 samples is neither the run's figure nor the file's (LE4 (3): 23743.9 against the run's 23737.9 N-s)."""
120 impulse = sum(f[i + 1] * (t[i + 1] - t[i]) for i in range(len(t) - 1))
121 return impulse, (impulse / t[-1] if t[-1] > 0 else 0.0)
122
123
125 result: Dict[str, Any],
126 *,
127 run_id: str = "",
128 name: Optional[str] = None,
129 diameter_mm: Optional[float] = None,
130 length_mm: Optional[float] = None,
131 dry_kg: Optional[float] = None,
132 manufacturer: str = "STAR",
133 note: Optional[str] = None,
134) -> str:
135 """The ``.eng`` text. Header values not given come from ``result["motor"]``."""
136 t, f = curve(result)
137 motor = result.get("motor") or {}
138 dia = diameter_mm if diameter_mm is not None else motor.get("diameter_mm")
139 length = length_mm if length_mm is not None else motor.get("length_mm")
140 dry = dry_kg if dry_kg is not None else motor.get("dry_kg")
141 missing = [k for k, v in (("diameter", dia), ("length", length)) if v is None]
142 if missing:
143 raise ValueError(f"This run did not record the engine {' or '.join(missing)}: run the burn again, "
144 "or give diameter_mm and length_mm.")
145 summ = (result.get("timeseries") or {}).get("summary") or {}
146 prop = float(summ.get("lox_propellant_kg") or 0.0) + float(summ.get("fuel_propellant_kg") or 0.0)
147 if not prop > 0.0:
148 raise ValueError("the run reports no propellant consumed")
149 total = prop + float(dry or 0.0)
150 run_impulse, mean = _run_mean(t, f)
151 name = _token(name or f"STAR-{round(mean)}N")
152
153 # Only the first point moves: the ramp in loses half an edge of the first step's thrust and
154 # the ramp out adds half an edge of the last's, so the file keeps the run's impulse.
155 pts_t = [EDGE_S] + t[1:] + [t[-1] + EDGE_S]
156 pts_f = [f[0]] + f[1:] + [0.0]
157 written = _trapz([0.0] + pts_t, [0.0] + pts_f)
158
159 prov = result.get("provenance") or {}
160 settings = prov.get("settings") or {}
161 derived = prov.get("derived") or {}
162 flown = bool((result.get("flight") or {}).get("ok"))
163 basis = motor.get("basis") or {}
164 lines = [
165 f"; {name}: Layer X burn{f' {run_id}' if run_id else ''}, exported {time.strftime('%Y-%m-%d %H:%M')}",
166 f"; drawing {((prov.get('drawing') or {}).get('name')) or '-'}, {_t0_words(result, derived, settings)}, "
167 f"{'as flown (acceleration on the feed)' if flown else 'on the pad'}",
168 f"; burn {t[-1]:.3f} s, total impulse {run_impulse:.1f} N-s (this file {written:.1f} N-s), "
169 f"mean thrust {mean:.1f} N",
170 f"; propellant {prop:.4f} kg consumed; total adds engine dry mass {float(dry or 0.0):.4f} kg"
171 f" ({basis.get('dry_mass', 'given') if dry is not None else 'none given'}). Tanks, pressurant and residual "
172 "are not included.",
173 f"; diameter: {basis.get('diameter', 'given') if diameter_mm is None else 'given'}; "
174 f"length: {basis.get('length', 'given') if length_mm is None else 'given'}",
175 "; OpenRocket burns this propellant mass at the motor; a liquid vehicle carries it in its tanks. Put the "
176 "tanks' propellant in as mass components and read the CG and stability margin from those, not from this motor.",
177 ]
178 if note:
179 lines.append(f"; {note}")
180 lines.append(f"{name} {float(dia):.1f} {float(length):.1f} P {prop:.4f} {total:.4f} {_token(manufacturer)}")
181 lines += [f" {a:.4f} {b:.2f}" for a, b in zip(pts_t, pts_f)]
182 lines.append(";")
183 return "\n".join(lines) + "\n"
184
185
186def filename(result: Dict[str, Any], run_id: str = "") -> str:
187 t, f = curve(result)
188 # The motor's own name inside the file (to_eng): the run's mean, not the trapezoid's, or the
189 # download is STAR-6189N and the motor in it STAR-6188N.
190 mean = _run_mean(t, f)[1]
191 stamp = run_id or time.strftime("%Y%m%d-%H%M%S")
192 return f"STAR-{round(mean)}N-{stamp}.eng"
193
194
195def check(text: str) -> Dict[str, Any]:
196 """Parse ``text`` the way a RASP reader does: header fields, points, impulse. For tests."""
197 rows = [ln.split(";")[0].strip() for ln in text.splitlines()]
198 rows = [r for r in rows if r]
199 head = rows[0].split()
200 pts = [tuple(float(x) for x in r.split()) for r in rows[1:]]
201 ts = [0.0] + [p[0] for p in pts]
202 fs = [0.0] + [p[1] for p in pts]
203 return {
204 "name": head[0], "diameter_mm": float(head[1]), "length_mm": float(head[2]), "delays": head[3],
205 "propellant_kg": float(head[4]), "total_kg": float(head[5]), "manufacturer": head[6],
206 "time": ts, "thrust": fs, "impulse": _trapz(ts, fs),
207 "increasing": all(b > a for a, b in zip(ts, ts[1:])) and not math.isnan(sum(ts)),
208 }
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
float _trapz(List[float] t, List[float] f)
Definition eng.py:112
Tuple[List[float], List[float]] curve(Dict[str, Any] result)
Definition eng.py:77
Dict[str, Any] motor_header(Any config)
Definition eng.py:33
str _token(str text)
Definition eng.py:72
str to_eng(Dict[str, Any] result, *, str run_id="", Optional[str] name=None, Optional[float] diameter_mm=None, Optional[float] length_mm=None, Optional[float] dry_kg=None, str manufacturer="STAR", Optional[str] note=None)
Definition eng.py:134
str filename(Dict[str, Any] result, str run_id="")
Definition eng.py:186
str _t0_words(Dict[str, Any] result, Dict[str, Any] derived, Dict[str, Any] settings)
Definition eng.py:96
Tuple[float, float] _run_mean(List[float] t, List[float] f)
Definition eng.py:116
Dict[str, Any] check(str text)
Definition eng.py:195
Optional[str] _psia(Any value, int digits=1)
Definition eng.py:92