1"""Axial drag of the vehicle the flight sim builds: Cd(M), motor off and motor on.
3Component build-up of the OpenRocket technical documentation (S. Niskanen, "OpenRocket
4technical documentation", v13.05, 2013, sec. 3.4 and app. B), which is Barrowman's method
5(TIR-33, 1967) with Hoerner's (Fluid-Dynamic Drag, 1965) and Stoney's (NASA TR R-100, 1961)
8 friction (3.78)-(3.85) fully turbulent Cf, roughness-limited Cf, compressibility,
9 body-fineness and fin-thickness corrections on the wetted area
10 nose (3.86)-(3.87), (B.1)-(B.9) 0.8 sin^2(phi) at M = 0, wave drag from Stoney's
11 fineness-3 curves scaled to this nose's fineness
12 fins (3.89)-(3.93) leading edge by profile with the cos^2 sweep correction, trailing
13 edge as base drag, on the fin frontal area N*t*s
14 base (3.94) 0.12 + 0.13 M^2 below M 1, 0.25/M above; while thrusting the
15 nozzle exit is taken out of the base area (sec. 3.4.5)
17Where the document and OpenRocket's source (BarrowmanCalculator, SymmetricComponentCalc)
18differ, the source is followed and the line says so. Not modelled: launch lugs and rail
19buttons (3.95), fin-body interference, boattails (the built body has none), laminar runs
20(the doc assumes turbulent from the tip, sec. 3.4.1).
22RocketPy takes Cd as a function of Mach alone, so each row's Reynolds number is taken in the
23ISA at one reference altitude, the launch site. At 1.5 km MSL the smooth-wall Cf is ~2 % higher;
24a roughness-limited Cf does not depend on Re at all.
27from __future__
import annotations
30from dataclasses
import dataclass, field, replace
31from functools
import lru_cache
32from typing
import Any, Dict, List, Optional, Sequence, Tuple
38FIN_PROFILES = (
"square",
"rounded",
"airfoil")
39MODEL_NAME =
"openrocket_barrowman"
43MACH_GRID = tuple(round(0.01 * i, 2)
for i
in range(1, 201))
47_STONEY_FN3: Dict[str, Tuple[Tuple[float, ...], Tuple[float, ...]]] = {
49 (0.9, 0.95, 1.0, 1.05, 1.1, 1.2, 1.4, 1.6, 2.0, 3.0),
50 (0.0, 0.010, 0.027, 0.055, 0.070, 0.081, 0.095, 0.097, 0.091, 0.083),
53 (0.9, 0.95, 1.0, 1.05, 1.1, 1.2, 1.4, 1.6, 2.0),
54 (0.0, 0.010, 0.024, 0.066, 0.084, 0.100, 0.114, 0.117, 0.113),
60 """(T [K], p [Pa], rho [kg/m3], a [m/s], nu [m2/s]) of the 1976 US Standard Atmosphere at a
61 geometric altitude, layers 0-2 (to 32 km geopotential). Sutherland viscosity, the standard's
62 1.458e-6 / 110.4 K."""
63 h = 6356766.0 * float(h_m) / (6356766.0 + float(h_m))
64 if not -610.0 <= h <= 32000.0:
65 raise ValueError(f
"ISA 1976 layers 0-2 only: {h:.0f} m is outside -610..32000 m")
68 T = 288.15 - 0.0065 * h
69 p = 101325.0 * (T / 288.15) ** (g_R / 0.0065)
71 p11 = 101325.0 * (216.65 / 288.15) ** (g_R / 0.0065)
74 p = p11 * math.exp(-g_R * (h - 11000.0) / T)
76 T = 216.65 + 0.001 * (h - 20000.0)
77 p = p11 * math.exp(-g_R * 9000.0 / 216.65) * (T / 216.65) ** (-g_R / 0.001)
79 mu = 1.458e-6 * T**1.5 / (T + 110.4)
80 return T, p, rho, math.sqrt(GAMMA_AIR * R_AIR * T), mu / rho
83@dataclass(frozen=True)
85 """What the drag sees. Lengths in m, tail at z = 0, fins as RocketPy builds them."""
92 fin_root_chord_m: float
93 fin_tip_chord_m: float
95 fin_sweep_length_m: float
96 fin_thickness_m: float
98 surface_roughness_m: float
99 nozzle_exit_area_m2: float
103 return math.pi * self.radius_m**2
106@dataclass(frozen=True)
108 """Cd(M) with the motor off and on, plus where they came from."""
110 mach: Tuple[float, ...]
111 cd_power_off: Tuple[float, ...]
112 cd_power_on: Tuple[float, ...]
115 inputs: Dict[str, Any] = field(default_factory=dict)
116 components_power_off: Dict[str, Tuple[float, ...]] = field(default_factory=dict)
118 def rocketpy(self, power_on: bool) -> List[List[float]]:
120 return [[m, c]
for m, c
in zip(self.
mach, cd)]
122 def at(self, mach: float, power_on: bool =
False) -> float:
124 return float(np.interp(mach, self.
mach, cd))
130 "mach": list(self.
mach),
134 "inputs": dict(self.
inputs),
142 """Compressibility-corrected Cf on the wetted area, (3.78)-(3.84)."""
145 cf_turb = 1.48e-2
if Re < 1.0e4
else 1.0 / (1.50 * math.log(Re) - 5.6) ** 2
147 c_turb = 1.0 - 0.1 * M * M
if M < 1.1
else 1.0 / (1.0 + 0.15 * M * M) ** 0.58
148 turb = cf_turb * c_turb
149 if roughness_m <= 0.0:
153 c_rough = 1.0 - 0.1 * M * M
155 c_rough = 1.0 / (1.0 + 0.18 * M * M)
157 c_lo, c_hi = 1.0 - 0.1 * 0.9**2, 1.0 / (1.0 + 0.18 * 1.1**2)
158 c_rough = c_hi * (M - 0.9) / 0.2 + c_lo * (1.1 - M) / 0.2
159 rough = 0.032 * (roughness_m / length_m) ** 0.2 * c_rough
161 return max(turb, rough)
165 """Blunt-cylinder front drag on its frontal area, (B.1)-(B.2)."""
168 q = 1.0 + M**2 / 4.0 + M**4 / 40.0
170 q = 1.84 - 0.76 / M**2 + 0.166 / M**4 + 0.035 / M**6
175 """(3.94), on the base area."""
177 return 0.12 + 0.13 * M * M
if M <= 1.0
else 0.25 / M
180def _subsonic_fit(M: float, m_lo: float, value: float, slope: float, cd0: float) -> float:
181 """(3.87): a M^b + cd0, fitted to the value and slope at the transonic lower bound."""
184 b = m_lo * slope / (value - cd0)
185 a = (value - cd0) / m_lo**b
186 return a * M**b + cd0
190 return str(kind).replace(
" ",
"").replace(
"_",
"").replace(
"-",
"").lower()
194 xs, ys = _STONEY_FN3[key]
195 log4 = math.log(fineness + 1.0) / math.log(4.0)
197 def scaled(m: float) -> float:
198 c3 = float(np.interp(m, xs, ys))
200 return 0.0
if c3 <= 0.0
else stag * (c3 / stag) ** log4
206 return _subsonic_fit(mach, m_lo, v, (scaled(m_lo + 0.01) - v) / 0.01, 0.0)
209def _cone_wave_cd(mach: float, fineness: float, cd0: float, shape_factor: float) -> float:
210 """Conical wave drag (B.4)-(B.6), times the ogive shape factor (B.8) at and above M 1."""
211 sin_e = 1.0 / math.sqrt(1.0 + 4.0 * fineness**2)
213 slope1 = 4.0 / (GAMMA_AIR + 1.0) * (1.0 - 0.5 * cd1)
215 def sup(m: float) -> float:
216 return 2.1 * sin_e**2 + 0.5 * sin_e / math.sqrt(m * m - 1.0)
220 return shape_factor * sup(M)
224 d1 = -0.5 * sin_e * 1.3 / (1.3**2 - 1.0) ** 1.5
226 h00, h10 = 2 * s**3 - 3 * s**2 + 1, s**3 - 2 * s**2 + s
227 h01, h11 = -2 * s**3 + 3 * s**2, s**3 - s**2
228 return shape_factor * (h00 * cd1 + h10 * h * slope1 + h01 * y1 + h11 * h * d1)
229 return _subsonic_fit(M, 1.0, shape_factor * cd1, shape_factor * slope1, cd0)
233 """Nose pressure (wave) drag on the nose base area, for a nose flush with the body."""
235 if key
in _STONEY_FN3:
238 sin_e = 1.0 / math.sqrt(1.0 + 4.0 * fineness**2)
240 if key
in (
"ogive",
"tangent",
"tangentogive"):
242 return _cone_wave_cd(mach, fineness, 0.0, 0.72 * (1.0 - 0.5) ** 2 + 0.82)
244 f
"No transonic nose drag data for nose kind '{kind}' (have: vonKarman, lvhaack, conical, "
245 "ogive). Give rocket.drag_curve_power_off / drag_curve_power_on from OpenRocket or RASAero."
250 """Leading plus trailing edge drag on the fin frontal area N*t*s, (3.89)-(3.93)."""
252 if profile ==
"square":
254 elif profile
in (
"rounded",
"airfoil"):
256 le_perp = (1.0 - M * M) ** -0.417 - 1.0
258 le_perp = 1.0 - 1.785 * (M - 0.9)
260 le_perp = 1.214 - 0.502 / M**2 + 0.1095 / M**4
262 raise ValueError(f
"fin_profile must be one of {FIN_PROFILES}, not {profile!r}")
263 le = le_perp * math.cos(le_sweep_rad) ** 2
264 te = {
"square": 1.0,
"rounded": 0.5,
"airfoil": 0.0}[profile] *
base_cd(M)
271def nose_profile(kind: str, length_m: float, radius_m: float, x: np.ndarray) -> np.ndarray:
272 """Nose radius at distance x from the tip (the shapes RocketPy draws)."""
274 xi = np.clip(np.asarray(x, dtype=float) / length_m, 0.0, 1.0)
277 if key
in (
"vonkarman",
"lvhaack"):
278 th = np.arccos(1.0 - 2.0 * xi)
279 c = 1.0 / 3.0
if key ==
"lvhaack" else 0.0
280 return radius_m * np.sqrt(np.maximum(th - np.sin(2 * th) / 2 + c * np.sin(th) ** 3, 0.0)) / math.sqrt(math.pi)
281 if key
in (
"ogive",
"tangent",
"tangentogive"):
282 rho = (radius_m**2 + length_m**2) / (2.0 * radius_m)
283 return np.sqrt(rho**2 - (length_m * (1.0 - xi)) ** 2) + radius_m - rho
284 raise ValueError(f
"No profile for nose kind '{kind}'")
287@lru_cache(maxsize=64)
289 """Surface of revolution, summed as frusta (exact for the polyline)."""
290 x = np.linspace(0.0, length_m, n)
292 return float(np.sum(math.pi * (y[1:] + y[:-1]) * np.hypot(np.diff(x), np.diff(y))))
296 """Tank cylinder height from the volume the caps and the ullage use (tank_capacity)."""
299 V = resolve_cylindrical_tank_volume_m3(section, height_attr=h_attr, radius_attr=r_attr)
300 return V / (math.pi * float(getattr(section, r_attr)) ** 2)
304 """The stack ui/flight_sim.py assembles, tail at z = 0 (RocketPy tail_to_nose).
306 Tank tops sit at motor_position + tank centre + h/2; the nose TIP is avionics_payload_length_m
307 above the highest of them (RocketPy's add_nose position is the tip). So the avionics length
308 includes the nose, and the body is a plain cylinder from the tail to the nose base.
311 motor_position = float(getattr(rk,
"motor_position", 0.0)
or 0.0)
312 lox_h =
_tank_height(config.lox_tank,
"lox_h",
"lox_radius")
313 fuel_h =
_tank_height(config.fuel_tank,
"rp1_h",
"rp1_radius")
314 lox_top = motor_position + config.lox_tank.ox_tank_pos + lox_h / 2
315 fuel_pos = config.fuel_tank.fuel_tank_pos
316 fuel_top = motor_position + fuel_pos + fuel_h / 2
if fuel_pos > 0
else 0.0
318 if getattr(config,
"press_tank",
None):
319 press_top = motor_position + config.press_tank.pres_tank_pos + config.press_tank.press_h / 2
320 avionics = float(getattr(rk,
"avionics_payload_length_m", 4.0)
or 0.0)
321 nose_tip =
max(lox_top, fuel_top, press_top, motor_position) + avionics
322 diameter = 2.0 * float(rk.radius)
323 nose_len_override = getattr(rk,
"nose_length",
None)
324 if nose_len_override
and float(nose_len_override) > 0:
325 nose_length = float(nose_len_override)
327 nose_length = float(getattr(rk,
"nose_fineness_ratio", 4.5)
or 4.5) * diameter
329 "motor_position": motor_position,
333 "fuel_top": fuel_top,
334 "press_top": press_top,
335 "nose_tip": nose_tip,
336 "nose_length": nose_length,
341def vehicle_geometry(config: Any, nozzle_exit_area_m2: float, stack: Optional[Dict[str, float]] =
None) -> VehicleGeometry:
344 fins = getattr(rk,
"fins",
None)
346 n, cr, ct, s = int(fins.no_fins), float(fins.root_chord), float(fins.tip_chord), float(fins.fin_span)
349 n, cr, ct, s, sweep = 0, 1.0, 1.0, 0.0, 0.0
351 length_m=float(stack[
"length"]),
352 radius_m=float(rk.radius),
353 nose_kind=str(getattr(rk,
"nose_kind",
None)
or "vonKarman"),
354 nose_length_m=float(stack[
"nose_length"]),
359 fin_sweep_length_m=sweep,
360 fin_thickness_m=float(rk.fin_thickness_m),
361 fin_profile=str(rk.fin_profile),
362 surface_roughness_m=float(rk.surface_roughness_m),
363 nozzle_exit_area_m2=float(nozzle_exit_area_m2),
370def drag_components(geom: VehicleGeometry, mach: float, *, a_m_s: float, nu_m2_s: float, thrusting: bool) -> Dict[str, float]:
371 """Cd on the body cross-section, by component, (3.97)."""
373 D, L, A_ref = 2.0 * geom.radius_m, geom.length_m, geom.reference_area_m2
374 Re = M * a_m_s * L / nu_m2_s
376 body_wet =
nose_wetted_area(geom.nose_kind, geom.nose_length_m, geom.radius_m) + math.pi * D * (L - geom.nose_length_m)
377 fin_planform = 0.5 * (geom.fin_root_chord_m + geom.fin_tip_chord_m) * geom.fin_span_m
378 fins_wet = 2.0 * geom.fin_count * fin_planform
379 lam = geom.fin_tip_chord_m / geom.fin_root_chord_m
380 mac = (2.0 / 3.0) * geom.fin_root_chord_m * (1.0 + lam + lam * lam) / (1.0 + lam)
382 friction = cf * ((1.0 + 1.0 / (2.0 * fB)) * body_wet + (1.0 + 2.0 * geom.fin_thickness_m / mac) * fins_wet) / A_ref
385 if geom.fin_count > 0
and geom.fin_span_m > 0:
386 sweep = math.atan2(geom.fin_sweep_length_m, geom.fin_span_m)
387 fins =
fin_pressure_cd(M, geom.fin_profile, sweep) * geom.fin_count * geom.fin_thickness_m * geom.fin_span_m / A_ref
390 base_area =
max(base_area - geom.nozzle_exit_area_m2, 0.0)
391 base =
base_cd(M) * base_area / A_ref
392 return {
"Re": Re,
"cf": cf,
"friction": friction,
"nose": nose,
"fins": fins,
"base": base,
"total": friction + nose + fins + base}
395def buildup_curves(geom: VehicleGeometry, reference_altitude_m: float, mach_grid: Sequence[float] = MACH_GRID) -> DragCurves:
397 off = [
drag_components(geom, m, a_m_s=a, nu_m2_s=nu, thrusting=
False)
for m
in mach_grid]
398 on = [
drag_components(geom, m, a_m_s=a, nu_m2_s=nu, thrusting=
True)
for m
in mach_grid]
400 mach=tuple(float(m)
for m
in mach_grid),
401 cd_power_off=tuple(c[
"total"]
for c
in off),
402 cd_power_on=tuple(c[
"total"]
for c
in on),
404 source=
"Component build-up, OpenRocket technical documentation (Niskanen 2013) sec. 3.4 / app. B",
406 "length_m": geom.length_m,
407 "diameter_m": 2.0 * geom.radius_m,
408 "nose_kind": geom.nose_kind,
409 "nose_length_m": geom.nose_length_m,
410 "fin_count": geom.fin_count,
411 "fin_le_sweep_deg": math.degrees(math.atan2(geom.fin_sweep_length_m, geom.fin_span_m))
if geom.fin_span_m > 0
else None,
412 "fin_thickness_m": geom.fin_thickness_m,
413 "fin_profile": geom.fin_profile,
414 "surface_roughness_m": geom.surface_roughness_m,
415 "nozzle_exit_area_m2": geom.nozzle_exit_area_m2,
416 "reynolds_reference_altitude_m": float(reference_altitude_m),
418 components_power_off={k: tuple(c[k]
for c
in off)
for k
in (
"friction",
"nose",
"fins",
"base")},
422def table_curves(power_off: Sequence[Sequence[float]], power_on: Sequence[Sequence[float]], source: str) -> DragCurves:
423 """A user's Cd(M) tables, put on one Mach grid (the union of both, linear in between)."""
424 off = np.asarray(power_off, dtype=float)
425 on = np.asarray(power_on, dtype=float)
426 mach = np.unique(np.concatenate([off[:, 0], on[:, 0]]))
428 mach=tuple(float(m)
for m
in mach),
429 cd_power_off=tuple(float(v)
for v
in np.interp(mach, off[:, 0], off[:, 1])),
430 cd_power_on=tuple(float(v)
for v
in np.interp(mach, on[:, 0], on[:, 1])),
433 inputs={
"rows_power_off": int(len(off)),
"rows_power_on": int(len(on))},
437def resolve_drag_curves(config: Any, nozzle_exit_area_m2: float, stack: Optional[Dict[str, float]] =
None, **overrides: Any) -> DragCurves:
438 """The user's tables when given, else the build-up of the vehicle the sim assembles.
440 ``overrides`` replaces build-up inputs by VehicleGeometry field name, e.g. the ceiling check's
441 ``surface_roughness_m=0.0`` (hydraulically smooth: the low-drag end of the finish).
444 if getattr(rk,
"drag_curve_power_off",
None)
is not None:
445 return table_curves(rk.drag_curve_power_off, rk.drag_curve_power_on, rk.drag_curve_source)
448 geom = replace(geom, **overrides)
List[List[float]] rocketpy(self, bool power_on)
Dict[str, Any] to_dict(self)
Dict components_power_off
float at(self, float mach, bool power_on=False)
float reference_area_m2(self)
DragCurves table_curves(Sequence[Sequence[float]] power_off, Sequence[Sequence[float]] power_on, str source)
float fin_pressure_cd(float mach, str profile, float le_sweep_rad)
float _haack_wave_cd(float mach, float fineness, str key)
DragCurves buildup_curves(VehicleGeometry geom, float reference_altitude_m, Sequence[float] mach_grid=MACH_GRID)
VehicleGeometry vehicle_geometry(Any config, float nozzle_exit_area_m2, Optional[Dict[str, float]] stack=None)
float base_cd(float mach)
float _subsonic_fit(float M, float m_lo, float value, float slope, float cd0)
float nose_wetted_area(str kind, float length_m, float radius_m, int n=20001)
DragCurves resolve_drag_curves(Any config, float nozzle_exit_area_m2, Optional[Dict[str, float]] stack=None, **Any overrides)
Dict[str, float] drag_components(VehicleGeometry geom, float mach, *, float a_m_s, float nu_m2_s, bool thrusting)
float _tank_height(Any section, str h_attr, str r_attr)
float skin_friction_cf(float Re, float mach, float roughness_m, float length_m)
Tuple[float, float, float, float, float] isa_troposphere(float h_m)
float nose_pressure_cd(float mach, str kind, float fineness)
np.ndarray nose_profile(str kind, float length_m, float radius_m, np.ndarray x)
float stagnation_cd(float mach)
Dict[str, float] built_stack(Any config)
float _cone_wave_cd(float mach, float fineness, float cd0, float shape_factor)