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//! Induced drag from angle of attack uses the normal-force tilt model
8//! (Cd += Cnα·α²). Not covered (deliberately): supersonic wave drag,
9//! boat-tails/transitions, and elliptical or free-form fins.
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    /// CP location as a fraction of nose length, from the volume/area relation
45    /// XB = L − V/A_base. Ogive uses the standard tangent-ogive value.
46    fn cp_fraction(self) -> f64 {
47        match self {
48            Self::Conical => 2.0 / 3.0,
49            Self::Ogive => 0.466,
50            Self::Elliptical => 1.0 / 3.0,
51            Self::Parabolic => 0.5,
52        }
53    }
54
55    /// Pressure-drag coefficient at subsonic speeds. Conical noses use
56    /// 0.8·sin²(φ) with the half-angle φ = atan(r/L); streamlined shapes are
57    /// taken as zero, the usual Barrowman approximation.
58    fn pressure_cd(self, length_m: f64, diameter_m: f64) -> f64 {
59        match self {
60            Self::Conical => {
61                let r = diameter_m / 2.0;
62                let sin_phi = r / (r * r + length_m * length_m).sqrt();
63                0.8 * sin_phi * sin_phi
64            }
65            _ => 0.0,
66        }
67    }
68
69    /// Lateral (wetted) area. Conical uses the exact slant area; the others
70    /// approximate the cylinder at the base diameter.
71    fn lateral_area(self, length_m: f64, diameter_m: f64) -> f64 {
72        let r = diameter_m / 2.0;
73        match self {
74            Self::Conical => std::f64::consts::PI * r * (r * r + length_m * length_m).sqrt(),
75            _ => std::f64::consts::PI * diameter_m * length_m,
76        }
77    }
78}
79
80/// A nose cone with length and base diameter, in metres.
81#[derive(Debug, Clone, Copy)]
82pub struct NoseCone {
83    /// Shape family.
84    pub shape: NoseShape,
85    /// Nose length in metres.
86    pub length_m: f64,
87    /// Base diameter in metres.
88    pub diameter_m: f64,
89}
90
91/// The cylindrical body tube behind the nose.
92#[derive(Debug, Clone, Copy)]
93pub struct BodyTube {
94    /// Tube length in metres.
95    pub length_m: f64,
96    /// Tube diameter in metres.
97    pub diameter_m: f64,
98}
99
100/// Identical trapezoidal fins at the aft end of the tube. `leading_sweep_m` is
101/// the horizontal offset of the tip leading edge from the root leading edge.
102#[derive(Debug, Clone, Copy)]
103pub struct TrapezoidalFins {
104    /// Number of identical fins.
105    pub count: u32,
106    /// Root chord length in metres.
107    pub root_chord_m: f64,
108    /// Tip chord length in metres.
109    pub tip_chord_m: f64,
110    /// Fin semispan in metres (body surface to tip).
111    pub span_m: f64,
112    /// Horizontal offset of the tip leading edge from the root leading edge.
113    pub leading_sweep_m: f64,
114    /// Fin thickness in metres.
115    pub thickness_m: f64,
116}
117
118/// The complete slender geometry.
119#[derive(Debug, Clone, Copy)]
120pub struct RocketGeometry {
121    /// Nose cone.
122    pub nose: NoseCone,
123    /// Cylindrical body tube.
124    pub tube: BodyTube,
125    /// Aft trapezoidal fin set.
126    pub fins: TrapezoidalFins,
127    /// Roughness height in metres (default 20 µm, painted surface).
128    pub roughness_m: f64,
129}
130
131impl RocketGeometry {
132    /// Build with the default painted-surface roughness (20 µm).
133    pub fn new(nose: NoseCone, tube: BodyTube, fins: TrapezoidalFins) -> Self {
134        Self {
135            nose,
136            tube,
137            fins,
138            roughness_m: 20e-6,
139        }
140    }
141
142    /// Set the roughness height in metres.
143    pub fn with_roughness(mut self, roughness_m: f64) -> Self {
144        self.roughness_m = roughness_m;
145        self
146    }
147
148    /// Total aerodynamic length (nose + tube).
149    pub fn total_length_m(&self) -> f64 {
150        self.nose.length_m + self.tube.length_m
151    }
152
153    /// Largest body diameter (nose base or tube).
154    pub fn max_diameter_m(&self) -> f64 {
155        self.nose.diameter_m.max(self.tube.diameter_m)
156    }
157
158    /// Reference area, π/4·d² of the largest diameter.
159    pub fn ref_area_m2(&self) -> f64 {
160        std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
161    }
162
163    fn fin_area(&self) -> f64 {
164        self.fins.span_m * (self.fins.root_chord_m + self.fins.tip_chord_m) / 2.0
165    }
166
167    /// Mean aerodynamic chord of one fin (trapezoid formula).
168    fn mac(&self) -> f64 {
169        let (cr, ct) = (self.fins.root_chord_m, self.fins.tip_chord_m);
170        (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct))
171    }
172
173    /// Fin CP measured from the fin's root leading edge: xMAC_LE + MAC/4.
174    fn fin_cp(&self) -> f64 {
175        let (cr, ct, s, m) = (
176            self.fins.root_chord_m,
177            self.fins.tip_chord_m,
178            self.fins.span_m,
179            self.fins.leading_sweep_m,
180        );
181        let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
182        let x_mac_le = m * y_mac / s;
183        x_mac_le + 0.25 * self.mac()
184    }
185
186    fn midchord_cos(&self) -> f64 {
187        let (cr, ct, s, m) = (
188            self.fins.root_chord_m,
189            self.fins.tip_chord_m,
190            self.fins.span_m,
191            self.fins.leading_sweep_m,
192        );
193        let gamma = ((m + ct / 2.0 - cr / 2.0) / s).atan();
194        gamma.cos()
195    }
196
197    /// Normal-force slope of one fin, divided by the reference area.
198    /// OpenRocket's subsonic Diederich simplification.
199    fn fin_cna1(&self, mach: f64) -> f64 {
200        let span = self.fins.span_m;
201        let area = self.fin_area();
202        let cos = self.midchord_cos();
203        if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
204            return 0.0;
205        }
206        let ratio = (span * span) / (area * cos);
207        2.0 * std::f64::consts::PI * span * span
208            / (1.0 + (1.0 + (1.0 - mach * mach) * ratio * ratio).sqrt())
209            / self.ref_area_m2()
210    }
211
212    /// Body-interference factor (1 + tau) with tau = r/(span + r).
213    fn interference_tau(&self) -> f64 {
214        let r = self.max_diameter_m() / 2.0;
215        r / (self.fins.span_m + r)
216    }
217
218    fn fin_count_factor(&self) -> f64 {
219        match self.fins.count {
220            5 => 0.948,
221            6 => 0.913,
222            7 => 0.854,
223            8 => 0.810,
224            n if n >= 9 => 0.75,
225            _ => 1.0,
226        }
227    }
228
229    /// Total fin-set normal-force slope for this Mach number.
230    ///
231    /// The Diederich `fin_cna1` is the slope of a fin *pair* (a wing of
232    /// semispan `span`), so a set of `n` fins contributes `n/2` pairs,
233    /// with fin-body carryover (1 + tau) and a many-fin interference
234    /// reduction. This matches OpenRocket's `FinSetCalc` convention,
235    /// verified against its BarrowmanCalculatorTest Alpha III values.
236    fn fin_set_cna(&self, mach: f64) -> f64 {
237        self.fin_cna1(mach)
238            * (1.0 + self.interference_tau())
239            * self.fin_count_factor()
240            * (self.fins.count as f64 / 2.0)
241    }
242
243    /// Fin CP measured from the nose tip (fin trailing edge at body end).
244    fn fin_cp_abs(&self) -> f64 {
245        self.total_length_m() - self.fins.root_chord_m + self.fin_cp()
246    }
247
248    /// Nose radius profile r(x) with x measured from the tip.
249    fn nose_radius(&self, x: f64) -> f64 {
250        let l = self.nose.length_m;
251        let r = self.nose.diameter_m / 2.0;
252        let t = (x / l).clamp(0.0, 1.0);
253        match self.nose.shape {
254            NoseShape::Conical => r * t,
255            NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
256            NoseShape::Parabolic => r * t * (2.0 - t),
257            NoseShape::Ogive => {
258                let rho = (r * r + l * l) / (2.0 * r);
259                (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
260            }
261        }
262    }
263
264    /// Solid-nose CG (from the tip) and pitch inertia per unit mass about
265    /// that CG, from numerical integration of the radius profile.
266    fn nose_volume_properties(&self) -> (f64, f64) {
267        const N: usize = 1024;
268        let l = self.nose.length_m;
269        let dx = l / N as f64;
270        let mut volume = 0.0; // ∫ r² dx (up to the density·π factor)
271        let mut moment = 0.0; // ∫ x r² dx
272        for i in 0..N {
273            let x = (i as f64 + 0.5) * dx;
274            let r2 = self.nose_radius(x).powi(2);
275            volume += r2 * dx;
276            moment += x * r2 * dx;
277        }
278        let cg = moment / volume;
279        let mut inertia = 0.0; // ∫ r² (r²/4 + (x−cg)²) dx
280        for i in 0..N {
281            let x = (i as f64 + 0.5) * dx;
282            let r2 = self.nose_radius(x).powi(2);
283            inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
284        }
285        (cg, inertia / volume)
286    }
287
288    /// Fin planform centroid, measured from the root leading edge.
289    fn fin_planform_cg(&self) -> f64 {
290        let (cr, ct, m) = (
291            self.fins.root_chord_m,
292            self.fins.tip_chord_m,
293            self.fins.leading_sweep_m,
294        );
295        (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct))
296    }
297
298    /// Fin centroid height above the body surface.
299    fn fin_radial_cg(&self) -> f64 {
300        let (cr, ct, s) = (
301            self.fins.root_chord_m,
302            self.fins.tip_chord_m,
303            self.fins.span_m,
304        );
305        s * (cr + 2.0 * ct) / (3.0 * (cr + ct))
306    }
307
308    /// Combine component masses with the geometry's component CGs into a
309    /// dry CG and a pitch-inertia estimate about that CG.
310    ///
311    /// The nose is a solid of revolution (numerically integrated), the
312    /// tube a thin-walled cylinder, and the fins and optional motor casing
313    /// point masses; evenly spaced fins add n/2 · m · (r_body + y_cg)² for
314    /// their radial offset. `casing` is (mass_kg, CG from the nose tip).
315    pub fn mass_properties(
316        &self,
317        nose_kg: f64,
318        tube_kg: f64,
319        fins_kg: f64,
320        casing: Option<(f64, f64)>,
321    ) -> MassProperties {
322        assert!(
323            nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
324            "component masses must be non-negative"
325        );
326        let (nose_cg, nose_inertia) = self.nose_volume_properties();
327        let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
328        let fin_cg = self.total_length_m() - self.fins.root_chord_m + self.fin_planform_cg();
329        let mut parts: Vec<(f64, f64, f64)> = vec![
330            (nose_kg, nose_cg, nose_kg * nose_inertia),
331            (
332                tube_kg,
333                tube_cg,
334                tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
335            ),
336            (fins_kg, fin_cg, 0.0),
337        ];
338        if let Some((kg, cg)) = casing {
339            parts.push((kg, cg, 0.0));
340        }
341        let total: f64 = parts.iter().map(|p| p.0).sum();
342        assert!(total > 0.0, "component masses must sum to a positive mass");
343        let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
344        let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
345        inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
346        MassProperties {
347            dry_mass_kg: total,
348            dry_cg_m: cg,
349            pitch_inertia_kg_m2: inertia,
350        }
351    }
352
353    /// Total CP from the component moment sum (nose + fin set).
354    fn total_cp(&self, mach: f64) -> f64 {
355        let nose_cna = 2.0;
356        let fin_cna = self.fin_set_cna(mach);
357        (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
358            + fin_cna * self.fin_cp_abs())
359            / (nose_cna + fin_cna)
360    }
361
362    #[cfg(test)]
363    fn nose_cp(&self) -> f64 {
364        self.nose.length_m * self.nose.shape.cp_fraction()
365    }
366
367    fn base_cd(mach: f64) -> f64 {
368        if mach <= 1.0 {
369            0.12 + 0.13 * mach * mach
370        } else {
371            0.25 / mach
372        }
373    }
374
375    fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
376        let length = self.total_length_m();
377        let velocity = mach * sound_mps;
378        let reynolds = velocity * length / 1.5e-5;
379        let rough_ratio = self.roughness_m / length;
380        let cf_turbulent = if reynolds < 1.0e4 {
381            1.48e-2
382        } else {
383            1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
384        };
385        let cf_rough = 0.032 * rough_ratio.powf(0.2);
386        // Fully turbulent: take the larger of the smooth and roughness-limited
387        // coefficients, then the subsonic compressibility correction.
388        let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
389        let r = self.max_diameter_m() / 2.0;
390        let fineness = length / r;
391        let correction = 1.0 + 1.0 / (2.0 * fineness);
392        let body_wet = self
393            .nose
394            .shape
395            .lateral_area(self.nose.length_m, self.nose.diameter_m)
396            + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m;
397        let fin_wet = 2.0 * self.fins.count as f64 * self.fin_area();
398        let thickness_factor = 1.0 + 2.0 * self.fins.thickness_m / self.mac();
399        cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
400    }
401
402    /// Full analysis. Mach samples at 0.0, 0.2, 0.4, 0.6, 0.8, 1.0, and 3.0;
403    /// the last point simply repeats the Mach-1 values (subsonic clamp).
404    pub fn analyze(&self) -> AeroAnalysis {
405        self.validate();
406        let machs: [f64; 7] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 3.0];
407        let mut cd_vs_mach = Vec::new();
408        let mut cna_vs_mach = Vec::new();
409        let mut cp_vs_mach = Vec::new();
410        let mut induced_k_vs_mach = Vec::new();
411        for &m in &machs {
412            let effective = m.min(1.0);
413            cd_vs_mach.push((
414                m,
415                Self::base_cd(effective)
416                    + self
417                        .nose
418                        .shape
419                        .pressure_cd(self.nose.length_m, self.nose.diameter_m)
420                    + self.friction_cd(effective, 340.0),
421            ));
422            let fin_cna = self.fin_set_cna(effective);
423            cna_vs_mach.push((m, 2.0 + fin_cna));
424            cp_vs_mach.push((m, self.total_cp(effective)));
425            // Induced drag from the normal force's tilt onto the flight
426            // path: Cd_induced = Cnα·α², so k = Cnα (OpenRocket's model).
427            induced_k_vs_mach.push((m, 2.0 + fin_cna));
428        }
429        AeroAnalysis {
430            cd_vs_mach,
431            cna_vs_mach,
432            cp_vs_mach,
433            induced_k_vs_mach,
434            ref_area_m2: self.ref_area_m2(),
435            total_length_m: self.total_length_m(),
436            max_diameter_m: self.max_diameter_m(),
437        }
438    }
439
440    fn validate(&self) {
441        assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
442        assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
443        assert!(self.fins.count > 0);
444        assert!(self.fins.span_m > 0.0);
445        assert!(self.fins.root_chord_m > 0.0 && self.fins.tip_chord_m > 0.0);
446        assert!(self.fins.thickness_m > 0.0);
447    }
448}
449
450/// Parse the compact geometry spec used by the CLI:
451/// `nose=shape:length:diameter,tube=length:diameter,fins=count:root:tip:span:sweep:thickness`.
452/// The fin thickness is optional (default 2 mm). Lengths are in metres.
453pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
454    let mut nose = None;
455    let mut tube = None;
456    let mut fins = None;
457    for item in spec.split(',') {
458        let (key, val) = item
459            .split_once('=')
460            .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
461        let parts: Vec<&str> = val.split(':').collect();
462        let number = |i: usize| -> Result<f64> {
463            parts
464                .get(i)
465                .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
466                .parse()
467                .map_err(|_| Error::invalid("geometry", "non-numeric field"))
468        };
469        match key {
470            "nose" => {
471                if parts.len() != 3 {
472                    return Err(Error::invalid("nose", "expected shape:length:diameter"));
473                }
474                let shape = parts[0].parse::<NoseShape>()?;
475                nose = Some(NoseCone {
476                    shape,
477                    length_m: number(1)?,
478                    diameter_m: number(2)?,
479                });
480            }
481            "tube" => {
482                if parts.len() != 2 {
483                    return Err(Error::invalid("tube", "expected length:diameter"));
484                }
485                tube = Some(BodyTube {
486                    length_m: number(0)?,
487                    diameter_m: number(1)?,
488                });
489            }
490            "fins" => {
491                if !(5..=6).contains(&parts.len()) {
492                    return Err(Error::invalid(
493                        "fins",
494                        "expected count:root:tip:span:sweep[:thickness]",
495                    ));
496                }
497                let count: u32 = parts[0]
498                    .parse()
499                    .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
500                fins = Some(TrapezoidalFins {
501                    count,
502                    root_chord_m: number(1)?,
503                    tip_chord_m: number(2)?,
504                    span_m: number(3)?,
505                    leading_sweep_m: number(4)?,
506                    thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
507                });
508            }
509            other => {
510                return Err(Error::invalid(
511                    "geometry key",
512                    match other {
513                        "nose" | "tube" | "fins" => unreachable!(),
514                        _ => "must be nose, tube or fins",
515                    },
516                ))
517            }
518        }
519    }
520    Ok(RocketGeometry::new(
521        nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
522        tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
523        fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
524    ))
525}
526
527/// Dry-mass breakdown estimated from geometry and component masses by
528/// [`RocketGeometry::mass_properties`].
529#[derive(Debug, Clone, Copy)]
530pub struct MassProperties {
531    /// Sum of the component masses (structure plus motor casing).
532    pub dry_mass_kg: f64,
533    /// Dry centre of gravity, metres aft of the nose tip.
534    pub dry_cg_m: f64,
535    /// Pitch inertia about the dry CG, kg·m².
536    pub pitch_inertia_kg_m2: f64,
537}
538
539/// The curves and reference quantities produced by [`RocketGeometry::analyze`].
540#[derive(Debug, Clone)]
541pub struct AeroAnalysis {
542    /// Axial drag coefficient vs Mach (subsonic, clamped above Mach 1).
543    pub cd_vs_mach: Vec<(f64, f64)>,
544    /// Normal-force coefficient slope per radian vs Mach (nose + fin set).
545    pub cna_vs_mach: Vec<(f64, f64)>,
546    /// Centre of pressure, metres aft of the nose tip, vs Mach.
547    pub cp_vs_mach: Vec<(f64, f64)>,
548    /// Induced-drag factor per radian² vs Mach (normal-force tilt: k = Cnα).
549    pub induced_k_vs_mach: Vec<(f64, f64)>,
550    /// Reference area, π/4·d² of the largest diameter.
551    pub ref_area_m2: f64,
552    /// Total aerodynamic length (nose + tube).
553    pub total_length_m: f64,
554    /// Largest body diameter.
555    pub max_diameter_m: f64,
556}
557
558#[cfg(feature = "ballistics")]
559impl AeroAnalysis {
560    /// Bundle the analysis with the remaining airframe parameters.
561    pub fn airframe(
562        &self,
563        cg_dry_m: f64,
564        cg_prop_m: f64,
565        pitch_inertia_kg_m2: f64,
566        recovery_cd_area_m2: f64,
567    ) -> crate::Airframe {
568        crate::Airframe {
569            diameter_m: self.max_diameter_m,
570            cd_vs_mach: self.cd_vs_mach.clone(),
571            cna_vs_mach: self.cna_vs_mach.clone(),
572            cp_vs_mach: self.cp_vs_mach.clone(),
573            cg_dry_m,
574            cg_prop_m,
575            pitch_inertia_kg_m2,
576            induced_k_vs_mach: self.induced_k_vs_mach.clone(),
577            recovery_cd_area_m2,
578        }
579    }
580
581    /// Drag + atmosphere + body lift for this geometry in the given wind.
582    pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
583        crate::ballistics::BallisticForces::new(
584            self.max_diameter_m,
585            self.cd_vs_mach.clone(),
586            crate::ballistics::Atmosphere::default(),
587            wind_mps,
588        )?
589        .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
590    }
591
592    /// Weathercock parameters for this geometry.
593    pub fn weathercock_params(
594        &self,
595        cg_dry_m: f64,
596        cg_prop_m: f64,
597        pitch_inertia_kg_m2: f64,
598    ) -> Result<crate::WeathercockParams> {
599        crate::WeathercockParams::new(
600            self.cna_vs_mach.clone(),
601            self.cp_vs_mach.clone(),
602            cg_dry_m,
603            cg_prop_m,
604            self.max_diameter_m,
605            pitch_inertia_kg_m2,
606            0.2,
607        )
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    fn example() -> RocketGeometry {
616        RocketGeometry::new(
617            NoseCone {
618                shape: NoseShape::Conical,
619                length_m: 0.1,
620                diameter_m: 0.024,
621            },
622            BodyTube {
623                length_m: 0.4,
624                diameter_m: 0.024,
625            },
626            TrapezoidalFins {
627                count: 3,
628                root_chord_m: 0.05,
629                tip_chord_m: 0.03,
630                span_m: 0.04,
631                leading_sweep_m: 0.02,
632                thickness_m: 0.002,
633            },
634        )
635    }
636
637    #[test]
638    fn nose_cp_fractions_match_barrowman() {
639        assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
640        assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
641        assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
642        assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
643    }
644
645    #[test]
646    fn conical_pressure_drag_uses_half_angle() {
647        let cd = NoseShape::Conical.pressure_cd(0.1, 0.024);
648        let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
649        assert!((cd - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
650        assert_eq!(NoseShape::Ogive.pressure_cd(0.1, 0.024), 0.0);
651    }
652
653    #[test]
654    fn fin_cp_matches_the_trapezoid_formula() {
655        let geo = example();
656        // Independently: MAC = (2/3)(cr+ct - cr*ct/(cr+ct)), yMAC = s(cr+2ct)/(3(cr+ct)).
657        let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
658        let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
659        let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
660        assert!((geo.fin_cp() - expected).abs() < 1e-12, "{}", geo.fin_cp());
661    }
662
663    #[test]
664    fn conical_nose_volume_properties_match_the_solid_cone() {
665        let geo = example(); // conical nose, L = 0.1, R = 0.012
666        let (cg, inertia) = geo.nose_volume_properties();
667        assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
668        let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
669        assert!(
670            (inertia - expected).abs() / expected < 1e-4,
671            "inertia {inertia} expected {expected}"
672        );
673    }
674
675    #[test]
676    fn fin_centroid_matches_polygon_limits() {
677        let geo = example();
678        // Rectangle limit (m = 0, ct = cr): centroid at cr/2, height s/2.
679        let rectangular = RocketGeometry::new(
680            geo.nose,
681            geo.tube,
682            TrapezoidalFins {
683                count: 3,
684                root_chord_m: 0.05,
685                tip_chord_m: 0.05,
686                span_m: 0.04,
687                leading_sweep_m: 0.0,
688                thickness_m: 0.002,
689            },
690        );
691        assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
692        assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
693        // Triangle limit (ct = 0): (cr + m)/3 from the leading edge, s/3 up.
694        let triangular = RocketGeometry::new(
695            geo.nose,
696            geo.tube,
697            TrapezoidalFins {
698                count: 3,
699                root_chord_m: 0.06,
700                tip_chord_m: 0.0,
701                span_m: 0.03,
702                leading_sweep_m: 0.03,
703                thickness_m: 0.002,
704            },
705        );
706        assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
707        assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
708    }
709
710    #[test]
711    fn mass_properties_are_consistent() {
712        let geo = example();
713        let props = geo.mass_properties(0.012, 0.020, 0.006, None);
714        assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
715        // CG is the mass-weighted component mean, inside the airframe.
716        let (nose_cg, _) = geo.nose_volume_properties();
717        let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
718        let fin_cg = geo.total_length_m() - geo.fins.root_chord_m + geo.fin_planform_cg();
719        let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
720        assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
721        assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
722        assert!(props.pitch_inertia_kg_m2 > 0.0);
723        // A casing at the aft end moves the CG aft and adds inertia.
724        let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
725        assert!(with_casing.dry_cg_m > props.dry_cg_m);
726        assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
727        assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
728        // A heavier nose moves the CG forward.
729        let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
730        assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
731    }
732
733    #[test]
734    fn induced_k_tracks_cna() {
735        // Tilt model: the induced-drag factor equals the normal-force slope.
736        let geo = example();
737        let analysis = geo.analyze();
738        for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
739            assert_eq!(k, cna, "mach {m}");
740        }
741    }
742
743    #[test]
744    fn mass_properties_single_component_is_exact() {
745        let geo = example();
746        let props = geo.mass_properties(0.0, 0.05, 0.0, None);
747        // Only the tube: CG at its midpoint, inertia its thin-cylinder value.
748        let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
749        assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
750        let expected =
751            0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
752        assert!(
753            (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
754            "{} vs {expected}",
755            props.pitch_inertia_kg_m2
756        );
757    }
758
759    #[test]
760    fn fin_set_cna_matches_diederich() {
761        let geo = example();
762        // Fin pair: 2π·span²/(1+sqrt(1+(span²/(A·cosΓ))²)) / Aref, then
763        // (N/2)·(1+tau) — the Diederich value is a wing (two fins).
764        let area = geo.fin_area();
765        let cos = geo.midchord_cos();
766        let ratio = geo.fins.span_m * geo.fins.span_m / (area * cos);
767        let one = 2.0 * std::f64::consts::PI * geo.fins.span_m * geo.fins.span_m
768            / (1.0 + (1.0 + ratio * ratio).sqrt())
769            / geo.ref_area_m2();
770        let expected = 1.5 * one * (1.0 + geo.interference_tau());
771        let actual = geo.fin_set_cna(0.0);
772        assert!(
773            (actual - expected).abs() / expected < 1e-9,
774            "actual {actual} expected {expected}"
775        );
776        // And the classic closed form 4N(s/d)²/(1+sqrt(1+(2l/(cr+ct))²))·K.
777        let d = geo.max_diameter_m();
778        let classic = 4.0 * 3.0 * (geo.fins.span_m / d).powi(2)
779            / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
780            * (1.0 + geo.interference_tau());
781        assert!(
782            (actual - classic).abs() / classic < 0.02,
783            "actual {actual} classic {classic}"
784        );
785    }
786
787    #[test]
788    fn total_cp_is_the_moment_sum() {
789        let geo = example();
790        let fin_cna = geo.fin_set_cna(0.0);
791        let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs()) / (2.0 + fin_cna);
792        assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
793        // CP must sit aft of the nose CP (fins pull it back).
794        assert!(geo.total_cp(0.0) > geo.nose_cp());
795    }
796
797    #[test]
798    fn base_cd_matches_openrocket() {
799        assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
800        assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
801        assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
802    }
803
804    #[test]
805    fn analysis_produces_finite_curves_and_roughness_costs_drag() {
806        let geo = example();
807        let analysis = geo.analyze();
808        for &(m, cd) in &analysis.cd_vs_mach {
809            assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
810        }
811        for &(m, cp) in &analysis.cp_vs_mach {
812            assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
813        }
814        let smooth = geo.with_roughness(1e-6).analyze();
815        let rough = geo.with_roughness(500e-6).analyze();
816        assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
817    }
818}