1"""A Layer X burn as a RASP ``.eng`` thrust curve, for OpenRocket.
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::
7 NAME DIAMETER_mm LENGTH_mm DELAYS PROPELLANT_kg TOTAL_kg MANUFACTURER
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.
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.
22from __future__
import annotations
27from typing
import Any, Dict, List, Optional, Tuple
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,
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:
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 "
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"
73 """A RASP header field: no whitespace."""
74 return re.sub(
r"\s+",
"-", text.strip())
or "-"
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]
89 return [v - t0
for v
in t], f
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
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"))
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)"
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))
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)
125 result: Dict[str, Any],
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,
135 """The ``.eng`` text. Header values not given come from ``result["motor"]``."""
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]
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)
148 raise ValueError(
"the run reports no propellant consumed")
149 total = prop + float(dry
or 0.0)
151 name =
_token(name
or f
"STAR-{round(mean)}N")
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)
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 {}
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 "
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.",
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)]
183 return "\n".join(lines) +
"\n"
186def filename(result: Dict[str, Any], run_id: str =
"") -> str:
191 stamp = run_id
or time.strftime(
"%Y%m%d-%H%M%S")
192 return f
"STAR-{round(mean)}N-{stamp}.eng"
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]
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)),
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
float _trapz(List[float] t, List[float] f)
Tuple[List[float], List[float]] curve(Dict[str, Any] result)
Dict[str, Any] motor_header(Any config)
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)
str filename(Dict[str, Any] result, str run_id="")
str _t0_words(Dict[str, Any] result, Dict[str, Any] derived, Dict[str, Any] settings)
Tuple[float, float] _run_mean(List[float] t, List[float] f)
Dict[str, Any] check(str text)
Optional[str] _psia(Any value, int digits=1)