Skip to main content

hpr_aero/
custom.rs

1//! Drag models of your own, flown in place of hpr's drag buildup.
2//!
3//! A [`DragModel`] gives the whole rocket's zero-lift drag coefficient `C_D0`, its drag with the
4//! air along its axis over the dynamic pressure and the reference area, at a flow.
5//! [`AeroModel::with_drag_model`] puts one in place of hpr's drag buildup (its sum of friction,
6//! pressure, base and parasitic drag, part by part) and of any drag table ([`crate::table`]); a
7//! flight takes one through `hpr_sim::Simulation::with_drag_model`.
8//!
9//! The model replaces the zero-lift drag only, as a drag table does. The rest stays hpr's:
10//!
11//! - At an angle of attack `α` the axial coefficient is `C_A = C_D0 f(α)`, with hpr's factor `f`
12//!   ([`crate::drag::axial_drag_alpha_factor`]): 1 along the axis, 1.3 at 17°, 0 at 90°.
13//! - The normal force, the center of pressure, the roll and the pitch and yaw damping are hpr's
14//!   own, from [`AeroModel::normal_force`] and [`AeroModel::roll`].
15//!
16//! The coefficient is on the rocket's reference area ([`DragQuery::reference_area_m2`]), which is
17//! the design's: by default a circle of the largest body diameter. Unlike a drag table, which
18//! can carry the diameter it was measured on, a model's number is not rescaled: a curve measured
19//! on another area `S` is multiplied by `S` over the reference area before it is returned.
20//!
21//! A model is asked a [`DragQuery`]: the flow (Mach number and angles), the drag conditions
22//! (Reynolds number per meter, whether a motor is thrusting) and hpr's own buildup at that flow,
23//! so a model can adjust hpr's number instead of replacing it.
24//!
25//! **How far to trust it:** as far as the model, and no further than hpr's other models, which
26//! still fly the rest of the rocket. hpr refuses a coefficient that is negative or not finite; it
27//! can't know whether the number is right.
28//!
29//! ```
30//! use hpr_aero::{AeroError, AeroModel, DragConditions, DragModel, DragQuery, Flow};
31//!
32//! /// hpr's own drag, 10% higher: a rougher finish than the design says, say.
33//! #[derive(Debug)]
34//! struct TenPercentMore;
35//!
36//! impl DragModel for TenPercentMore {
37//!     fn zero_lift_drag(&self, query: &DragQuery<'_>) -> Result<f64, AeroError> {
38//!         Ok(1.1 * query.buildup()?.zero_lift_coefficient)
39//!     }
40//! }
41//!
42//! let rocket: hpr_design::Rocket = serde_json::from_str(include_str!(
43//!     "../../../validation/designs/rocketpy-calisto-tests-motor-at-minus-1.373.json"
44//! ))?;
45//! let hpr = AeroModel::new(&rocket.layout()?)?;
46//! let custom = hpr.clone().with_drag_model(TenPercentMore);
47//!
48//! let (flow, conditions) = (Flow::axial(0.3), DragConditions::coasting(6.0e6));
49//! let own = hpr.drag(&flow, &conditions)?.zero_lift_coefficient;
50//! let more = custom.drag(&flow, &conditions)?.zero_lift_coefficient;
51//! assert!((more - 1.1 * own).abs() < 1e-15);
52//! # Ok::<(), Box<dyn std::error::Error>>(())
53//! ```
54
55use std::fmt;
56use std::sync::Arc;
57
58use crate::drag::{ComponentDrag, Drag, DragConditions};
59use crate::error::AeroError;
60use crate::model::{AeroModel, Flow};
61
62/// A rocket's zero-lift drag, given by a program in place of hpr's drag buildup.
63///
64/// Implement [`DragModel::zero_lift_drag`] and hand the model to [`AeroModel::with_drag_model`]
65/// or `hpr_sim::Simulation::with_drag_model`. The flight asks it at every evaluation of the
66/// equations of motion, several times a step, so it should be quick and give the same answer to
67/// the same question: a flight's determinism is only as good as its model's. The integrator's
68/// trial evaluations can reach a little past the speeds the flight itself reaches, so a model
69/// that refuses past the end of its data wants some margin beyond the top speed.
70///
71/// A model is shared, not copied: it is kept behind an [`Arc`], so a model that holds a large
72/// table costs nothing to fly many times. [`AeroModel::with_shared_drag_model`] takes an
73/// `Arc<dyn DragModel>` that is already shared.
74///
75/// A model must not ask for the drag of an [`AeroModel`] that holds it, which would ask the model
76/// again, without end; [`DragQuery::buildup`] is hpr's own drag without the model.
77pub trait DragModel: fmt::Debug + Send + Sync {
78    /// The whole rocket's zero-lift drag coefficient `C_D0` at `query`'s flow, on the rocket's
79    /// reference area ([`DragQuery::reference_area_m2`]). From hpr's buildup that is
80    /// [`Drag::zero_lift_coefficient`], not [`Drag::axial_coefficient`], which is already scaled
81    /// for the angle of attack.
82    ///
83    /// # Errors
84    ///
85    /// Whatever the model can't answer, such as a Mach number past its data. The flight wraps
86    /// the error in [`AeroError::DragModel`], so it says where it came from; an error from
87    /// [`DragQuery::buildup`] can be passed on as it is.
88    fn zero_lift_drag(&self, query: &DragQuery<'_>) -> Result<f64, AeroError>;
89}
90
91/// What a [`DragModel`] is asked: the flow and the drag conditions, with the rocket's reference
92/// area, its length and hpr's own drag buildup at hand. Made by [`AeroModel::drag`]; to try a
93/// model on its own, give it to an [`AeroModel`] and ask that for its drag.
94#[derive(Clone, Copy)]
95pub struct DragQuery<'a> {
96    flow: &'a Flow,
97    conditions: &'a DragConditions,
98    model: &'a AeroModel,
99}
100
101impl<'a> DragQuery<'a> {
102    /// A question about `model`'s rocket at `flow` and `conditions`, the conditions already read
103    /// as the buildup reads them (`AeroModel::read`) and the Mach number checked.
104    pub(crate) fn new(
105        flow: &'a Flow,
106        conditions: &'a DragConditions,
107        model: &'a AeroModel,
108    ) -> Self {
109        Self {
110            flow,
111            conditions,
112            model,
113        }
114    }
115
116    /// The flow: Mach number, angle of attack and roll. The coefficient asked for is the
117    /// zero-lift one whatever the angle of attack; hpr scales it for the angle itself.
118    pub fn flow(&self) -> &Flow {
119        self.flow
120    }
121
122    /// The Mach number, as a shorthand for `flow().mach`: finite and not negative.
123    pub fn mach(&self) -> f64 {
124        self.flow.mach
125    }
126
127    /// The drag conditions: the Reynolds number per meter, and whether a motor is thrusting,
128    /// which a model with a power-on curve reads. They are as hpr's buildup reads them: under
129    /// [`AeroModel::with_full_base_drag_under_power`] the thrusting motors' areas are zero.
130    pub fn conditions(&self) -> &DragConditions {
131        self.conditions
132    }
133
134    /// The area the coefficient is on: the rocket's reference area, m².
135    pub fn reference_area_m2(&self) -> f64 {
136        self.model.reference_area_m2()
137    }
138
139    /// The rocket's length, nose tip to the aft end of its last body component, m: the length
140    /// hpr's buildup takes the Reynolds number on.
141    pub fn length_m(&self) -> f64 {
142        self.model.length_m()
143    }
144
145    /// hpr's own drag buildup at this flow and these conditions ([`AeroModel::buildup_drag`]):
146    /// what the flight would have used without the model.
147    ///
148    /// # Errors
149    ///
150    /// As [`AeroModel::buildup_drag`]: among others, [`AeroError::Mach`] at Mach 5 and faster,
151    /// where the buildup ends.
152    pub fn buildup(&self) -> Result<Drag, AeroError> {
153        self.model.buildup_drag(self.flow, self.conditions)
154    }
155
156    /// hpr's own drag buildup at this flow, component by component
157    /// ([`AeroModel::buildup_components`]).
158    ///
159    /// # Errors
160    ///
161    /// As [`AeroModel::buildup_components`].
162    pub fn buildup_components(&self) -> Result<Vec<ComponentDrag>, AeroError> {
163        self.model.buildup_components(self.flow, self.conditions)
164    }
165}
166
167impl fmt::Debug for DragQuery<'_> {
168    /// The question, without the rocket's whole model.
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.debug_struct("DragQuery")
171            .field("flow", self.flow)
172            .field("conditions", self.conditions)
173            .finish_non_exhaustive()
174    }
175}
176
177/// A [`DragModel`] held by an [`AeroModel`]. Two are equal when they are the same model, so an
178/// [`AeroModel`] and its clone stay equal. It serializes as its `Debug` text, to show which model
179/// an inspected [`AeroModel`] flies.
180#[derive(Clone)]
181pub(crate) struct SharedDragModel(pub(crate) Arc<dyn DragModel>);
182
183impl PartialEq for SharedDragModel {
184    fn eq(&self, other: &Self) -> bool {
185        Arc::ptr_eq(&self.0, &other.0)
186    }
187}
188
189impl fmt::Debug for SharedDragModel {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        self.0.fmt(f)
192    }
193}
194
195impl serde::Serialize for SharedDragModel {
196    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
197        serializer.collect_str(&format_args!("{:?}", self.0))
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use std::sync::Mutex;
204
205    use super::*;
206    use crate::table::DragTable;
207    use crate::testing::finned_rocket;
208
209    fn model() -> AeroModel {
210        AeroModel::new(&finned_rocket(3).layout().unwrap()).unwrap()
211    }
212
213    /// hpr's own buildup, handed back unchanged.
214    #[derive(Debug)]
215    struct Buildup;
216
217    impl DragModel for Buildup {
218        fn zero_lift_drag(&self, query: &DragQuery<'_>) -> Result<f64, AeroError> {
219            Ok(query.buildup()?.zero_lift_coefficient)
220        }
221    }
222
223    /// The same coefficient at every flow.
224    #[derive(Debug)]
225    struct Constant(f64);
226
227    impl DragModel for Constant {
228        fn zero_lift_drag(&self, _query: &DragQuery<'_>) -> Result<f64, AeroError> {
229            Ok(self.0)
230        }
231    }
232
233    /// Keeps the conditions it was asked with.
234    #[derive(Debug, Default)]
235    struct Spy(Mutex<Vec<DragConditions>>);
236
237    impl DragModel for Spy {
238        fn zero_lift_drag(&self, query: &DragQuery<'_>) -> Result<f64, AeroError> {
239            self.0.lock().unwrap().push(*query.conditions());
240            Ok(0.5)
241        }
242    }
243
244    #[test]
245    fn a_model_handing_back_the_buildup_changes_nothing() {
246        let own = model();
247        let custom = own.clone().with_drag_model(Buildup);
248        for mach in [0.0, 0.3, 0.95, 1.2, 2.5, 4.9] {
249            for alpha_deg in [0.0, 4.0, 17.0, 60.0, 120.0] {
250                let flow = Flow::new(mach, f64::to_radians(alpha_deg), 0.3);
251                for conditions in [
252                    DragConditions::coasting(5.0e6),
253                    DragConditions::thrusting(5.0e6, 0.0005),
254                ] {
255                    assert_eq!(
256                        custom.drag(&flow, &conditions).unwrap(),
257                        Drag {
258                            friction: 0.0,
259                            pressure: 0.0,
260                            base: 0.0,
261                            parasitic: 0.0,
262                            ..own.drag(&flow, &conditions).unwrap()
263                        },
264                        "Mach {mach}, {alpha_deg}°"
265                    );
266                    assert_eq!(
267                        custom.buildup_drag(&flow, &conditions).unwrap(),
268                        own.drag(&flow, &conditions).unwrap()
269                    );
270                }
271            }
272        }
273        // The buildup ends at Mach 5; the model passes its refusal on, and the flight says it
274        // came through the model.
275        let past = Flow::axial(5.0);
276        let conditions = DragConditions::coasting(5.0e6);
277        let error = custom.drag(&past, &conditions).unwrap_err();
278        assert!(
279            matches!(&error, AeroError::DragModel { source }
280                if matches!(**source, AeroError::Mach { mach, .. } if mach == 5.0)),
281            "{error:?}"
282        );
283    }
284
285    #[test]
286    fn a_constant_model_flies_as_a_constant_table() {
287        let table = DragTable::from_csv("0,0.45\n1,0.45\n", None).unwrap();
288        let with_table = model().with_drag_table(table);
289        let with_model = model().with_drag_model(Constant(0.45));
290        for mach in [0.0, 0.5, 3.0, 7.0] {
291            for alpha_deg in [0.0, 10.0, 170.0] {
292                let flow = Flow::new(mach, f64::to_radians(alpha_deg), 0.0);
293                let conditions = DragConditions::coasting(1.0e6);
294                let from_table = with_table.drag(&flow, &conditions).unwrap();
295                let from_model = with_model.drag(&flow, &conditions).unwrap();
296                assert_eq!(
297                    from_model.zero_lift_coefficient,
298                    from_table.zero_lift_coefficient
299                );
300                assert_eq!(from_model.axial_coefficient, from_table.axial_coefficient);
301                assert!(from_model.table.is_none());
302            }
303        }
304    }
305
306    /// A drag scale multiplies the zero-lift coefficient and its parts, whatever gives it, and
307    /// the axial coefficient with it; a table's lookup and the buildup a model adjusts are left
308    /// as they are. A scale of 1 changes nothing, and a bad one is refused.
309    #[test]
310    fn a_drag_scale_multiplies_the_buildup_a_table_and_a_model() {
311        let table = DragTable::from_csv("0,0.45\n1,0.45\n", None).unwrap();
312        let conditions = DragConditions::thrusting(5.0e6, 0.0005);
313        let flow = Flow::new(0.6, f64::to_radians(7.0), 0.2);
314        for own in [
315            model(),
316            model().with_drag_table(table),
317            model().with_drag_model(Constant(0.7)),
318        ] {
319            let plain = own.drag(&flow, &conditions).unwrap();
320            assert_eq!(own.drag_scale(), 1.0);
321            assert_eq!(
322                own.clone()
323                    .with_drag_scale(1.0)
324                    .unwrap()
325                    .drag(&flow, &conditions)
326                    .unwrap(),
327                plain
328            );
329            let scaled_model = own.clone().with_drag_scale(1.25).unwrap();
330            assert_eq!(scaled_model.drag_scale(), 1.25);
331            let scaled = scaled_model.drag(&flow, &conditions).unwrap();
332            assert!(plain.zero_lift_coefficient > 0.0);
333            assert_eq!(
334                scaled.zero_lift_coefficient,
335                1.25 * plain.zero_lift_coefficient
336            );
337            assert_eq!(scaled.friction, 1.25 * plain.friction);
338            assert_eq!(scaled.pressure, 1.25 * plain.pressure);
339            assert_eq!(scaled.base, 1.25 * plain.base);
340            assert_eq!(scaled.parasitic, 1.25 * plain.parasitic);
341            let ratio = scaled.axial_coefficient / plain.axial_coefficient;
342            assert!((ratio - 1.25).abs() < 1e-15, "{ratio}");
343            assert_eq!(scaled.table, plain.table);
344            assert_eq!(
345                scaled_model.buildup_drag(&flow, &conditions).unwrap(),
346                own.buildup_drag(&flow, &conditions).unwrap()
347            );
348        }
349        for bad in [-0.1, f64::NAN, f64::INFINITY] {
350            let error = model().with_drag_scale(bad).unwrap_err();
351            assert!(
352                matches!(error, AeroError::Domain { what: "drag scale", value }
353                    if value.to_bits() == bad.to_bits()),
354                "{error:?}"
355            );
356        }
357    }
358
359    #[test]
360    fn a_model_and_a_table_replace_each_other() {
361        let table = DragTable::from_csv("0,0.45\n1,0.45\n", None).unwrap();
362        let model_last = model()
363            .with_drag_table(table.clone())
364            .with_drag_model(Constant(0.7));
365        assert!(model_last.drag_table().is_none() && model_last.drag_model().is_some());
366        let table_last = model()
367            .with_drag_model(Constant(0.7))
368            .with_drag_table(table);
369        assert!(table_last.drag_model().is_none() && table_last.drag_table().is_some());
370        let conditions = DragConditions::coasting(1.0e6);
371        let flow = Flow::axial(0.5);
372        let drag = |m: &AeroModel| m.drag(&flow, &conditions).unwrap().zero_lift_coefficient;
373        assert_eq!(drag(&model_last), 0.7);
374        assert_eq!(drag(&table_last), 0.45);
375    }
376
377    #[test]
378    fn a_model_is_asked_the_conditions_the_buildup_reads() {
379        let spy = Arc::new(Spy::default());
380        let asked = || spy.0.lock().unwrap().pop().unwrap();
381        let conditions =
382            DragConditions::thrusting(1.0e6, 0.0005).with_pod_motors([0.0, 0.0002, 0.0, 0.0]);
383        let flow = Flow::axial(0.5);
384        model()
385            .with_shared_drag_model(spy.clone())
386            .drag(&flow, &conditions)
387            .unwrap();
388        assert_eq!(asked(), conditions);
389        // With the base kept whole, the motors' areas are read as zero, for the model as for the
390        // buildup; it still hears that a motor burns.
391        model()
392            .with_full_base_drag_under_power()
393            .with_shared_drag_model(spy.clone())
394            .drag(&flow, &conditions)
395            .unwrap();
396        let read = asked();
397        assert!(read.thrusting);
398        assert_eq!(read.thrusting_motor_area_m2, 0.0);
399        assert_eq!(
400            read.thrusting_pod_motor_areas_m2,
401            [0.0; crate::MOTOR_POD_SETS]
402        );
403    }
404
405    #[test]
406    fn a_coefficient_that_is_negative_or_not_finite_is_refused() {
407        let conditions = DragConditions::coasting(1.0e6);
408        let flow = Flow::axial(0.5);
409        for bad in [-0.01, f64::NAN, f64::INFINITY] {
410            let error = model()
411                .with_drag_model(Constant(bad))
412                .drag(&flow, &conditions)
413                .unwrap_err();
414            assert!(
415                matches!(error, AeroError::Domain { what, value }
416                    if what == "zero-lift drag coefficient from a drag model"
417                        && value.to_bits() == bad.to_bits()),
418                "{error:?}"
419            );
420        }
421        // Zero is a coefficient, if an odd one.
422        let zero = model().with_drag_model(Constant(0.0));
423        assert_eq!(
424            zero.drag(&flow, &conditions).unwrap().axial_coefficient,
425            0.0
426        );
427        // A Mach number the model can't be asked about, and angles out of their domain.
428        let custom = model().with_drag_model(Constant(0.5));
429        for mach in [-0.1, f64::NAN] {
430            let error = custom.drag(&Flow::axial(mach), &conditions).unwrap_err();
431            assert!(
432                matches!(error, AeroError::Domain { what, .. }
433                    if what == "Mach number for a drag model"),
434                "{error:?}"
435            );
436        }
437        let error = custom
438            .drag(&Flow::new(0.5, -0.1, 0.0), &conditions)
439            .unwrap_err();
440        assert!(
441            matches!(error, AeroError::Domain { value, .. } if value == -0.1),
442            "{error:?}"
443        );
444    }
445
446    #[test]
447    fn a_models_own_error_is_passed_on() {
448        #[derive(Debug)]
449        struct Refuses;
450        impl DragModel for Refuses {
451            fn zero_lift_drag(&self, query: &DragQuery<'_>) -> Result<f64, AeroError> {
452                Err(AeroError::Domain {
453                    what: "Mach number past the model's data",
454                    value: query.mach(),
455                })
456            }
457        }
458        let error = model()
459            .with_drag_model(Refuses)
460            .drag(&Flow::axial(0.5), &DragConditions::coasting(1.0e6))
461            .unwrap_err();
462        assert!(
463            matches!(&error, AeroError::DragModel { source }
464            if **source == AeroError::Domain {
465                what: "Mach number past the model's data",
466                value: 0.5,
467            }),
468            "{error:?}"
469        );
470        assert_eq!(
471            error.to_string(),
472            "drag model: Mach number past the model's data is outside its domain: 0.5"
473        );
474    }
475
476    #[test]
477    fn models_holding_the_same_drag_model_are_equal() {
478        let custom = model().with_drag_model(Constant(0.5));
479        assert_eq!(custom.clone(), custom);
480        assert_ne!(model().with_drag_model(Constant(0.5)), custom);
481        assert_ne!(model(), custom);
482        let shared: Arc<dyn DragModel> = Arc::new(Constant(0.5));
483        assert_eq!(
484            model().with_shared_drag_model(Arc::clone(&shared)),
485            model().with_shared_drag_model(Arc::clone(&shared))
486        );
487        assert!(Arc::ptr_eq(
488            model()
489                .with_shared_drag_model(Arc::clone(&shared))
490                .drag_model()
491                .unwrap(),
492            &shared
493        ));
494    }
495
496    #[test]
497    fn a_query_says_what_the_flight_asks() {
498        let rocket = model();
499        let (flow, conditions) = (Flow::new(0.2, 0.1, 0.3), DragConditions::coasting(1.0e6));
500        let query = DragQuery::new(&flow, &conditions, &rocket);
501        assert_eq!(query.mach(), 0.2);
502        assert_eq!(*query.flow(), flow);
503        assert_eq!(*query.conditions(), conditions);
504        assert_eq!(query.reference_area_m2(), rocket.reference_area_m2());
505        assert_eq!(query.length_m(), rocket.length_m());
506        assert_eq!(
507            query.buildup_components().unwrap(),
508            rocket.buildup_components(&flow, &conditions).unwrap()
509        );
510        // Its debug text leaves the rocket's whole model out.
511        let text = format!("{query:?}");
512        assert!(text.starts_with("DragQuery { flow: Flow {") && text.ends_with(", .. }"));
513    }
514
515    #[test]
516    fn an_inspected_model_names_its_drag_model() {
517        let text = serde_json::to_string(&model().with_drag_model(Constant(0.45))).unwrap();
518        assert!(text.contains(r#""drag_model":"Constant(0.45)""#), "{text}");
519        let own = serde_json::to_string(&model()).unwrap();
520        assert!(!own.contains("drag_model"));
521    }
522}