Skip to main content

rockets_engine/
geometry.rs

1//! Geometry-to-aerodynamics: a subsonic Barrowman subset for slender model
2//! rockets (nose cone + body tube + trapezoidal fins), implemented against the
3//! OpenRocket technical documentation and its `FinSetCalc`/`BarrowmanCalculator`
4//! sources. CP comes from the component moment sum; drag is friction + base +
5//! conical nose pressure. Supersonic values are clamped to the Mach-1 results.
6//!
7//! Not covered (deliberately): supersonic wave drag, boat-tails/transitions,
8//! elliptical or free-form fins, and induced drag from angle of attack (use
9//! `with_body_lift`'s `induced_k` table if you have a better estimate).
10
11use crate::{Error, Result};
12
13/// Nose-cone families supported by the subset.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum NoseShape {
16    /// Straight conical nose.
17    Conical,
18    /// Tangent ogive nose.
19    Ogive,
20    /// Half-ellipsoid nose.
21    Elliptical,
22    /// Power-series parabolic nose (exponent 0.5).
23    Parabolic,
24}
25
26impl std::str::FromStr for NoseShape {
27    type Err = Error;
28
29    fn from_str(s: &str) -> Result<Self> {
30        match s {
31            "conical" | "cone" => Ok(Self::Conical),
32            "ogive" => Ok(Self::Ogive),
33            "elliptical" | "ellipse" => Ok(Self::Elliptical),
34            "parabolic" => Ok(Self::Parabolic),
35            _ => Err(Error::invalid(
36                "nose shape",
37                "conical, ogive, elliptical or parabolic",
38            )),
39        }
40    }
41}
42
43impl NoseShape {
44
45    /// CP location as a fraction of nose length, from the volume/area relation
46    /// XB = L − V/A_base. Ogive uses the standard tangent-ogive value.
47    fn cp_fraction(self) -> f64 {
48        match self {
49            Self::Conical => 2.0 / 3.0,
50            Self::Ogive => 0.466,
51            Self::Elliptical => 1.0 / 3.0,
52            Self::Parabolic => 0.5,
53        }
54    }
55
56    /// Pressure-drag coefficient at subsonic speeds. Conical noses use
57    /// 0.8·sin²(φ) with the half-angle φ = atan(r/L); streamlined shapes are
58    /// taken as zero, the usual Barrowman approximation.
59    fn pressure_cd(self, length_m: f64, diameter_m: f64) -> f64 {
60        match self {
61            Self::Conical => {
62                let r = diameter_m / 2.0;
63                let sin_phi = r / (r * r + length_m * length_m).sqrt();
64                0.8 * sin_phi * sin_phi
65            }
66            _ => 0.0,
67        }
68    }
69
70    /// Lateral (wetted) area. Conical uses the exact slant area; the others
71    /// approximate the cylinder at the base diameter.
72    fn lateral_area(self, length_m: f64, diameter_m: f64) -> f64 {
73        let r = diameter_m / 2.0;
74        match self {
75            Self::Conical => std::f64::consts::PI * r * (r * r + length_m * length_m).sqrt(),
76            _ => std::f64::consts::PI * diameter_m * length_m,
77        }
78    }
79}
80
81/// A nose cone with length and base diameter, in metres.
82#[derive(Debug, Clone, Copy)]
83pub struct NoseCone {
84    /// Shape family.
85    pub shape: NoseShape,
86    /// Nose length in metres.
87    pub length_m: f64,
88    /// Base diameter in metres.
89    pub diameter_m: f64,
90}
91
92/// The cylindrical body tube behind the nose.
93#[derive(Debug, Clone, Copy)]
94pub struct BodyTube {
95    /// Tube length in metres.
96    pub length_m: f64,
97    /// Tube diameter in metres.
98    pub diameter_m: f64,
99}
100
101/// Identical trapezoidal fins at the aft end of the tube. `leading_sweep_m` is
102/// the horizontal offset of the tip leading edge from the root leading edge.
103#[derive(Debug, Clone, Copy)]
104pub struct TrapezoidalFins {
105    /// Number of identical fins.
106    pub count: u32,
107    /// Root chord length in metres.
108    pub root_chord_m: f64,
109    /// Tip chord length in metres.
110    pub tip_chord_m: f64,
111    /// Fin semispan in metres (body surface to tip).
112    pub span_m: f64,
113    /// Horizontal offset of the tip leading edge from the root leading edge.
114    pub leading_sweep_m: f64,
115    /// Fin thickness in metres.
116    pub thickness_m: f64,
117}
118
119/// The complete slender geometry.
120#[derive(Debug, Clone, Copy)]
121pub struct RocketGeometry {
122    /// Nose cone.
123    pub nose: NoseCone,
124    /// Cylindrical body tube.
125    pub tube: BodyTube,
126    /// Aft trapezoidal fin set.
127    pub fins: TrapezoidalFins,
128    /// Roughness height in metres (default 20 µm, painted surface).
129    pub roughness_m: f64,
130}
131
132impl RocketGeometry {
133    /// Build with the default painted-surface roughness (20 µm).
134    pub fn new(nose: NoseCone, tube: BodyTube, fins: TrapezoidalFins) -> Self {
135        Self {
136            nose,
137            tube,
138            fins,
139            roughness_m: 20e-6,
140        }
141    }
142
143    /// Set the roughness height in metres.
144    pub fn with_roughness(mut self, roughness_m: f64) -> Self {
145        self.roughness_m = roughness_m;
146        self
147    }
148
149    /// Total aerodynamic length (nose + tube).
150    pub fn total_length_m(&self) -> f64 {
151        self.nose.length_m + self.tube.length_m
152    }
153
154    /// Largest body diameter (nose base or tube).
155    pub fn max_diameter_m(&self) -> f64 {
156        self.nose.diameter_m.max(self.tube.diameter_m)
157    }
158
159    /// Reference area, π/4·d² of the largest diameter.
160    pub fn ref_area_m2(&self) -> f64 {
161        std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
162    }
163
164    fn fin_area(&self) -> f64 {
165        self.fins.span_m * (self.fins.root_chord_m + self.fins.tip_chord_m) / 2.0
166    }
167
168    /// Mean aerodynamic chord of one fin (trapezoid formula).
169    fn mac(&self) -> f64 {
170        let (cr, ct) = (self.fins.root_chord_m, self.fins.tip_chord_m);
171        (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct))
172    }
173
174    /// Fin CP measured from the fin's root leading edge: xMAC_LE + MAC/4.
175    fn fin_cp(&self) -> f64 {
176        let (cr, ct, s, m) = (
177            self.fins.root_chord_m,
178            self.fins.tip_chord_m,
179            self.fins.span_m,
180            self.fins.leading_sweep_m,
181        );
182        let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
183        let x_mac_le = m * y_mac / s;
184        x_mac_le + 0.25 * self.mac()
185    }
186
187    fn midchord_cos(&self) -> f64 {
188        let (cr, ct, s, m) = (
189            self.fins.root_chord_m,
190            self.fins.tip_chord_m,
191            self.fins.span_m,
192            self.fins.leading_sweep_m,
193        );
194        let gamma = ((m + ct / 2.0 - cr / 2.0) / s).atan();
195        gamma.cos()
196    }
197
198    /// Normal-force slope of one fin, divided by the reference area.
199    /// OpenRocket's subsonic Diederich simplification.
200    fn fin_cna1(&self, mach: f64) -> f64 {
201        let span = self.fins.span_m;
202        let area = self.fin_area();
203        let cos = self.midchord_cos();
204        if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
205            return 0.0;
206        }
207        let ratio = (span * span) / (area * cos);
208        2.0 * std::f64::consts::PI * span * span
209            / (1.0 + (1.0 + (1.0 - mach * mach) * ratio * ratio).sqrt())
210            / self.ref_area_m2()
211    }
212
213    /// Body-interference factor (1 + tau) with tau = r/(span + r).
214    fn interference_tau(&self) -> f64 {
215        let r = self.max_diameter_m() / 2.0;
216        r / (self.fins.span_m + r)
217    }
218
219    fn fin_count_factor(&self) -> f64 {
220        match self.fins.count {
221            5 => 0.948,
222            6 => 0.913,
223            7 => 0.854,
224            8 => 0.810,
225            n if n >= 9 => 0.75,
226            _ => 1.0,
227        }
228    }
229
230    /// Total fin-set normal-force slope for this Mach number.
231    fn fin_set_cna(&self, mach: f64) -> f64 {
232        self.fin_cna1(mach)
233            * (1.0 + self.interference_tau())
234            * self.fin_count_factor()
235            * self.fins.count as f64
236    }
237
238    /// Fin CP measured from the nose tip (fin trailing edge at body end).
239    fn fin_cp_abs(&self) -> f64 {
240        self.total_length_m() - self.fins.root_chord_m + self.fin_cp()
241    }
242
243    /// Total CP from the component moment sum (nose + fin set).
244    fn total_cp(&self, mach: f64) -> f64 {
245        let nose_cna = 2.0;
246        let fin_cna = self.fin_set_cna(mach);
247        (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
248            + fin_cna * self.fin_cp_abs())
249            / (nose_cna + fin_cna)
250    }
251
252    #[cfg(test)]
253    fn nose_cp(&self) -> f64 {
254        self.nose.length_m * self.nose.shape.cp_fraction()
255    }
256
257    fn base_cd(mach: f64) -> f64 {
258        if mach <= 1.0 {
259            0.12 + 0.13 * mach * mach
260        } else {
261            0.25 / mach
262        }
263    }
264
265    fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
266        let length = self.total_length_m();
267        let velocity = mach * sound_mps;
268        let reynolds = velocity * length / 1.5e-5;
269        let rough_ratio = self.roughness_m / length;
270        let cf_turbulent = if reynolds < 1.0e4 {
271            1.48e-2
272        } else {
273            1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
274        };
275        let cf_rough = 0.032 * rough_ratio.powf(0.2);
276        // Fully turbulent: take the larger of the smooth and roughness-limited
277        // coefficients, then the subsonic compressibility correction.
278        let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
279        let r = self.max_diameter_m() / 2.0;
280        let fineness = length / r;
281        let correction = 1.0 + 1.0 / (2.0 * fineness);
282        let body_wet = self
283            .nose
284            .shape
285            .lateral_area(self.nose.length_m, self.nose.diameter_m)
286            + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m;
287        let fin_wet = 2.0 * self.fins.count as f64 * self.fin_area();
288        let thickness_factor = 1.0 + 2.0 * self.fins.thickness_m / self.mac();
289        cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
290    }
291
292    /// Full analysis. Mach samples at 0.0, 0.2, 0.4, 0.6, 0.8, 1.0, and 3.0;
293    /// the last point simply repeats the Mach-1 values (subsonic clamp).
294    pub fn analyze(&self) -> AeroAnalysis {
295        self.validate();
296        let machs: [f64; 7] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 3.0];
297        let mut cd_vs_mach = Vec::new();
298        let mut cna_vs_mach = Vec::new();
299        let mut cp_vs_mach = Vec::new();
300        let mut induced_k_vs_mach = Vec::new();
301        for &m in &machs {
302            let effective = m.min(1.0);
303            cd_vs_mach.push((
304                m,
305                Self::base_cd(effective)
306                    + self
307                        .nose
308                        .shape
309                        .pressure_cd(self.nose.length_m, self.nose.diameter_m)
310                    + self.friction_cd(effective, 340.0),
311            ));
312            let fin_cna = self.fin_set_cna(effective);
313            cna_vs_mach.push((m, 2.0 + fin_cna));
314            cp_vs_mach.push((m, self.total_cp(effective)));
315            // Induced drag is not part of the Barrowman model; leave zero.
316            induced_k_vs_mach.push((m, 0.0));
317        }
318        AeroAnalysis {
319            cd_vs_mach,
320            cna_vs_mach,
321            cp_vs_mach,
322            induced_k_vs_mach,
323            ref_area_m2: self.ref_area_m2(),
324            total_length_m: self.total_length_m(),
325            max_diameter_m: self.max_diameter_m(),
326        }
327    }
328
329    fn validate(&self) {
330        assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
331        assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
332        assert!(self.fins.count > 0);
333        assert!(self.fins.span_m > 0.0);
334        assert!(self.fins.root_chord_m > 0.0 && self.fins.tip_chord_m > 0.0);
335        assert!(self.fins.thickness_m > 0.0);
336    }
337}
338
339/// Parse the compact geometry spec used by the CLI:
340/// `nose=shape:length:diameter,tube=length:diameter,fins=count:root:tip:span:sweep:thickness`.
341/// The fin thickness is optional (default 2 mm). Lengths are in metres.
342pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
343    let mut nose = None;
344    let mut tube = None;
345    let mut fins = None;
346    for item in spec.split(',') {
347        let (key, val) = item
348            .split_once('=')
349            .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
350        let parts: Vec<&str> = val.split(':').collect();
351        let number = |i: usize| -> Result<f64> {
352            parts
353                .get(i)
354                .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
355                .parse()
356                .map_err(|_| Error::invalid("geometry", "non-numeric field"))
357        };
358        match key {
359            "nose" => {
360                if parts.len() != 3 {
361                    return Err(Error::invalid("nose", "expected shape:length:diameter"));
362                }
363                let shape = parts[0].parse::<NoseShape>()?;
364                nose = Some(NoseCone {
365                    shape,
366                    length_m: number(1)?,
367                    diameter_m: number(2)?,
368                });
369            }
370            "tube" => {
371                if parts.len() != 2 {
372                    return Err(Error::invalid("tube", "expected length:diameter"));
373                }
374                tube = Some(BodyTube {
375                    length_m: number(0)?,
376                    diameter_m: number(1)?,
377                });
378            }
379            "fins" => {
380                if !(5..=6).contains(&parts.len()) {
381                    return Err(Error::invalid(
382                        "fins",
383                        "expected count:root:tip:span:sweep[:thickness]",
384                    ));
385                }
386                let count: u32 = parts[0]
387                    .parse()
388                    .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
389                fins = Some(TrapezoidalFins {
390                    count,
391                    root_chord_m: number(1)?,
392                    tip_chord_m: number(2)?,
393                    span_m: number(3)?,
394                    leading_sweep_m: number(4)?,
395                    thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
396                });
397            }
398            other => {
399                return Err(Error::invalid(
400                    "geometry key",
401                    match other {
402                        "nose" | "tube" | "fins" => unreachable!(),
403                        _ => "must be nose, tube or fins",
404                    },
405                ))
406            }
407        }
408    }
409    Ok(RocketGeometry::new(
410        nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
411        tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
412        fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
413    ))
414}
415
416/// The curves and reference quantities produced by [`RocketGeometry::analyze`].
417#[derive(Debug, Clone)]
418pub struct AeroAnalysis {
419    /// Axial drag coefficient vs Mach (subsonic, clamped above Mach 1).
420    pub cd_vs_mach: Vec<(f64, f64)>,
421    /// Normal-force coefficient slope per radian vs Mach (nose + fin set).
422    pub cna_vs_mach: Vec<(f64, f64)>,
423    /// Centre of pressure, metres aft of the nose tip, vs Mach.
424    pub cp_vs_mach: Vec<(f64, f64)>,
425    /// Induced-drag factor per radian² vs Mach (zero: not in the Barrowman model).
426    pub induced_k_vs_mach: Vec<(f64, f64)>,
427    /// Reference area, π/4·d² of the largest diameter.
428    pub ref_area_m2: f64,
429    /// Total aerodynamic length (nose + tube).
430    pub total_length_m: f64,
431    /// Largest body diameter.
432    pub max_diameter_m: f64,
433}
434
435#[cfg(feature = "ballistics")]
436impl AeroAnalysis {
437    /// Bundle the analysis with the remaining airframe parameters.
438    pub fn airframe(
439        &self,
440        cg_dry_m: f64,
441        cg_prop_m: f64,
442        pitch_inertia_kg_m2: f64,
443        recovery_cd_area_m2: f64,
444    ) -> crate::Airframe {
445        crate::Airframe {
446            diameter_m: self.max_diameter_m,
447            cd_vs_mach: self.cd_vs_mach.clone(),
448            cna_vs_mach: self.cna_vs_mach.clone(),
449            cp_vs_mach: self.cp_vs_mach.clone(),
450            cg_dry_m,
451            cg_prop_m,
452            pitch_inertia_kg_m2,
453            induced_k_vs_mach: self.induced_k_vs_mach.clone(),
454            recovery_cd_area_m2,
455        }
456    }
457
458    /// Drag + atmosphere + body lift for this geometry in the given wind.
459    pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
460        crate::ballistics::BallisticForces::new(
461            self.max_diameter_m,
462            self.cd_vs_mach.clone(),
463            crate::ballistics::Atmosphere::default(),
464            wind_mps,
465        )?
466        .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
467    }
468
469    /// Weathercock parameters for this geometry.
470    pub fn weathercock_params(
471        &self,
472        cg_dry_m: f64,
473        cg_prop_m: f64,
474        pitch_inertia_kg_m2: f64,
475    ) -> Result<crate::WeathercockParams> {
476        crate::WeathercockParams::new(
477            self.cna_vs_mach.clone(),
478            self.cp_vs_mach.clone(),
479            cg_dry_m,
480            cg_prop_m,
481            self.max_diameter_m,
482            pitch_inertia_kg_m2,
483            0.2,
484        )
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    fn example() -> RocketGeometry {
493        RocketGeometry::new(
494            NoseCone {
495                shape: NoseShape::Conical,
496                length_m: 0.1,
497                diameter_m: 0.024,
498            },
499            BodyTube {
500                length_m: 0.4,
501                diameter_m: 0.024,
502            },
503            TrapezoidalFins {
504                count: 3,
505                root_chord_m: 0.05,
506                tip_chord_m: 0.03,
507                span_m: 0.04,
508                leading_sweep_m: 0.02,
509                thickness_m: 0.002,
510            },
511        )
512    }
513
514    #[test]
515    fn nose_cp_fractions_match_barrowman() {
516        assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
517        assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
518        assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
519        assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
520    }
521
522    #[test]
523    fn conical_pressure_drag_uses_half_angle() {
524        let cd = NoseShape::Conical.pressure_cd(0.1, 0.024);
525        let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
526        assert!((cd - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
527        assert_eq!(NoseShape::Ogive.pressure_cd(0.1, 0.024), 0.0);
528    }
529
530    #[test]
531    fn fin_cp_matches_the_trapezoid_formula() {
532        let geo = example();
533        // Independently: MAC = (2/3)(cr+ct - cr*ct/(cr+ct)), yMAC = s(cr+2ct)/(3(cr+ct)).
534        let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
535        let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
536        let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
537        assert!((geo.fin_cp() - expected).abs() < 1e-12, "{}", geo.fin_cp());
538    }
539
540    #[test]
541    fn fin_set_cna_matches_diederich() {
542        let geo = example();
543        // One fin: 2π·span²/(1+sqrt(1+(span²/(A·cosΓ))²)) / Aref, then N·(1+tau).
544        let area = geo.fin_area();
545        let cos = geo.midchord_cos();
546        let ratio = geo.fins.span_m * geo.fins.span_m / (area * cos);
547        let one = 2.0 * std::f64::consts::PI * geo.fins.span_m * geo.fins.span_m
548            / (1.0 + (1.0 + ratio * ratio).sqrt())
549            / geo.ref_area_m2();
550        let expected = 3.0 * one * (1.0 + geo.interference_tau());
551        let actual = geo.fin_set_cna(0.0);
552        assert!(
553            (actual - expected).abs() / expected < 1e-9,
554            "actual {actual} expected {expected}"
555        );
556        // And the classic closed form 8N(s/d)²/(1+sqrt(1+(2l/(cr+ct))²))·K.
557        let d = geo.max_diameter_m();
558        let classic = 8.0 * 3.0 * (geo.fins.span_m / d).powi(2)
559            / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
560            * (1.0 + geo.interference_tau());
561        assert!(
562            (actual - classic).abs() / classic < 0.02,
563            "actual {actual} classic {classic}"
564        );
565    }
566
567    #[test]
568    fn total_cp_is_the_moment_sum() {
569        let geo = example();
570        let fin_cna = geo.fin_set_cna(0.0);
571        let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs()) / (2.0 + fin_cna);
572        assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
573        // CP must sit aft of the nose CP (fins pull it back).
574        assert!(geo.total_cp(0.0) > geo.nose_cp());
575    }
576
577    #[test]
578    fn base_cd_matches_openrocket() {
579        assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
580        assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
581        assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
582    }
583
584    #[test]
585    fn analysis_produces_finite_curves_and_roughness_costs_drag() {
586        let geo = example();
587        let analysis = geo.analyze();
588        for &(m, cd) in &analysis.cd_vs_mach {
589            assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
590        }
591        for &(m, cp) in &analysis.cp_vs_mach {
592            assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
593        }
594        let smooth = geo.with_roughness(1e-6).analyze();
595        let rough = geo.with_roughness(500e-6).analyze();
596        assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
597    }
598}