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 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 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 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#[derive(Debug, Clone, Copy)]
82pub struct NoseCone {
83 pub shape: NoseShape,
85 pub length_m: f64,
87 pub diameter_m: f64,
89}
90
91#[derive(Debug, Clone, Copy)]
93pub struct BodyTube {
94 pub length_m: f64,
96 pub diameter_m: f64,
98}
99
100#[derive(Debug, Clone, Copy)]
103pub struct TrapezoidalFins {
104 pub count: u32,
106 pub root_chord_m: f64,
108 pub tip_chord_m: f64,
110 pub span_m: f64,
112 pub leading_sweep_m: f64,
114 pub thickness_m: f64,
116}
117
118#[derive(Debug, Clone, Copy)]
120pub struct RocketGeometry {
121 pub nose: NoseCone,
123 pub tube: BodyTube,
125 pub fins: TrapezoidalFins,
127 pub roughness_m: f64,
129}
130
131impl RocketGeometry {
132 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 pub fn with_roughness(mut self, roughness_m: f64) -> Self {
144 self.roughness_m = roughness_m;
145 self
146 }
147
148 pub fn total_length_m(&self) -> f64 {
150 self.nose.length_m + self.tube.length_m
151 }
152
153 pub fn max_diameter_m(&self) -> f64 {
155 self.nose.diameter_m.max(self.tube.diameter_m)
156 }
157
158 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 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 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 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 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 fn fin_set_cna(&self, mach: f64) -> f64 {
231 self.fin_cna1(mach)
232 * (1.0 + self.interference_tau())
233 * self.fin_count_factor()
234 * self.fins.count as f64
235 }
236
237 fn fin_cp_abs(&self) -> f64 {
239 self.total_length_m() - self.fins.root_chord_m + self.fin_cp()
240 }
241
242 fn total_cp(&self, mach: f64) -> f64 {
244 let nose_cna = 2.0;
245 let fin_cna = self.fin_set_cna(mach);
246 (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
247 + fin_cna * self.fin_cp_abs())
248 / (nose_cna + fin_cna)
249 }
250
251 #[cfg(test)]
252 fn nose_cp(&self) -> f64 {
253 self.nose.length_m * self.nose.shape.cp_fraction()
254 }
255
256 fn base_cd(mach: f64) -> f64 {
257 if mach <= 1.0 {
258 0.12 + 0.13 * mach * mach
259 } else {
260 0.25 / mach
261 }
262 }
263
264 fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
265 let length = self.total_length_m();
266 let velocity = mach * sound_mps;
267 let reynolds = velocity * length / 1.5e-5;
268 let rough_ratio = self.roughness_m / length;
269 let cf_turbulent = if reynolds < 1.0e4 {
270 1.48e-2
271 } else {
272 1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
273 };
274 let cf_rough = 0.032 * rough_ratio.powf(0.2);
275 let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
278 let r = self.max_diameter_m() / 2.0;
279 let fineness = length / r;
280 let correction = 1.0 + 1.0 / (2.0 * fineness);
281 let body_wet = self
282 .nose
283 .shape
284 .lateral_area(self.nose.length_m, self.nose.diameter_m)
285 + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m;
286 let fin_wet = 2.0 * self.fins.count as f64 * self.fin_area();
287 let thickness_factor = 1.0 + 2.0 * self.fins.thickness_m / self.mac();
288 cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
289 }
290
291 pub fn analyze(&self) -> AeroAnalysis {
294 self.validate();
295 let machs: [f64; 7] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 3.0];
296 let mut cd_vs_mach = Vec::new();
297 let mut cna_vs_mach = Vec::new();
298 let mut cp_vs_mach = Vec::new();
299 let mut induced_k_vs_mach = Vec::new();
300 for &m in &machs {
301 let effective = m.min(1.0);
302 cd_vs_mach.push((
303 m,
304 Self::base_cd(effective)
305 + self
306 .nose
307 .shape
308 .pressure_cd(self.nose.length_m, self.nose.diameter_m)
309 + self.friction_cd(effective, 340.0),
310 ));
311 let fin_cna = self.fin_set_cna(effective);
312 cna_vs_mach.push((m, 2.0 + fin_cna));
313 cp_vs_mach.push((m, self.total_cp(effective)));
314 induced_k_vs_mach.push((m, 0.0));
316 }
317 AeroAnalysis {
318 cd_vs_mach,
319 cna_vs_mach,
320 cp_vs_mach,
321 induced_k_vs_mach,
322 ref_area_m2: self.ref_area_m2(),
323 total_length_m: self.total_length_m(),
324 max_diameter_m: self.max_diameter_m(),
325 }
326 }
327
328 fn validate(&self) {
329 assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
330 assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
331 assert!(self.fins.count > 0);
332 assert!(self.fins.span_m > 0.0);
333 assert!(self.fins.root_chord_m > 0.0 && self.fins.tip_chord_m > 0.0);
334 assert!(self.fins.thickness_m > 0.0);
335 }
336}
337
338pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
342 let mut nose = None;
343 let mut tube = None;
344 let mut fins = None;
345 for item in spec.split(',') {
346 let (key, val) = item
347 .split_once('=')
348 .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
349 let parts: Vec<&str> = val.split(':').collect();
350 let number = |i: usize| -> Result<f64> {
351 parts
352 .get(i)
353 .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
354 .parse()
355 .map_err(|_| Error::invalid("geometry", "non-numeric field"))
356 };
357 match key {
358 "nose" => {
359 if parts.len() != 3 {
360 return Err(Error::invalid("nose", "expected shape:length:diameter"));
361 }
362 let shape = parts[0].parse::<NoseShape>()?;
363 nose = Some(NoseCone {
364 shape,
365 length_m: number(1)?,
366 diameter_m: number(2)?,
367 });
368 }
369 "tube" => {
370 if parts.len() != 2 {
371 return Err(Error::invalid("tube", "expected length:diameter"));
372 }
373 tube = Some(BodyTube {
374 length_m: number(0)?,
375 diameter_m: number(1)?,
376 });
377 }
378 "fins" => {
379 if !(5..=6).contains(&parts.len()) {
380 return Err(Error::invalid(
381 "fins",
382 "expected count:root:tip:span:sweep[:thickness]",
383 ));
384 }
385 let count: u32 = parts[0]
386 .parse()
387 .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
388 fins = Some(TrapezoidalFins {
389 count,
390 root_chord_m: number(1)?,
391 tip_chord_m: number(2)?,
392 span_m: number(3)?,
393 leading_sweep_m: number(4)?,
394 thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
395 });
396 }
397 other => {
398 return Err(Error::invalid(
399 "geometry key",
400 match other {
401 "nose" | "tube" | "fins" => unreachable!(),
402 _ => "must be nose, tube or fins",
403 },
404 ))
405 }
406 }
407 }
408 Ok(RocketGeometry::new(
409 nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
410 tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
411 fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
412 ))
413}
414
415#[derive(Debug, Clone)]
417pub struct AeroAnalysis {
418 pub cd_vs_mach: Vec<(f64, f64)>,
420 pub cna_vs_mach: Vec<(f64, f64)>,
422 pub cp_vs_mach: Vec<(f64, f64)>,
424 pub induced_k_vs_mach: Vec<(f64, f64)>,
426 pub ref_area_m2: f64,
428 pub total_length_m: f64,
430 pub max_diameter_m: f64,
432}
433
434#[cfg(feature = "ballistics")]
435impl AeroAnalysis {
436 pub fn airframe(
438 &self,
439 cg_dry_m: f64,
440 cg_prop_m: f64,
441 pitch_inertia_kg_m2: f64,
442 recovery_cd_area_m2: f64,
443 ) -> crate::Airframe {
444 crate::Airframe {
445 diameter_m: self.max_diameter_m,
446 cd_vs_mach: self.cd_vs_mach.clone(),
447 cna_vs_mach: self.cna_vs_mach.clone(),
448 cp_vs_mach: self.cp_vs_mach.clone(),
449 cg_dry_m,
450 cg_prop_m,
451 pitch_inertia_kg_m2,
452 induced_k_vs_mach: self.induced_k_vs_mach.clone(),
453 recovery_cd_area_m2,
454 }
455 }
456
457 pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
459 crate::ballistics::BallisticForces::new(
460 self.max_diameter_m,
461 self.cd_vs_mach.clone(),
462 crate::ballistics::Atmosphere::default(),
463 wind_mps,
464 )?
465 .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
466 }
467
468 pub fn weathercock_params(
470 &self,
471 cg_dry_m: f64,
472 cg_prop_m: f64,
473 pitch_inertia_kg_m2: f64,
474 ) -> Result<crate::WeathercockParams> {
475 crate::WeathercockParams::new(
476 self.cna_vs_mach.clone(),
477 self.cp_vs_mach.clone(),
478 cg_dry_m,
479 cg_prop_m,
480 self.max_diameter_m,
481 pitch_inertia_kg_m2,
482 0.2,
483 )
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 fn example() -> RocketGeometry {
492 RocketGeometry::new(
493 NoseCone {
494 shape: NoseShape::Conical,
495 length_m: 0.1,
496 diameter_m: 0.024,
497 },
498 BodyTube {
499 length_m: 0.4,
500 diameter_m: 0.024,
501 },
502 TrapezoidalFins {
503 count: 3,
504 root_chord_m: 0.05,
505 tip_chord_m: 0.03,
506 span_m: 0.04,
507 leading_sweep_m: 0.02,
508 thickness_m: 0.002,
509 },
510 )
511 }
512
513 #[test]
514 fn nose_cp_fractions_match_barrowman() {
515 assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
516 assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
517 assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
518 assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
519 }
520
521 #[test]
522 fn conical_pressure_drag_uses_half_angle() {
523 let cd = NoseShape::Conical.pressure_cd(0.1, 0.024);
524 let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
525 assert!((cd - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
526 assert_eq!(NoseShape::Ogive.pressure_cd(0.1, 0.024), 0.0);
527 }
528
529 #[test]
530 fn fin_cp_matches_the_trapezoid_formula() {
531 let geo = example();
532 let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
534 let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
535 let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
536 assert!((geo.fin_cp() - expected).abs() < 1e-12, "{}", geo.fin_cp());
537 }
538
539 #[test]
540 fn fin_set_cna_matches_diederich() {
541 let geo = example();
542 let area = geo.fin_area();
544 let cos = geo.midchord_cos();
545 let ratio = geo.fins.span_m * geo.fins.span_m / (area * cos);
546 let one = 2.0 * std::f64::consts::PI * geo.fins.span_m * geo.fins.span_m
547 / (1.0 + (1.0 + ratio * ratio).sqrt())
548 / geo.ref_area_m2();
549 let expected = 3.0 * one * (1.0 + geo.interference_tau());
550 let actual = geo.fin_set_cna(0.0);
551 assert!(
552 (actual - expected).abs() / expected < 1e-9,
553 "actual {actual} expected {expected}"
554 );
555 let d = geo.max_diameter_m();
557 let classic = 8.0 * 3.0 * (geo.fins.span_m / d).powi(2)
558 / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
559 * (1.0 + geo.interference_tau());
560 assert!(
561 (actual - classic).abs() / classic < 0.02,
562 "actual {actual} classic {classic}"
563 );
564 }
565
566 #[test]
567 fn total_cp_is_the_moment_sum() {
568 let geo = example();
569 let fin_cna = geo.fin_set_cna(0.0);
570 let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs()) / (2.0 + fin_cna);
571 assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
572 assert!(geo.total_cp(0.0) > geo.nose_cp());
574 }
575
576 #[test]
577 fn base_cd_matches_openrocket() {
578 assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
579 assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
580 assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
581 }
582
583 #[test]
584 fn analysis_produces_finite_curves_and_roughness_costs_drag() {
585 let geo = example();
586 let analysis = geo.analyze();
587 for &(m, cd) in &analysis.cd_vs_mach {
588 assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
589 }
590 for &(m, cp) in &analysis.cp_vs_mach {
591 assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
592 }
593 let smooth = geo.with_roughness(1e-6).analyze();
594 let rough = geo.with_roughness(500e-6).analyze();
595 assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
596 }
597}