1pub mod afterbody;
61pub mod blunt_tip;
62pub mod body;
63pub mod crossflow;
64pub mod custom;
65pub mod drag;
66pub mod error;
67pub mod fins;
68pub mod model;
69pub mod nose_drag;
70pub mod shock_expansion;
71pub mod supersonic_boattail;
72pub mod table;
73pub mod tube_fins;
74
75pub use afterbody::Boattail;
76pub use body::{BODY_LIFT_K, BodyGeometry};
77pub use crossflow::BodyLift;
78pub use custom::{DragModel, DragQuery};
79pub use drag::{
80 BaseBehindBoattail, BoattailTerm, ComponentDrag, ComponentDragTerms, Drag, DragConditions,
81 MOTOR_POD_SETS, MergedBoattail, PressureDragTerm, ReliefSource, WakeTerm,
82};
83pub use error::AeroError;
84pub use fins::{
85 FinAero, FinGeometry, FinLoading, FinOutline, FinRoll, FinRollTerms, fin_count_factor,
86 interference_factor, roll_damping_interference, roll_forcing_interference, roll_sum, side_sum,
87};
88pub use model::{
89 AeroModel, BodyAero, BodyModel, ComponentNormalForce, FinSetAero, Flow, MAX_CANT_RAD,
90 NORMAL_FORCE_MACH_LIMIT, NormalForce, PodFins, PodSetAero, Roll, SUPERSONIC_JOIN_START_MACH,
91 SUPERSONIC_JOIN_WIDTH_MACH, SupersonicBoattail, SupersonicBody, SupersonicFallback,
92 SupersonicFlare,
93};
94pub use nose_drag::{PressureDragCurve, StoneyNose};
95pub use table::{
96 DragTable, NormalForceColumn, NormalForceLookup, NormalForceTable, TableReference,
97 parse_mach_csv,
98};
99pub use tube_fins::TubeFinSetAero;
100
101#[cfg(test)]
102mod testing;
103
104#[cfg(test)]
105mod tests {
106 use std::f64::consts::PI;
107
108 use hpr_design::{
109 FinPlanform, NoseShape, Overrides, Position, ReferenceDiameter, Rocket, Stage,
110 };
111 use serde::Deserialize;
112
113 use super::*;
114 use crate::testing::{body_part, committed_design, component, fin_set, nose, one_stage};
115
116 const INCH: f64 = 0.0254;
117
118 fn close(got: f64, want: f64, rel: f64, what: &str) {
119 let err = ((got - want) / want).abs();
120 assert!(
121 err <= rel,
122 "{what}: got {got}, want {want}, rel err {err:e}"
123 );
124 }
125
126 fn component_force(model: &AeroModel, id: &str) -> NormalForce {
127 model
128 .components(&Flow::axial(0.0))
129 .unwrap()
130 .into_iter()
131 .find(|c| c.id == id)
132 .unwrap()
133 .normal_force
134 }
135
136 #[test]
141 fn barrowman_hand_values() {
142 let cone = one_stage(
143 vec![
144 component("nose", nose(NoseShape::Conical {}, 0.2, 0.025), None),
145 component("tube", body_part(0.6, 0.025, 0.025), None),
146 ],
147 ReferenceDiameter::Maximum {},
148 );
149 let model = AeroModel::new(&cone.layout().unwrap()).unwrap();
150 let total = model.normal_force(&Flow::axial(0.0)).unwrap();
151 close(total.slope_per_rad, 2.0, 1e-15, "cone slope");
152 close(
153 total.cp_station_m.unwrap(),
154 0.2 * 2.0 / 3.0,
155 1e-11,
156 "cone CP",
157 );
158 assert_eq!(component_force(&model, "tube").slope_per_rad, 0.0);
159 assert_eq!(component_force(&model, "tube").cp_station_m, None);
160
161 let shoulder = one_stage(
162 vec![
163 component("nose", nose(NoseShape::Conical {}, 0.1, 0.02), None),
164 component("shoulder", body_part(0.1, 0.02, 0.04), None),
165 component("tube", body_part(0.5, 0.04, 0.04), None),
166 component("boattail", body_part(0.1, 0.04, 0.02), None),
167 ],
168 ReferenceDiameter::Custom { diameter_m: 0.08 },
169 );
170 let model = AeroModel::new(&shoulder.layout().unwrap()).unwrap();
171 let s = component_force(&model, "shoulder");
172 close(s.slope_per_rad, 1.5, 1e-15, "shoulder slope");
173 close(s.cp_station_m.unwrap() - 0.1, 0.05556, 1e-4, "shoulder CP");
174 close(
175 s.cp_station_m.unwrap() - 0.1,
176 0.1 / 3.0 * (1.0 + 1.0 / 1.5),
177 1e-11,
178 "eq. 44",
179 );
180 let b = component_force(&model, "boattail");
181 close(b.slope_per_rad, -1.5, 1e-15, "boattail slope");
182 close(
183 b.cp_station_m.unwrap() - 0.7,
184 0.1 / 3.0 * (1.0 + 1.0 / 3.0),
185 1e-11,
186 "boattail CP",
187 );
188
189 let (c_r, s) = (0.1, 0.06);
190 let mut tube = component("tube", body_part(0.6, 0.025, 0.025), None);
191 tube.children = vec![component(
192 "fins",
193 fin_set(
194 4,
195 FinPlanform::Elliptical {
196 root_chord_m: c_r,
197 span_m: s,
198 },
199 ),
200 Some(Position::Bottom { aft_offset_m: 0.0 }),
201 )];
202 let finned = one_stage(
203 vec![
204 component("nose", nose(NoseShape::Conical {}, 0.2, 0.025), None),
205 tube,
206 ],
207 ReferenceDiameter::Maximum {},
208 );
209 let layout = finned.layout().unwrap();
210 let model = AeroModel::new(&layout).unwrap();
211 let root_le = layout.find("fins").unwrap().1.fore_station_m;
212 let fins = component_force(&model, "fins");
213 close(
214 (fins.cp_station_m.unwrap() - root_le) / c_r,
215 0.28779,
216 2e-5,
217 "elliptical fin CP",
218 );
219 close(
220 (fins.cp_station_m.unwrap() - root_le) / c_r,
221 0.5 - 2.0 / (3.0 * PI),
222 1e-12,
223 "elliptical fin CP, exact",
224 );
225 }
226
227 #[derive(Deserialize)]
228 struct Fixture {
229 tolerance_rel: f64,
230 examples: Vec<Example>,
231 }
232
233 #[derive(Deserialize)]
234 struct Example {
235 id: String,
236 reference_diameter_in: f64,
237 station_offset_in: f64,
238 nose: NoseIn,
239 body: Vec<BodyIn>,
240 fin_sets: Vec<FinsIn>,
241 printed: Vec<Printed>,
242 }
243
244 #[derive(Deserialize)]
245 struct NoseIn {
246 shape: String,
247 length_in: f64,
248 base_diameter_in: f64,
249 }
250
251 #[derive(Deserialize)]
252 struct BodyIn {
253 id: String,
254 #[serde(default)]
255 stage: Option<String>,
256 length_in: f64,
257 fore_diameter_in: f64,
258 aft_diameter_in: f64,
259 }
260
261 #[derive(Deserialize)]
262 struct FinsIn {
263 id: String,
264 on: String,
265 count: u32,
266 root_chord_in: f64,
267 tip_chord_in: f64,
268 span_in: f64,
269 sweep_in: f64,
270 root_leading_edge_station_in: f64,
271 }
272
273 #[derive(Deserialize)]
274 struct Printed {
275 what: String,
276 cn_alpha: f64,
277 cp_in: f64,
278 #[serde(default)]
279 six_fin_rule: bool,
280 }
281
282 fn example_rockets(example: &Example) -> (Rocket, Rocket) {
284 let offset = example.station_offset_in;
285 let shape = match example.nose.shape.as_str() {
286 "cone" => NoseShape::Conical {},
287 "tangent_ogive" => NoseShape::Ogive { radius_ratio: 1.0 },
288 other => panic!("unknown nose shape {other}"),
289 };
290 let mut stages = vec![Stage {
291 id: "sustainer".to_owned(),
292 name: String::new(),
293 components: vec![component(
294 "nose",
295 nose(
296 shape,
297 example.nose.length_in * INCH,
298 0.5 * example.nose.base_diameter_in * INCH,
299 ),
300 None,
301 )],
302 overrides: Overrides::default(),
303 drag_override: None,
304 parallel: None,
305 }];
306 for b in &example.body {
307 let mut part = component(
308 &b.id,
309 body_part(
310 b.length_in * INCH,
311 0.5 * b.fore_diameter_in * INCH,
312 0.5 * b.aft_diameter_in * INCH,
313 ),
314 None,
315 );
316 part.children = example
317 .fin_sets
318 .iter()
319 .filter(|f| f.on == b.id)
320 .map(|f| {
321 component(
322 &f.id,
323 fin_set(
324 f.count,
325 FinPlanform::Trapezoidal {
326 root_chord_m: f.root_chord_in * INCH,
327 tip_chord_m: f.tip_chord_in * INCH,
328 span_m: f.span_in * INCH,
329 sweep_m: f.sweep_in * INCH,
330 },
331 ),
332 Some(Position::Absolute {
333 station_m: (f.root_leading_edge_station_in + offset) * INCH,
334 }),
335 )
336 })
337 .collect();
338 match &b.stage {
339 Some(stage) if stages.last().is_some_and(|s| &s.id != stage) => {
340 stages.push(Stage {
341 id: stage.clone(),
342 name: String::new(),
343 components: vec![part],
344 overrides: Overrides::default(),
345 drag_override: None,
346 parallel: None,
347 });
348 }
349 _ => stages.last_mut().unwrap().components.push(part),
350 }
351 }
352 let reference = ReferenceDiameter::Custom {
353 diameter_m: example.reference_diameter_in * INCH,
354 };
355 let whole = Rocket {
356 name: example.id.clone(),
357 stages,
358 reference_diameter: reference,
359 configurations: Vec::new(),
360 };
361 let first = Rocket {
362 stages: whole.stages[..1].to_vec(),
363 ..whole.clone()
364 };
365 (whole, first)
366 }
367
368 fn six_fin_rule_ratio(set: &FinSetAero) -> f64 {
371 let r_over = (set.interference - 1.0).max(0.0);
372 (1.0 + 0.5 * r_over) / (set.count_factor * set.interference)
373 }
374
375 #[test]
384 fn barrowman_worked_examples() {
385 let fixture: Fixture = serde_json::from_str(include_str!(
386 "../../../validation/fixtures/aero/barrowman-worked-examples.json"
387 ))
388 .unwrap();
389 let tolerance = fixture.tolerance_rel;
390 assert_eq!(tolerance, 0.01);
391 let flow = Flow::axial(0.0);
392 let mut worst: f64 = 0.0;
393 let mut worst_own = (0.0, String::new());
394 let mut outside_own = Vec::new();
395 assert_eq!(fixture.examples.len(), 5);
396 let printed_values: usize = fixture.examples.iter().map(|e| e.printed.len()).sum();
397 assert_eq!(printed_values, 19);
398 for example in &fixture.examples {
399 let (whole, first) = example_rockets(example);
400 let model = AeroModel::new(&whole.layout().unwrap()).unwrap();
401 let components = model.components(&flow).unwrap();
402 let six_fin: Option<&FinSetAero> = model.fin_sets().iter().find(|s| s.count == 6);
403 for printed in &example.printed {
404 let force = match printed.what.as_str() {
405 "total" => model.normal_force(&flow).unwrap(),
406 "sustainer-alone" => AeroModel::new(&first.layout().unwrap())
407 .unwrap()
408 .normal_force(&flow)
409 .unwrap(),
410 id => {
411 components
412 .iter()
413 .find(|c| c.id == id)
414 .unwrap_or_else(|| panic!("{}: no component {id}", example.id))
415 .normal_force
416 }
417 };
418 let mut slope = force.slope_per_rad;
419 let mut cp_m = force.cp_station_m.unwrap();
420 let own_cp_err = (cp_m / INCH - example.station_offset_in) / printed.cp_in - 1.0;
421 let own = (slope / printed.cn_alpha - 1.0).abs().max(own_cp_err.abs());
422 if own > worst_own.0 {
423 worst_own = (own, format!("{} {}", example.id, printed.what));
424 }
425 if own > tolerance {
426 outside_own.push(format!("{} {}", example.id, printed.what));
427 }
428 if printed.six_fin_rule {
429 let set = six_fin.expect("a six-fin set");
430 let set_slope = set
431 .fin
432 .geometry()
433 .single_fin_slope(model.reference_area_m2(), 0.0)
434 .unwrap()
435 * roll_sum(set.count, set.base_angle_rad, 0.0)
436 * set.count_factor
437 * set.interference;
438 let extra = set_slope * (six_fin_rule_ratio(set) - 1.0);
439 let tir33_slope = slope + extra;
440 let rule_gap = slope / tir33_slope - 1.0;
443 let printed_gap = slope / printed.cn_alpha - 1.0;
444 assert!(
445 rule_gap > 0.02 && (printed_gap - rule_gap).abs() < tolerance,
446 "{} {}: rule gap {rule_gap:e}, gap to print {printed_gap:e}",
447 example.id,
448 printed.what
449 );
450 eprintln!(
451 "{} {}: hpr C_Nα {slope:.4} is {:+.2}% from the print; \
452 TIR-33's six-fin rule alone accounts for {:+.2}%",
453 example.id,
454 printed.what,
455 100.0 * printed_gap,
456 100.0 * rule_gap
457 );
458 eprintln!(
459 "{} {}: with hpr's rule, CP {:.4} in",
460 example.id,
461 printed.what,
462 cp_m / INCH - example.station_offset_in
463 );
464 cp_m = (cp_m * slope + extra * set.cp_station_m(0.0).unwrap()) / tir33_slope;
465 slope = tir33_slope;
466 }
467 let cp_in = cp_m / INCH - example.station_offset_in;
468 let slope_err = slope / printed.cn_alpha - 1.0;
469 let cp_err = cp_in / printed.cp_in - 1.0;
470 eprintln!(
471 "{} {}: C_Nα {slope:.4} vs {} ({:+.3}%), CP {cp_in:.4} in vs {} ({:+.3}%)",
472 example.id,
473 printed.what,
474 printed.cn_alpha,
475 100.0 * slope_err,
476 printed.cp_in,
477 100.0 * cp_err
478 );
479 assert!(
480 slope_err.abs() <= tolerance && cp_err.abs() <= tolerance,
481 "{} {}: C_Nα error {slope_err:e}, CP error {cp_err:e}",
482 example.id,
483 printed.what
484 );
485 worst = worst.max(slope_err.abs()).max(cp_err.abs());
486 }
487 }
488 eprintln!(
489 "worst relative error, hpr's own model: {:.2}% ({}); with TIR-33's six-fin rule for \
490 the Recruiter: {:.3}%",
491 100.0 * worst_own.0,
492 worst_own.1,
493 100.0 * worst
494 );
495 assert_eq!(outside_own, ["recruiter fins", "recruiter total"]);
497 }
498
499 #[derive(Deserialize)]
500 struct DragCurves {
501 mach: f64,
502 reynolds_per_m: f64,
503 tolerance_rel: f64,
504 cases: Vec<DragCurveCase>,
505 }
506
507 #[derive(Deserialize)]
508 struct DragCurveCase {
509 id: String,
510 design: String,
511 variant_of: Option<String>,
512 thrusting: bool,
513 curve_cd0: f64,
514 hpr_cd0: f64,
515 relative_error: f64,
516 }
517
518 #[test]
526 fn rocketpy_drag_curves_at_mach_0_3() {
527 let fixture: DragCurves = serde_json::from_str(include_str!(
528 "../../../validation/fixtures/aero/rocketpy-drag-curves.json"
529 ))
530 .unwrap();
531 assert_eq!(fixture.mach, 0.3);
532 assert_eq!(fixture.tolerance_rel, 0.10);
533 let air = hpr_atmos::Ussa76::standard().sample(0.0).unwrap().air;
534 close(
535 fixture.reynolds_per_m,
536 0.3 * air.speed_of_sound_m_s / air.kinematic_viscosity_m2_s(),
537 1e-12,
538 "sea-level Reynolds number per meter",
539 );
540 let mut outside = Vec::new();
541 for case in &fixture.cases {
542 let rocket = committed_design(&case.design);
543 let model = AeroModel::new(&rocket.layout().unwrap()).unwrap();
544 assert!(model.pod_sets().is_empty(), "{}", case.design);
546 let motor_area: f64 = if case.thrusting {
547 rocket.configurations[0]
548 .motors
549 .iter()
550 .map(|m| 0.25 * PI * m.diameter_m * m.diameter_m)
551 .sum()
552 } else {
553 0.0
554 };
555 let conditions = if case.thrusting {
556 DragConditions::thrusting(fixture.reynolds_per_m, motor_area)
557 } else {
558 DragConditions::coasting(fixture.reynolds_per_m)
559 };
560 let drag = model.drag(&Flow::axial(fixture.mach), &conditions).unwrap();
561 close(drag.zero_lift_coefficient, case.hpr_cd0, 1e-12, &case.id);
563 let error = drag.zero_lift_coefficient / case.curve_cd0 - 1.0;
564 assert!(
565 (error - case.relative_error).abs() < 1e-12,
566 "{}: recorded error {}, recomputed {error}",
567 case.id,
568 case.relative_error
569 );
570 eprintln!(
571 "{}: C_D0 {:.4} ({:.4} friction, {:.4} pressure, {:.4} base, {:.4} parasitic), \
572 {:+.1}% from the curve",
573 case.id,
574 drag.zero_lift_coefficient,
575 drag.friction,
576 drag.pressure,
577 drag.base,
578 drag.parasitic,
579 100.0 * case.relative_error
580 );
581 if error.abs() > fixture.tolerance_rel {
582 outside.push(case.id.as_str());
583 }
584 }
585 assert_eq!(fixture.cases.len(), 7);
588 let variants: Vec<&str> = fixture
589 .cases
590 .iter()
591 .filter(|c| c.variant_of.is_some())
592 .map(|c| c.id.as_str())
593 .collect();
594 assert_eq!(variants, ["calisto-getting-started-power-off"]);
595 assert_eq!(
600 outside,
601 ["cavour-power-on", "valetudo-power-off", "valetudo-power-on"]
602 );
603 }
604
605 #[derive(Deserialize)]
606 struct NormalForceVsMach {
607 targets: Targets,
608 references: Vec<NormalForceReference>,
609 }
610
611 #[derive(Deserialize)]
612 struct Targets {
613 cp_calibers: f64,
614 cn_alpha_rel: f64,
615 }
616
617 #[derive(Deserialize)]
618 struct NormalForceReference {
619 id: String,
620 design: String,
621 reference_diameter_m: f64,
622 rows: Vec<NormalForceRow>,
623 }
624
625 #[derive(Deserialize)]
626 struct NormalForceRow {
627 mach: f64,
628 reference_cn_alpha_per_rad: f64,
629 reference_cp_m: f64,
630 hpr_cn_alpha_per_rad: f64,
631 hpr_cp_m: f64,
632 cn_alpha_error: f64,
633 cp_error_calibers: f64,
634 within_targets: bool,
635 reference_body_cn_alpha_per_rad: Option<f64>,
636 hpr_body_cn_alpha_per_rad: Option<f64>,
637 }
638
639 #[derive(Deserialize)]
640 struct WindTunnel {
641 configurations: Vec<WindTunnelConfiguration>,
642 }
643
644 #[derive(Deserialize)]
645 struct WindTunnelConfiguration {
646 id: String,
647 length_m: f64,
648 cn_alpha: Vec<WindTunnelCurve>,
649 cn_alpha_fins_off: Vec<WindTunnelCurve>,
650 cp: Vec<WindTunnelCp>,
651 }
652
653 #[derive(Deserialize)]
654 struct WindTunnelCurve {
655 mach: f64,
656 alpha_deg_c_n: Vec<[f64; 2]>,
657 }
658
659 #[derive(Deserialize)]
660 struct WindTunnelCp {
661 mach: f64,
662 percent_length: f64,
663 }
664
665 fn lsq_slope(xs: &[f64], ys: &[f64]) -> f64 {
667 let n = xs.len() as f64;
668 let (mx, my) = (xs.iter().sum::<f64>() / n, ys.iter().sum::<f64>() / n);
669 let sxy: f64 = xs.iter().zip(ys).map(|(x, y)| (x - mx) * (y - my)).sum();
670 let sxx: f64 = xs.iter().map(|x| (x - mx) * (x - mx)).sum();
671 sxy / sxx
672 }
673
674 fn force_at(model: &AeroModel, mach: f64, alpha_deg: f64, bodies_only: bool) -> (f64, f64) {
677 let parts = model
678 .components(&Flow::new(mach, alpha_deg.abs().to_radians(), 0.0))
679 .unwrap();
680 let kept = if bodies_only {
681 &parts[..model.bodies().len()]
682 } else {
683 &parts[..]
684 };
685 let sign = alpha_deg.signum();
686 (
687 sign * kept.iter().map(|p| p.normal_force.coefficient).sum::<f64>(),
688 sign * kept.iter().map(|p| p.normal_force.moment_m).sum::<f64>(),
689 )
690 }
691
692 #[test]
701 fn normal_force_against_mach() {
702 let fixture: NormalForceVsMach = serde_json::from_str(include_str!(
703 "../../../validation/fixtures/aero/normal-force-vs-mach.json"
704 ))
705 .unwrap();
706 let tunnel: WindTunnel = serde_json::from_str(include_str!(
707 "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
708 ))
709 .unwrap();
710 assert_eq!(fixture.targets.cp_calibers, 0.5);
711 assert_eq!(fixture.targets.cn_alpha_rel, 0.15);
712 let cp_angles: Vec<f64> = [-2.0_f64, -1.0, 0.0, 1.0, 2.0]
713 .iter()
714 .map(|a| a.to_radians())
715 .collect();
716 let mut misses = Vec::new();
717 for reference in &fixture.references {
718 let model =
719 AeroModel::new(&committed_design(&reference.design).layout().unwrap()).unwrap();
720 let d = (4.0 * model.reference_area_m2() / PI).sqrt();
721 close(reference.reference_diameter_m, d, 1e-15, &reference.id);
722 let configuration = tunnel.configurations.iter().find(|c| c.id == reference.id);
723 for row in &reference.rows {
724 let what = format!("{} at Mach {}", reference.id, row.mach);
725 let (cn_alpha, cp_m, hpr_cn_alpha, hpr_cp_m) = match configuration {
726 None => {
728 let force = model.normal_force(&Flow::axial(row.mach)).unwrap();
729 (
730 row.reference_cn_alpha_per_rad,
731 row.reference_cp_m,
732 force.slope_per_rad,
733 force.cp_station_m.unwrap(),
734 )
735 }
736 Some(configuration) => {
739 let fit = |curves: &[WindTunnelCurve], bodies_only: bool| {
740 let curve = curves.iter().find(|c| c.mach == row.mach).unwrap();
741 let alphas: Vec<f64> = curve
742 .alpha_deg_c_n
743 .iter()
744 .map(|p| p[0].to_radians())
745 .collect();
746 let measured: Vec<f64> =
747 curve.alpha_deg_c_n.iter().map(|p| p[1]).collect();
748 let hpr: Vec<f64> = curve
749 .alpha_deg_c_n
750 .iter()
751 .map(|p| force_at(&model, row.mach, p[0], bodies_only).0)
752 .collect();
753 (lsq_slope(&alphas, &measured), lsq_slope(&alphas, &hpr))
754 };
755 let (measured, hpr) = fit(&configuration.cn_alpha, false);
756 if let Some(curve_body) = row.reference_body_cn_alpha_per_rad {
757 let (body, hpr_body) = fit(&configuration.cn_alpha_fins_off, true);
758 close(curve_body, body, 1e-12, &format!("{what}: body"));
759 close(
760 row.hpr_body_cn_alpha_per_rad.unwrap(),
761 hpr_body,
762 1e-12,
763 &format!("{what}: hpr's body"),
764 );
765 }
766 let cp = configuration
767 .cp
768 .iter()
769 .find(|c| c.mach == row.mach)
770 .unwrap();
771 let forces: Vec<(f64, f64)> = [-2.0, -1.0, 0.0, 1.0, 2.0]
772 .iter()
773 .map(|&a| force_at(&model, row.mach, a, false))
774 .collect();
775 let normal: Vec<f64> = forces.iter().map(|f| f.0).collect();
776 let moment: Vec<f64> = forces.iter().map(|f| f.1).collect();
777 (
778 measured,
779 0.01 * cp.percent_length * configuration.length_m,
780 hpr,
781 lsq_slope(&cp_angles, &moment) / lsq_slope(&cp_angles, &normal),
782 )
783 }
784 };
785 close(row.reference_cn_alpha_per_rad, cn_alpha, 1e-12, &what);
787 close(row.reference_cp_m, cp_m, 1e-12, &what);
788 close(row.hpr_cn_alpha_per_rad, hpr_cn_alpha, 1e-12, &what);
789 close(row.hpr_cp_m, hpr_cp_m, 1e-12, &what);
790 let cn_error = hpr_cn_alpha / cn_alpha - 1.0;
791 let cp_error = (hpr_cp_m - cp_m) / d;
792 assert!((row.cn_alpha_error - cn_error).abs() < 1e-12, "{what}");
793 assert!((row.cp_error_calibers - cp_error).abs() < 1e-12, "{what}");
794 let within = cn_error.abs() <= 0.15 && cp_error.abs() <= 0.5;
795 assert_eq!(row.within_targets, within, "{what}");
796 eprintln!(
797 "{what}: C_Na {hpr_cn_alpha:.3} against {cn_alpha:.3} ({:+.1}%), CP {:+.2} \
798 calibers",
799 100.0 * cn_error,
800 cp_error
801 );
802 if !within {
803 misses.push(format!("{}@{}", reference.id, row.mach));
804 }
805 }
806 }
807 assert_eq!(fixture.references.len(), 3);
808 assert_eq!(
809 misses,
810 [
811 "calisto-rasaero-ii@0.8",
818 "calisto-rasaero-ii@0.9",
819 "calisto-rasaero-ii@0.95",
820 "calisto-rasaero-ii@1.3",
821 "arcas-robin-short@0.8",
830 "arcas-robin-short@0.9",
831 "arcas-robin-short@0.95",
832 "arcas-robin-short@1.2",
833 "arcas-robin-long@0.9",
834 "arcas-robin-long@1",
835 "arcas-robin-long@1.2",
836 "arcas-robin-long@1.8",
837 "arcas-robin-long@2.3",
838 ]
839 );
840 }
841
842 #[derive(Deserialize)]
843 struct DragVsMach {
844 target_rel: f64,
845 reynolds_per_m: f64,
846 references: Vec<DragReference>,
847 }
848
849 #[derive(Deserialize)]
850 struct DragReference {
851 id: String,
852 design: String,
853 rows: Vec<DragRow>,
854 }
855
856 #[derive(Deserialize)]
857 struct DragRow {
858 mach: f64,
859 fins: String,
860 reference_forebody_c_a: f64,
861 hpr_forebody_c_d: f64,
862 hpr_base: f64,
863 error: f64,
864 within_target: bool,
865 }
866
867 #[derive(Deserialize)]
868 struct AxialTunnel {
869 configurations: Vec<AxialConfiguration>,
870 }
871
872 #[derive(Deserialize)]
873 struct AxialConfiguration {
874 id: String,
875 design: String,
876 axial_force: Vec<AxialPoint>,
877 }
878
879 #[derive(Deserialize)]
880 struct AxialPoint {
881 mach: f64,
882 fins: String,
883 forebody_c_a: f64,
884 c_a: Option<f64>,
885 chamber_c_a: Option<f64>,
886 forebody_c_a_chamber_only: Option<f64>,
887 }
888
889 #[test]
898 fn drag_against_mach() {
899 let fixture: DragVsMach = serde_json::from_str(include_str!(
900 "../../../validation/fixtures/aero/drag-vs-mach.json"
901 ))
902 .unwrap();
903 let tunnel: AxialTunnel = serde_json::from_str(include_str!(
904 "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
905 ))
906 .unwrap();
907 assert_eq!(fixture.target_rel, 0.10);
908 assert_eq!(fixture.reynolds_per_m, 3.0e6 / 0.3048);
909 let conditions = DragConditions::coasting(fixture.reynolds_per_m);
910 let (mut within, mut rows) = (Vec::new(), 0);
911 assert_eq!(fixture.references.len(), tunnel.configurations.len());
912 for (reference, configuration) in fixture.references.iter().zip(&tunnel.configurations) {
913 assert_eq!(reference.id, configuration.id);
914 assert_eq!(reference.design, configuration.design);
915 assert_eq!(
916 reference.rows.len(),
917 configuration.axial_force.len(),
918 "{}: every measured point is compared",
919 reference.id
920 );
921 let model =
922 AeroModel::new(&committed_design(&reference.design).layout().unwrap()).unwrap();
923 let fin_ids: Vec<&str> = model.fin_sets().iter().map(|f| f.id.as_str()).collect();
924 for (row, point) in reference.rows.iter().zip(&configuration.axial_force) {
925 let what = format!("{}@{} fins {}", reference.id, row.mach, row.fins);
926 assert_eq!((row.mach, &row.fins), (point.mach, &point.fins), "{what}");
927 assert_eq!(row.reference_forebody_c_a, point.forebody_c_a, "{what}");
928 if let (Some(c_a), Some(chamber)) = (point.c_a, point.chamber_c_a) {
932 let factor = (1.470f64 / 1.250).powi(2);
933 assert!(
934 (point.forebody_c_a - (c_a - factor * chamber)).abs() < 1.5e-4,
935 "{what}"
936 );
937 let bound = point.forebody_c_a_chamber_only.unwrap_or(f64::NAN);
938 assert!((bound - (c_a - chamber)).abs() < 1.5e-4, "{what}");
939 }
940 let parts = model
941 .buildup_components(&Flow::axial(row.mach), &conditions)
942 .unwrap();
943 let kept = parts
944 .iter()
945 .filter(|p| row.fins == "on" || !fin_ids.contains(&p.id.as_str()));
946 let (mut forebody, mut base) = (0.0, 0.0);
947 for part in kept {
948 forebody += part.drag.friction + part.drag.pressure + part.drag.parasitic;
949 base += part.drag.base;
950 }
951 close(row.hpr_forebody_c_d, forebody, 1e-12, &what);
952 close(row.hpr_base, base, 1e-12, &what);
953 let error = forebody / point.forebody_c_a - 1.0;
954 assert!((row.error - error).abs() < 1e-12, "{what}");
955 assert_eq!(
956 row.within_target,
957 error.abs() <= fixture.target_rel,
958 "{what}"
959 );
960 rows += 1;
961 if row.within_target {
962 within.push(what);
963 }
964 }
965 }
966 assert_eq!(rows, 44);
967 assert_eq!(
971 within,
972 ["arcas-robin-short@1 fins on", "arcas-robin-long@1 fins on",]
973 );
974 }
975
976 #[test]
987 fn calistos_rows_by_fin_and_finish() {
988 use hpr_design::{Component, FinCrossSection, Finish, Part};
989
990 let fixture: serde_json::Value = serde_json::from_str(include_str!(
991 "../../../validation/fixtures/aero/rocketpy-drag-curves.json"
992 ))
993 .unwrap();
994 let case = fixture["cases"]
995 .as_array()
996 .unwrap()
997 .iter()
998 .find(|c| c["id"] == "calisto-power-off")
999 .unwrap();
1000 let rows: Vec<(f64, f64)> = case["sweep"]["rows"]
1001 .as_array()
1002 .unwrap()
1003 .iter()
1004 .map(|r| {
1005 let hpr = r["hpr_cd0"].as_f64().unwrap();
1006 let error = r["relative_error"].as_f64().unwrap();
1007 (r["mach"].as_f64().unwrap(), hpr / (1.0 + error))
1008 })
1009 .collect();
1010 assert_eq!(rows.len(), 39);
1011 let air = hpr_atmos::Ussa76::standard().sample(0.0).unwrap().air;
1012 fn set(components: &mut [Component], section: FinCrossSection, t: f64, finish: Finish) {
1013 for c in components {
1014 c.finish = Some(finish);
1015 if let Part::FinSet(fins) = &mut c.part {
1016 fins.cross_section = section;
1017 fins.thickness_m = t;
1018 }
1019 set(&mut c.children, section, t, finish);
1020 }
1021 }
1022 let (mut counts, mut ranges) = (Vec::new(), Vec::new());
1023 for section in [
1024 FinCrossSection::Square,
1025 FinCrossSection::Rounded,
1026 FinCrossSection::Airfoil,
1027 ] {
1028 for t in [0.002, 0.003, 0.00476, 0.00635] {
1029 for finish in [Finish::Mirror {}, Finish::MassProductionPaint {}] {
1030 let mut rocket =
1031 committed_design("rocketpy-calisto-tests-motor-at-minus-1.373.json");
1032 set(&mut rocket.stages[0].components, section, t, finish);
1033 let model = AeroModel::new(&rocket.layout().unwrap()).unwrap();
1034 let mut within = [0usize; 3];
1036 let mut errors: [Vec<f64>; 3] = Default::default();
1037 for &(mach, curve) in &rows {
1038 let conditions = DragConditions::coasting(
1039 mach * air.speed_of_sound_m_s / air.kinematic_viscosity_m2_s(),
1040 );
1041 let drag = model.drag(&Flow::axial(mach), &conditions).unwrap();
1042 let error = drag.zero_lift_coefficient / curve - 1.0;
1043 let band = if mach <= 0.8 {
1044 0
1045 } else if mach < 1.2 {
1046 1
1047 } else {
1048 2
1049 };
1050 if error.abs() <= 0.10 {
1051 within[band] += 1;
1052 }
1053 errors[band].push(error);
1054 }
1055 counts.push(within);
1056 let pct = |e: f64| (e * 1000.0).round() / 10.0;
1058 ranges.push(errors.map(|band| {
1059 let min = band.iter().copied().fold(f64::INFINITY, f64::min);
1060 let max = band.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1061 (pct(min), pct(max))
1062 }));
1063 }
1064 }
1065 }
1066 assert_eq!(
1069 counts,
1070 [
1071 [15, 3, 1],
1072 [7, 4, 8],
1073 [15, 3, 8],
1074 [3, 4, 15],
1075 [0, 3, 17],
1076 [0, 3, 17],
1077 [0, 3, 17],
1078 [0, 1, 2],
1079 [9, 4, 0],
1080 [14, 3, 4],
1081 [15, 3, 1],
1082 [14, 4, 9],
1083 [15, 4, 14],
1084 [12, 3, 17],
1085 [14, 4, 17],
1086 [9, 3, 17],
1087 [3, 4, 0],
1088 [14, 3, 2],
1089 [7, 3, 0],
1090 [13, 4, 7],
1091 [10, 4, 9],
1092 [13, 4, 17],
1093 [11, 4, 17],
1094 [13, 3, 17],
1095 ]
1096 );
1097 assert_eq!(ranges[2], [(3.9, 8.9), (-10.1, 16.4), (-14.9, -5.1)]);
1100 assert_eq!(ranges[12], [(-8.4, 5.8), (-8.9, 22.5), (-11.7, -3.4)]);
1101 assert!(counts.iter().all(|c| c != &[15, 7, 17]));
1102 }
1103
1104 #[test]
1112 fn boattails_against_measurements() {
1113 use crate::afterbody::Boattail;
1114 let references: serde_json::Value = serde_json::from_str(include_str!(
1115 "../../../validation/fixtures/aero/measured-boattails.json"
1116 ))
1117 .unwrap();
1118 let fixture: serde_json::Value = serde_json::from_str(include_str!(
1119 "../../../validation/fixtures/aero/drag-vs-mach.json"
1120 ))
1121 .unwrap();
1122 let f = |row: &serde_json::Value, key: &str| row[key].as_f64().unwrap();
1123 let same = |got: f64, want: f64, what: &str| {
1125 assert!(
1126 (got - want).abs() <= 1e-12 * want.abs().max(1e-3),
1127 "{what}: {got} against {want}"
1128 );
1129 };
1130 let boattail = |row: &serde_json::Value| {
1131 let (l, r) = (f(row, "length_ratio"), f(row, "diameter_ratio").min(1.0));
1132 Boattail::new(l, 1.0, r).unwrap()
1133 };
1134 let rows = |section: &str| {
1135 let reference = references[section]["rows"].as_array().unwrap();
1136 let compared = fixture[section]["rows"].as_array().unwrap();
1137 assert_eq!(reference.len(), compared.len(), "{section}");
1138 compared.clone()
1139 };
1140 let range = |errors: &[f64]| {
1142 let pct = |e: f64| (e * 1000.0).round() / 10.0;
1143 let min = errors.iter().copied().fold(f64::INFINITY, f64::min);
1144 let max = errors.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1145 (errors.len(), pct(min), pct(max))
1146 };
1147
1148 let mut groups: std::collections::BTreeMap<&str, Vec<f64>> = Default::default();
1154 let mut absolute: Vec<f64> = Vec::new();
1155 for row in rows("boattails") {
1156 let b = boattail(&row);
1157 let mach = f(&row, "mach");
1158 let hpr = b.pressure_drag_coefficient(mach).unwrap();
1159 same(f(&row, "hpr"), hpr, "boattail");
1160 let error = hpr / f(&row, "cd") - 1.0;
1161 assert!((f(&row, "error") - error).abs() < 1e-12);
1162 let deg = b.half_angle_rad.to_degrees();
1163 let group = if row["flow"] == "separated" {
1164 "separated"
1165 } else if row["questionable"] == true {
1166 "questionable"
1167 } else if deg > 12.0 {
1168 if mach >= 1.0 {
1169 "steep"
1170 } else {
1171 "steep, subsonic"
1172 }
1173 } else if mach >= 1.2 {
1174 absolute.push(hpr - f(&row, "cd"));
1175 "supersonic"
1176 } else if mach >= 1.0 {
1177 "held"
1178 } else if mach > 0.8 {
1179 "rise"
1180 } else {
1181 "rule"
1182 };
1183 groups.entry(group).or_default().push(error);
1184 }
1185 let got: Vec<(&str, (usize, f64, f64))> =
1186 groups.iter().map(|(k, v)| (*k, range(v))).collect();
1187 assert_eq!(
1188 got,
1189 [
1190 ("held", (4, -18.2, -5.4)),
1191 ("questionable", (27, -46.2, 60.0)),
1192 ("rise", (28, -77.5, 7.6)),
1193 ("rule", (58, -100.0, -83.5)),
1194 ("separated", (3, -2.8, 6.6)),
1195 ("steep", (9, 26.4, 54.2)),
1196 ("steep, subsonic", (6, -30.2, 60.4)),
1197 ("supersonic", (58, -21.9, 28.3)),
1198 ]
1199 );
1200 assert!(absolute.iter().all(|e| e.abs() < 0.0123));
1203
1204 let mut differences = Vec::new();
1206 for row in rows("base_pressures") {
1207 let b = boattail(&row);
1208 let k = b
1209 .base_pressure_ratio(f(&row, "mach"), b.area_ratio)
1210 .unwrap();
1211 same(f(&row, "k_hpr"), k, "base ratio");
1212 let measured = f(&row, "boattail_cp") / f(&row, "cylinder_cp");
1213 let difference = (k - measured) * -f(&row, "cylinder_cp") * b.area_ratio;
1214 assert!((f(&row, "base_cd_difference") - difference).abs() < 1e-12);
1215 differences.push(difference);
1216 }
1217 assert_eq!(differences.len(), 12);
1218 assert!(differences.iter().all(|d| d.abs() < 0.0102));
1219 assert_eq!(differences.iter().filter(|d| d.abs() <= 0.004).count(), 10);
1220
1221 let (mut inside, mut past, mut shallow) = (Vec::new(), Vec::new(), Vec::new());
1223 for row in rows("second_order_theory") {
1224 let a = f(&row, "area_ratio");
1225 let theta = f(&row, "half_angle_deg").to_radians();
1226 let r = a.sqrt();
1227 let b = Boattail::new((1.0 - r) / (2.0 * theta.tan()), 1.0, r).unwrap();
1228 let mach = f(&row, "mach");
1229 let hpr = b.attached_pressure_drag(mach).unwrap();
1230 same(f(&row, "hpr"), hpr, "theory");
1231 let error = hpr / f(&row, "cd") - 1.0;
1232 assert!((f(&row, "error") - error).abs() < 1e-12);
1233 if a > 0.6 + 1e-9 {
1234 shallow.push(error);
1235 } else if f(&row, "x") <= 1.4 {
1236 inside.push(error);
1237 } else {
1238 past.push(error);
1239 }
1240 }
1241 assert_eq!(range(&inside), (83, -10.4, 8.0));
1242 assert_eq!(range(&past), (28, -2.7, 8.0));
1243 assert_eq!(range(&shallow), (40, -1.5, 32.4));
1245 }
1246
1247 #[derive(Deserialize)]
1248 struct HandbookDrag {
1249 calculations: Vec<HandbookCalculation>,
1250 }
1251
1252 #[derive(Deserialize)]
1253 struct HandbookCalculation {
1254 design: String,
1255 rows: Vec<HandbookComparison>,
1256 }
1257
1258 #[derive(Deserialize)]
1259 struct HandbookComparison {
1260 mach: f64,
1261 reynolds_per_m: f64,
1262 reference: HandbookParts,
1263 hpr: HandbookParts,
1264 compared: HandbookCompared,
1265 error: f64,
1266 within_target: bool,
1267 }
1268
1269 #[derive(Deserialize)]
1270 struct HandbookCompared {
1271 reference: f64,
1272 hpr: f64,
1273 }
1274
1275 #[derive(Deserialize)]
1276 struct HandbookParts {
1277 friction: f64,
1278 nose: f64,
1279 fins: f64,
1280 base: f64,
1281 #[serde(default)]
1282 other: f64,
1283 total: f64,
1284 }
1285
1286 #[derive(Deserialize)]
1287 struct HandbookReference {
1288 rows: Vec<HandbookRow>,
1289 }
1290
1291 #[derive(Deserialize)]
1292 struct HandbookRow {
1293 mach: f64,
1294 reynolds_per_m: f64,
1295 body_friction: f64,
1296 fin_friction: f64,
1297 friction: f64,
1298 nose_wave: Option<f64>,
1299 fin_wave: Option<f64>,
1300 fin_base: f64,
1301 fins: f64,
1302 base: f64,
1303 total_jet_off: f64,
1304 }
1305
1306 #[test]
1316 fn drag_against_mil_hdbk_762_sample() {
1317 let reference: HandbookReference = serde_json::from_str(include_str!(
1318 "../../../validation/fixtures/aero/mil-hdbk-762-sample-drag.json"
1319 ))
1320 .unwrap();
1321 for row in &reference.rows {
1323 let what = format!("Table 5-4 at Mach {}", row.mach);
1324 assert!(
1325 (row.body_friction + row.fin_friction - row.friction).abs() < 1.5e-3,
1326 "{what}"
1327 );
1328 assert!(
1329 (row.fin_wave.unwrap_or(0.0) + row.fin_base - row.fins).abs() < 1.5e-3,
1330 "{what}"
1331 );
1332 let sum = row.friction + row.nose_wave.unwrap_or(0.0) + row.fins + row.base;
1333 assert!((sum - row.total_jet_off).abs() < 1.5e-3, "{what}");
1334 assert_eq!(row.nose_wave.is_none(), row.mach < 0.9, "{what}");
1335 assert_eq!(row.fin_wave.is_none(), row.mach < 0.95, "{what}");
1336 }
1337 let fixture: HandbookDrag = serde_json::from_str(include_str!(
1338 "../../../validation/fixtures/aero/drag-vs-mach.json"
1339 ))
1340 .unwrap();
1341 let [calculation] = fixture.calculations.as_slice() else {
1342 panic!("one calculation")
1343 };
1344 let model =
1345 AeroModel::new(&committed_design(&calculation.design).layout().unwrap()).unwrap();
1346 assert_eq!(calculation.rows.len(), reference.rows.len());
1347 let mut within = Vec::new();
1348 for (row, printed) in calculation.rows.iter().zip(&reference.rows) {
1349 let what = format!("Mach {}", row.mach);
1350 assert_eq!(
1351 (row.mach, row.reynolds_per_m),
1352 (printed.mach, printed.reynolds_per_m)
1353 );
1354 let r = &row.reference;
1355 assert_eq!(
1356 (r.friction, r.nose, r.fins, r.base, r.total),
1357 (
1358 printed.friction,
1359 printed.nose_wave.unwrap_or(0.0),
1360 printed.fins,
1361 printed.base,
1362 printed.total_jet_off
1363 ),
1364 "{what}"
1365 );
1366 let conditions = DragConditions::coasting(printed.reynolds_per_m);
1367 let flow = Flow::axial(row.mach);
1368 let drag = model.drag(&flow, &conditions).unwrap();
1369 let parts = model.buildup_components(&flow, &conditions).unwrap();
1370 let pressure = |id: &str| {
1371 parts
1372 .iter()
1373 .filter(|p| p.id == id)
1374 .map(|p| p.drag.pressure)
1375 .sum::<f64>()
1376 };
1377 let h = &row.hpr;
1378 close(h.total, drag.zero_lift_coefficient, 1e-12, &what);
1381 close(h.friction, drag.friction, 1e-12, &what);
1382 close(h.base, drag.base, 1e-12, &what);
1383 close(h.nose, pressure("nose"), 1e-12, &what);
1384 close(h.fins, pressure("fins"), 1e-12, &what);
1385 assert_eq!(
1386 h.other, 0.0,
1387 "{what}: only the nose and fins have pressure drag"
1388 );
1389 assert!(
1390 (h.friction + h.nose + h.fins + h.base - h.total).abs() < 1e-12,
1391 "{what}"
1392 );
1393 let c = &row.compared;
1394 close(c.hpr, h.total - h.fins, 1e-12, &what);
1395 close(
1396 c.reference,
1397 printed.total_jet_off - printed.fins,
1398 1e-12,
1399 &what,
1400 );
1401 let error = c.hpr / c.reference - 1.0;
1402 assert!((row.error - error).abs() < 1e-12, "{what}");
1403 assert_eq!(row.within_target, error.abs() <= 0.10, "{what}");
1404 if row.within_target {
1405 within.push(row.mach);
1406 }
1407 }
1408 assert_eq!(within, [0.7, 1.6, 2.0, 2.4, 2.8, 3.2]);
1414 }
1415
1416 #[test]
1424 fn drag_against_mach_depends_on_the_fins_and_finish() {
1425 use hpr_design::{FinCrossSection, Finish, Part};
1426 let tunnel: serde_json::Value = serde_json::from_str(include_str!(
1427 "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
1428 ))
1429 .unwrap();
1430 let conditions = DragConditions::coasting(3.0e6 / 0.3048);
1431 let mut counts = Vec::new();
1432 for (section, finish) in [
1433 (FinCrossSection::Square, None),
1434 (FinCrossSection::Airfoil, None),
1435 (FinCrossSection::Square, Some(Finish::Polished {})),
1436 (FinCrossSection::Airfoil, Some(Finish::Polished {})),
1437 ] {
1438 let (mut within, mut chamber_only, mut fragile) = (0, 0, 0);
1439 for configuration in tunnel["configurations"].as_array().unwrap() {
1440 let mut rocket = committed_design(configuration["design"].as_str().unwrap());
1441 for component in &mut rocket.stages[0].components {
1442 component.finish = finish;
1443 for child in &mut component.children {
1444 child.finish = finish;
1445 if let Part::FinSet(set) = &mut child.part {
1446 set.cross_section = section;
1447 }
1448 }
1449 }
1450 let model = AeroModel::new(&rocket.layout().unwrap()).unwrap();
1451 let fin_ids: Vec<&str> = model.fin_sets().iter().map(|f| f.id.as_str()).collect();
1452 for point in configuration["axial_force"].as_array().unwrap() {
1453 let mach = point["mach"].as_f64().unwrap();
1454 let fins = point["fins"] == "on";
1455 let hpr: f64 = model
1456 .buildup_components(&Flow::axial(mach), &conditions)
1457 .unwrap()
1458 .iter()
1459 .filter(|part| fins || !fin_ids.contains(&part.id.as_str()))
1460 .map(|part| part.drag.friction + part.drag.pressure + part.drag.parasitic)
1461 .sum();
1462 let measured = point["forebody_c_a"].as_f64().unwrap();
1463 let inside = |reference: f64| (hpr / reference - 1.0).abs() <= 0.10;
1464 within += usize::from(inside(measured));
1465 let bound = point["forebody_c_a_chamber_only"]
1466 .as_f64()
1467 .unwrap_or(measured);
1468 chamber_only += usize::from(inside(bound));
1469 let spread = point["uncertainty"].as_f64().unwrap() + 0.004;
1470 fragile += usize::from(
1471 inside(measured)
1472 && !(inside(measured - spread) && inside(measured + spread)),
1473 );
1474 }
1475 }
1476 assert_eq!(within, chamber_only, "{section:?}, {finish:?}");
1477 counts.push((within, fragile));
1478 }
1479 assert_eq!(counts.iter().map(|c| c.0).collect::<Vec<_>>(), [0, 0, 0, 2]);
1480 assert_eq!(counts[3].1, 0);
1481 }
1482
1483 #[test]
1488 fn roll_against_mach() {
1489 use serde_json::Value;
1490 let fixture: Value = serde_json::from_str(include_str!(
1491 "../../../validation/fixtures/aero/roll-vs-mach.json"
1492 ))
1493 .unwrap();
1494 let tunnel: Value = serde_json::from_str(include_str!(
1495 "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
1496 ))
1497 .unwrap();
1498 let finner: Value = serde_json::from_str(include_str!(
1499 "../../../validation/fixtures/aero/basic-finner-roll-damping.json"
1500 ))
1501 .unwrap();
1502 let f = |v: &Value| v.as_f64().unwrap();
1503 let mut errors: Vec<(f64, f64)> = Vec::new();
1504 for configuration in fixture["arcas_robin"].as_array().unwrap() {
1505 let design = configuration["design"].as_str().unwrap();
1506 let model = AeroModel::new(&committed_design(design).layout().unwrap()).unwrap();
1507 let set = &model.fin_sets()[0];
1508 let measured = tunnel["configurations"]
1509 .as_array()
1510 .unwrap()
1511 .iter()
1512 .find(|c| c["design"] == design)
1513 .unwrap();
1514 for row in configuration["rows"].as_array().unwrap() {
1515 let mach = f(&row["mach"]);
1516 let what = format!("{design} at Mach {mach}");
1517 let fin = set
1518 .fin
1519 .roll(mach, set.body_radius_m, model.reference_diameter_m())
1520 .unwrap();
1521 let hpr =
1522 f64::from(set.count) * fin.forcing_per_rad * set.roll_forcing_interference * PI
1523 / 180.0;
1524 close(f(&row["hpr_c_l_delta_per_deg"]), hpr, 1e-12, &what);
1525 let reading = measured["roll_effectiveness"]
1526 .as_array()
1527 .unwrap()
1528 .iter()
1529 .find(|c| f(&c["mach"]) == mach)
1530 .unwrap()["alpha_deg_c_l_delta_per_deg"]
1531 .as_array()
1532 .unwrap()
1533 .iter()
1534 .find(|p| f(&p[0]).abs() < 0.5)
1535 .map(|p| f(&p[1]))
1536 .unwrap();
1537 assert_eq!(f(&row["reference_c_l_delta_per_deg"]), reading, "{what}");
1538 close(f(&row["error"]), hpr / reading - 1.0, 1e-12, &what);
1539 errors.push((mach, hpr / reading - 1.0));
1540 }
1541 }
1542 assert_eq!(errors.len(), 11);
1545 let high: Vec<f64> = errors.iter().filter(|e| e.0 >= 2.3).map(|e| e.1).collect();
1546 let low: Vec<f64> = errors.iter().filter(|e| e.0 < 2.3).map(|e| e.1).collect();
1547 assert_eq!(high.len(), 8);
1548 assert!(high.iter().all(|e| e.abs() < 0.0535));
1549 let (lo, hi) = low
1550 .iter()
1551 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &e| {
1552 (a.min(e), b.max(e))
1553 });
1554 assert!(
1555 (lo - 0.143).abs() < 5e-4 && (hi - 0.478).abs() < 5e-4,
1556 "{lo} {hi}"
1557 );
1558
1559 let basic = &fixture["basic_finner"];
1561 let d = 1.0;
1562 let fin = FinAero::new(
1563 &FinPlanform::Trapezoidal {
1564 root_chord_m: d,
1565 tip_chord_m: d,
1566 span_m: d,
1567 sweep_m: 0.0,
1568 },
1569 0.25 * PI * d * d,
1570 )
1571 .unwrap();
1572 let k_r = roll_damping_interference(d, 0.5 * d, 1.0).unwrap();
1573 close(f(&basic["roll_damping_interference"]), k_r, 1e-15, "k_R(B)");
1574 for (key, source) in [
1575 ("wind_tunnel", "wind_tunnel_c_lp"),
1576 ("barrowman_theory", "barrowman_theory_c_lp"),
1577 ] {
1578 let rows = basic[key].as_array().unwrap();
1579 let readings = finner[source].as_array().unwrap();
1580 assert_eq!(rows.len(), readings.len());
1581 for (row, reading) in rows.iter().zip(readings) {
1582 let mach = f(&row["mach"]);
1583 assert_eq!(mach, f(&reading["mach"]));
1584 assert_eq!(f(&row["reference_c_lp"]), f(&reading["c_lp"]));
1585 let hpr = 4.0 * fin.roll(mach, 0.5 * d, d).unwrap().damping * k_r;
1586 close(f(&row["hpr_c_lp"]), hpr, 1e-12, key);
1587 close(
1588 f(&row["error"]),
1589 hpr / f(&reading["c_lp"]) - 1.0,
1590 1e-12,
1591 key,
1592 );
1593 }
1594 }
1595 let tunnel_errors: Vec<f64> = basic["wind_tunnel"]
1598 .as_array()
1599 .unwrap()
1600 .iter()
1601 .map(|r| f(&r["error"]))
1602 .collect();
1603 let (lo, hi) = tunnel_errors
1604 .iter()
1605 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &e| {
1606 (a.min(e), b.max(e))
1607 });
1608 assert!(
1609 (lo + 0.162).abs() < 5e-4 && (hi + 0.059).abs() < 5e-4,
1610 "{lo} {hi}"
1611 );
1612 let theory = f(&basic["barrowman_theory"][0]["error"]);
1613 assert!((theory + 0.020).abs() < 5e-4, "{theory}");
1614 }
1615
1616 #[test]
1620 fn a_rocket_rolls_by_the_sum_of_its_fin_sets() {
1621 let fins = |count: u32, cant: f64, planform: FinPlanform| {
1622 let mut part = fin_set(count, planform);
1623 if let hpr_design::Part::FinSet(set) = &mut part {
1624 set.cant_rad = cant;
1625 }
1626 part
1627 };
1628 let rocket = |cants: [f64; 2]| {
1629 let mut tube = component("tube", body_part(0.8, 0.03, 0.03), None);
1630 tube.children = vec![
1631 component(
1632 "fore",
1633 fins(
1634 3,
1635 cants[0],
1636 FinPlanform::Trapezoidal {
1637 root_chord_m: 0.05,
1638 tip_chord_m: 0.02,
1639 span_m: 0.03,
1640 sweep_m: 0.02,
1641 },
1642 ),
1643 Some(Position::Top { aft_offset_m: 0.1 }),
1644 ),
1645 component(
1646 "aft",
1647 fins(
1648 4,
1649 cants[1],
1650 FinPlanform::Elliptical {
1651 root_chord_m: 0.08,
1652 span_m: 0.05,
1653 },
1654 ),
1655 Some(Position::Bottom { aft_offset_m: 0.0 }),
1656 ),
1657 ];
1658 one_stage(
1659 vec![
1660 component(
1661 "nose",
1662 nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, 0.03),
1663 None,
1664 ),
1665 tube,
1666 ],
1667 ReferenceDiameter::Maximum {},
1668 )
1669 };
1670 let (a, b) = (0.02, -0.01);
1671 let model = AeroModel::new(&rocket([a, b]).layout().unwrap()).unwrap();
1672 let d = model.reference_diameter_m();
1673 for mach in [0.5, 0.9, 1.1, 2.0, 4.5] {
1674 let roll = model.roll(mach).unwrap();
1675 let (mut forcing, mut damping) = (0.0, 0.0);
1676 for set in model.fin_sets() {
1677 let fin = set.fin.roll(mach, set.body_radius_m, d).unwrap();
1678 let n = f64::from(set.count);
1679 forcing -= n * fin.forcing_per_rad * set.roll_forcing_interference * set.cant_rad;
1680 damping += n * fin.damping * set.roll_damping_interference;
1681 }
1682 close(roll.forcing, forcing, 1e-13, "forcing");
1683 close(roll.damping, damping, 1e-13, "damping");
1684 assert!(roll.damping < 0.0);
1685 }
1686 let one = AeroModel::new(&rocket([a, 0.0]).layout().unwrap()).unwrap();
1687 assert!(one.roll(0.5).unwrap().forcing < 0.0);
1688 assert!(model.roll(5.0).is_err());
1689 let still = AeroModel::new(&rocket([0.0, 0.0]).layout().unwrap()).unwrap();
1690 for cant in [0.3, -0.3, f64::NAN] {
1692 let refused = rocket([cant, 0.0])
1693 .layout()
1694 .map_or(true, |layout| AeroModel::new(&layout).is_err());
1695 assert!(refused, "cant {cant}");
1696 }
1697 assert_eq!(still.steady_roll_rate_rad_s(0.5, 170.0).unwrap(), 0.0);
1698 assert!(model.steady_roll_rate_rad_s(0.5, -1.0).is_err());
1699 }
1700}