1use crate::{Error, Result};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum NoseShape {
16 Conical,
18 Ogive,
20 Elliptical,
22 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 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 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 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#[derive(Debug, Clone, Copy)]
83pub struct NoseCone {
84 pub shape: NoseShape,
86 pub length_m: f64,
88 pub diameter_m: f64,
90}
91
92#[derive(Debug, Clone, Copy)]
94pub struct BodyTube {
95 pub length_m: f64,
97 pub diameter_m: f64,
99}
100
101#[derive(Debug, Clone, Copy)]
104pub struct TrapezoidalFins {
105 pub count: u32,
107 pub root_chord_m: f64,
109 pub tip_chord_m: f64,
111 pub span_m: f64,
113 pub leading_sweep_m: f64,
115 pub thickness_m: f64,
117}
118
119#[derive(Debug, Clone, Copy)]
121pub struct RocketGeometry {
122 pub nose: NoseCone,
124 pub tube: BodyTube,
126 pub fins: TrapezoidalFins,
128 pub roughness_m: f64,
130}
131
132impl RocketGeometry {
133 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 pub fn with_roughness(mut self, roughness_m: f64) -> Self {
145 self.roughness_m = roughness_m;
146 self
147 }
148
149 pub fn total_length_m(&self) -> f64 {
151 self.nose.length_m + self.tube.length_m
152 }
153
154 pub fn max_diameter_m(&self) -> f64 {
156 self.nose.diameter_m.max(self.tube.diameter_m)
157 }
158
159 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 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 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 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 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 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 fn fin_cp_abs(&self) -> f64 {
240 self.total_length_m() - self.fins.root_chord_m + self.fin_cp()
241 }
242
243 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 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 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_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
339pub 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#[derive(Debug, Clone)]
418pub struct AeroAnalysis {
419 pub cd_vs_mach: Vec<(f64, f64)>,
421 pub cna_vs_mach: Vec<(f64, f64)>,
423 pub cp_vs_mach: Vec<(f64, f64)>,
425 pub induced_k_vs_mach: Vec<(f64, f64)>,
427 pub ref_area_m2: f64,
429 pub total_length_m: f64,
431 pub max_diameter_m: f64,
433}
434
435#[cfg(feature = "ballistics")]
436impl AeroAnalysis {
437 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 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 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 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 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 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 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}