Skip to main content

hpr/
flight.rs

1//! A flight: a rocket launched from a rail in an environment, flown to the ground.
2
3use std::sync::Arc;
4
5use hpr_aero::{DragModel, DragTable};
6use hpr_analysis::montecarlo::{DragOverride, FlightInputs};
7use hpr_sim::metrics::{FlightMetrics, FlightSummary, Landing};
8use hpr_sim::{
9    EnvelopeFlag, FlightResult, FlightSettings, IssueWarning, Observer, Rail, Separation,
10    Simulation,
11};
12use serde::{Deserialize, Serialize};
13
14use crate::environment::Environment;
15use crate::error::{Error, finite};
16use crate::rocket::Rocket;
17
18/// How a flight is launched: which rocket, where, and from what rail. Made by
19/// [`Flight::builder`]; [`FlightBuilder::fly`] flies it.
20#[derive(Debug, Clone)]
21pub struct FlightBuilder<'a> {
22    rocket: &'a Rocket,
23    environment: &'a Environment,
24    rail: Rail,
25    /// The rail's angles, degrees, where set: they take the place of the rail's own.
26    inclination_deg: Option<f64>,
27    heading_deg: Option<f64>,
28    settings: FlightSettings,
29    /// A drag model flown in place of hpr's drag buildup, where set.
30    drag_model: Option<Arc<dyn DragModel>>,
31    /// A drag table flown in place of hpr's drag buildup, where set; never with a model.
32    drag_table: Option<DragTable>,
33    /// The stack coming apart in flight, in the order it does: none where unset.
34    separations: Vec<Separation>,
35}
36
37impl FlightBuilder<'_> {
38    /// The rail's angle above the horizon, degrees: 90, the default, is vertical, and 85 leans
39    /// 5° off it. (OpenRocket's launch rod angle is measured from the vertical instead: its 5° is
40    /// 85 here.)
41    #[must_use]
42    pub fn inclination_deg(mut self, inclination_deg: f64) -> Self {
43        self.inclination_deg = Some(inclination_deg);
44        self
45    }
46
47    /// The direction the rail leans toward, clockwise from true north, degrees: 0, the
48    /// default, is north and 90 is east. A wind's direction is where it blows from, so a rail
49    /// leaning into a west wind has both at 270. On a vertical rail the heading still turns the
50    /// rocket about its axis, which way its fins face. That matters to a set of one or two fins,
51    /// whose lift depends on which way the air meets them; three or more equal fins lift nearly
52    /// the same whichever way they face.
53    #[must_use]
54    pub fn heading_deg(mut self, heading_deg: f64) -> Self {
55        self.heading_deg = Some(heading_deg);
56        self
57    }
58
59    /// The whole rail, in place of the one [`Flight::builder`] made: its length, its angles in
60    /// radians, the rocket's roll on it and its friction ([`Rail`]).
61    /// [`FlightBuilder::inclination_deg`] and [`FlightBuilder::heading_deg`], called before or
62    /// after, still set its angles.
63    #[must_use]
64    pub fn rail(mut self, rail: Rail) -> Self {
65        self.rail = rail;
66        self
67    }
68
69    /// The integrator and its limits, in place of the defaults ([`FlightSettings`]).
70    #[must_use]
71    pub fn settings(mut self, settings: FlightSettings) -> Self {
72        self.settings = settings;
73        self
74    }
75
76    /// Flies with `separation`: at its trigger the stack comes apart at a stage boundary, and
77    /// each part flies on to its own landing ([`Simulation::with_separation`], which has the
78    /// rules). Every part needs a recovery device on the rocket, each on its own part
79    /// ([`hpr_sim::Device::on_body`]); for a `.ork` file, [`crate::ork::separated_recovery`]
80    /// makes them. Each separated part's landing is in [`FlightSummary::body_landings`].
81    #[must_use]
82    pub fn separation(mut self, separation: Separation) -> Self {
83        self.separations = vec![separation];
84        self
85    }
86
87    /// Flies with `separations`, in the order they fire, each at a stage boundary further forward
88    /// than the one before: a stack that drops its stages one at a time under power
89    /// ([`Simulation::with_separations`], which has the rules). As [`FlightBuilder::separation`],
90    /// every part needs a device of its own; for a `.ork` file, [`crate::ork::separations`] and
91    /// [`crate::ork::separated_recovery`] make them. In place of any set before.
92    #[must_use]
93    pub fn separations(mut self, separations: Vec<Separation>) -> Self {
94        self.separations = separations;
95        self
96    }
97
98    /// Flies `model`'s drag in place of hpr's drag buildup: a model of your own, from a wind
99    /// tunnel, another tool or your flights, or hpr's own adjusted
100    /// ([`hpr_aero::custom`], [`Simulation::with_drag_model`]). The model gives the zero-lift
101    /// drag coefficient on the rocket's reference area, by default a circle of its largest body
102    /// diameter; unlike a drag table's, a model's number isn't rescaled, so a curve measured on
103    /// another area is converted before it is returned. The normal force, center of pressure,
104    /// roll and damping stay hpr's, so the stability margin a [`Rocket`] reports doesn't change.
105    /// The last model or table set is the one flown ([`FlightBuilder::drag_table`]), and every
106    /// flight of this builder shares it.
107    ///
108    /// ```
109    /// # use hpr::rocket::{Fins, Mass, MotorTube, Nose, Tube, material};
110    /// # use hpr::{Environment, FinPlanform, Flight, Motor, NoseShape, Position, Rocket};
111    /// use hpr::hpr_aero::{AeroError, DragModel, DragQuery};
112    ///
113    /// /// hpr's own drag, 20% higher.
114    /// #[derive(Debug)]
115    /// struct Rougher;
116    ///
117    /// impl DragModel for Rougher {
118    ///     fn zero_lift_drag(&self, query: &DragQuery<'_>) -> Result<f64, AeroError> {
119    ///         Ok(1.2 * query.buildup()?.zero_lift_coefficient)
120    ///     }
121    /// }
122    ///
123    /// # let mut rocket = Rocket::new("Small", 0.0563)?;
124    /// # rocket
125    /// #     .add_nose(Nose::hollow(NoseShape::Ogive { radius_ratio: 1.0 }, 0.22, 0.0015, material("abs")?))?
126    /// #     .add_tube(Tube::new(0.9, 0.00115, material("kraft_phenolic")?))?
127    /// #     .add_fins(Fins::new(
128    /// #         3,
129    /// #         FinPlanform::Trapezoidal { root_chord_m: 0.1, tip_chord_m: 0.04, span_m: 0.045, sweep_m: 0.05 },
130    /// #         0.003175,
131    /// #         material("birch_plywood")?,
132    /// #     ))?
133    /// #     .add_motor_tube(MotorTube::new(0.2, 0.029, 0.001, material("kraft_phenolic")?))?
134    /// #     .add_mass(Mass::new(0.2, Position::Top { aft_offset_m: 0.07 }))?
135    /// #     .set_motor(Motor::from_catalog("H54")?)?;
136    /// let environment = Environment::new(32.99, -106.97, 1400.0)?;
137    /// let launch = Flight::builder(&rocket, &environment, 1.8);
138    /// let own = launch.fly()?.apogee_m().ok_or("no apogee")?;
139    /// let rougher = launch.drag_model(Rougher).fly()?.apogee_m().ok_or("no apogee")?;
140    /// assert!(rougher < own);
141    /// # Ok::<(), Box<dyn std::error::Error>>(())
142    /// ```
143    #[must_use]
144    pub fn drag_model(self, model: impl DragModel + 'static) -> Self {
145        self.shared_drag_model(Arc::new(model))
146    }
147
148    /// As [`FlightBuilder::drag_model`], with a model already shared: one model, a large table
149    /// say, flown by many builders without a copy for each.
150    #[must_use]
151    pub fn shared_drag_model(mut self, model: Arc<dyn DragModel>) -> Self {
152        self.drag_model = Some(model);
153        self.drag_table = None;
154        self
155    }
156
157    /// Flies `table`'s drag in place of hpr's drag buildup: another tool's zero-lift drag
158    /// coefficient `C_D0` against Mach number, as RocketPy's `power_off_drag` and
159    /// `power_on_drag` curves are ([`DragTable`], [`Simulation::with_drag_table`]). Its power-on
160    /// curve, where it has one, is flown while a motor thrusts, and its power-off curve at every
161    /// other time. A table read by [`DragTable::from_csv`] interpolates linearly between its Mach
162    /// numbers and holds its end values past them; one built from [`hpr_core::interp::Table1D`]s
163    /// interpolates and extrapolates as they say. A table on a reference diameter of its own
164    /// ([`DragTable::with_reference_diameter_m`]) is rescaled to the rocket's reference area by
165    /// the ratio of the two areas, `C_D0 · (d_table / d_rocket)²`. The normal force, center of
166    /// pressure, roll and damping stay hpr's. The last model or table set is the one flown
167    /// ([`FlightBuilder::drag_model`]). A flight that meets a negative coefficient in the table,
168    /// drag that would push the rocket along, stops with an error that says so.
169    ///
170    /// ```
171    /// # use hpr::rocket::{Fins, Mass, MotorTube, Nose, Tube, material};
172    /// # use hpr::{Environment, FinPlanform, Flight, Motor, NoseShape, Position, Rocket};
173    /// use hpr::hpr_aero::DragTable;
174    ///
175    /// # let mut rocket = Rocket::new("Small", 0.0563)?;
176    /// # rocket
177    /// #     .add_nose(Nose::hollow(NoseShape::Ogive { radius_ratio: 1.0 }, 0.22, 0.0015, material("abs")?))?
178    /// #     .add_tube(Tube::new(0.9, 0.00115, material("kraft_phenolic")?))?
179    /// #     .add_fins(Fins::new(
180    /// #         3,
181    /// #         FinPlanform::Trapezoidal { root_chord_m: 0.1, tip_chord_m: 0.04, span_m: 0.045, sweep_m: 0.05 },
182    /// #         0.003175,
183    /// #         material("birch_plywood")?,
184    /// #     ))?
185    /// #     .add_motor_tube(MotorTube::new(0.2, 0.029, 0.001, material("kraft_phenolic")?))?
186    /// #     .add_mass(Mass::new(0.2, Position::Top { aft_offset_m: 0.07 }))?
187    /// #     .set_motor(Motor::from_catalog("H54")?)?;
188    /// let environment = Environment::new(32.99, -106.97, 1400.0)?;
189    /// let launch = Flight::builder(&rocket, &environment, 1.8);
190    /// // C_D0 of 0.45 up to Mach 0.8, 0.6 by Mach 1.2; 0.05 less while the motor burns.
191    /// let table = DragTable::from_csv(
192    ///     "mach,cd\n0,0.45\n0.8,0.45\n1.2,0.6\n",
193    ///     Some("mach,cd\n0,0.40\n0.8,0.40\n1.2,0.55\n"),
194    /// )?;
195    /// let tabled = launch.clone().drag_table(table).fly()?.apogee_m().ok_or("no apogee")?;
196    /// let own = launch.fly()?.apogee_m().ok_or("no apogee")?;
197    /// assert_ne!(tabled, own);
198    /// # Ok::<(), Box<dyn std::error::Error>>(())
199    /// ```
200    #[must_use]
201    pub fn drag_table(mut self, table: DragTable) -> Self {
202        self.drag_table = Some(table);
203        self.drag_model = None;
204        self
205    }
206
207    /// The [`Simulation`] [`FlightBuilder::fly`] runs, for what the facade doesn't offer: user
208    /// events, staging, mass shifts and the rest of [`hpr_sim`].
209    ///
210    /// # Errors
211    ///
212    /// - [`Error::NoMotor`] for a rocket with no motor.
213    /// - [`Error::Domain`] for an inclination outside `(0°, 90°]` or a heading that isn't
214    ///   finite.
215    /// - [`Error::Sim`] for what [`Simulation::new`], [`Simulation::with_recovery`] and
216    ///   [`Simulation::with_separations`] refuse: a rail that isn't positive in length, a design
217    ///   with errors in it ([`hpr_design::checks`]), a recovery device the flight can't fly, a
218    ///   separation with a part that carries no device.
219    pub fn simulation(&self) -> Result<Simulation, Error> {
220        Ok(self.inputs()?.simulation()?)
221    }
222
223    /// Everything the flight is flown from, as [`hpr_analysis::montecarlo::FlightInputs`]: what a
224    /// Monte Carlo run disperses ([`hpr_analysis::montecarlo`]).
225    ///
226    /// # Errors
227    ///
228    /// - [`Error::NoMotor`] for a rocket with no motor.
229    /// - [`Error::Domain`] for an inclination outside `(0°, 90°]` or a heading that isn't
230    ///   finite.
231    /// - [`Error::Sim`] for a rail that isn't positive in length or whose friction is negative.
232    pub fn inputs(&self) -> Result<FlightInputs, Error> {
233        let configuration_id = self.rocket.configuration_id().ok_or(Error::NoMotor)?;
234        let mut rail = self.rail;
235        if let Some(inclination_deg) = self.inclination_deg {
236            if !(inclination_deg > 0.0 && inclination_deg <= 90.0) {
237                return Err(Error::Domain {
238                    what: "rail inclination, degrees above the horizon",
239                    value: inclination_deg,
240                });
241            }
242            rail.elevation_rad = inclination_deg.to_radians();
243        }
244        if let Some(heading_deg) = self.heading_deg {
245            rail.azimuth_rad = finite("rail heading, degrees", heading_deg)?.to_radians();
246        }
247        rail.validate()?;
248        let mut inputs = FlightInputs::new(
249            self.rocket.design().clone(),
250            configuration_id,
251            self.environment.sim().clone(),
252            rail,
253        );
254        inputs.settings = self.settings;
255        inputs.recovery = self.rocket.recovery().to_vec();
256        // A builder holds one or neither: each setter clears the other.
257        if let Some(model) = &self.drag_model {
258            inputs.drag = Some(DragOverride::Model(Arc::clone(model)));
259        }
260        if let Some(table) = &self.drag_table {
261            inputs.drag = Some(DragOverride::Table(table.clone()));
262        }
263        inputs.separations.clone_from(&self.separations);
264        Ok(inputs)
265    }
266
267    /// Flies the rocket from the rail to the ground.
268    ///
269    /// # Errors
270    ///
271    /// As [`FlightBuilder::simulation`], and [`Error::Sim`] for a flight that fails on the way,
272    /// such as a model asked about a flow it doesn't cover.
273    pub fn fly(&self) -> Result<Flight, Error> {
274        self.fly_with(&mut ())
275    }
276
277    /// Flies the rocket, showing `observer` every step of the flight as it goes: a
278    /// [`hpr_sim::Recorder`] keeps a trajectory, and an [`Observer`] of your own can watch for
279    /// anything.
280    ///
281    /// # Errors
282    ///
283    /// As [`FlightBuilder::fly`], and whatever `observer` returns.
284    pub fn fly_with(&self, observer: &mut dyn Observer) -> Result<Flight, Error> {
285        let simulation = self.simulation()?;
286        let mut metrics = FlightMetrics::new();
287        let result = simulation.run(&mut (&mut metrics, observer))?;
288        let summary = metrics.summary(&result, self.environment.sim())?;
289        // The drag issues are hpr's buildup's: a flight on a drag of its own meets none.
290        let own_drag = self.drag_model.is_some() || self.drag_table.is_some();
291        let mut issue_warnings = if own_drag {
292            Vec::new()
293        } else {
294            let layout = &simulation.assembly().layout;
295            let mut warnings = hpr_sim::issues::layout_issue_warnings(layout, summary.max_mach);
296            warnings.extend(hpr_sim::issues::friction_issue_warnings(summary.max_mach));
297            warnings
298        };
299        // A separated part's descent never takes the rocket's drag, so its issues stay with a
300        // drag of the flight's own.
301        issue_warnings.extend(hpr_sim::issues::separated_part_issue_warnings(
302            &result,
303            summary.max_mach,
304        ));
305        // The stability issues are hpr's normal force's (ADR-181): a drag of the flight's own
306        // keeps them. Asking why the supersonic run stopped may build its table, so only a flight
307        // the switches can matter to asks.
308        let aero = simulation.aero();
309        if aero.normal_force_table().is_none() {
310            let supersonic = summary.max_mach.is_some_and(|mach| {
311                mach.value.is_nan() || mach.value > hpr_sim::issues::SUPERSONIC_SWITCH_MACH
312            });
313            let fallback = if supersonic {
314                aero.supersonic_fallback()
315            } else {
316                None
317            };
318            let layout = &simulation.assembly().layout;
319            issue_warnings.extend(hpr_sim::issues::layout_stability_issue_warnings(
320                layout,
321                summary.max_mach,
322            ));
323            issue_warnings.extend(hpr_sim::issues::stability_issue_warnings(
324                fallback.as_ref(),
325                summary.min_static_margin_cal,
326                summary.max_mach,
327            ));
328        }
329        Ok(Flight {
330            result,
331            summary,
332            issue_warnings,
333        })
334    }
335}
336
337/// A flown flight: what happened, and its metrics.
338///
339/// Heights are the rocket's center of gravity's, above the launch site: the rocket stands on
340/// the rail at the start, so the first height is not zero. Speeds are relative to the ground.
341/// [`Flight::summary`] has every metric; the methods below are the ones most asked for. A flight
342/// serializes and reads back as a record; one read back isn't flown again or checked.
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344pub struct Flight {
345    result: FlightResult,
346    summary: FlightSummary,
347    /// The known drag issues it meets; none on a record saved before they were kept.
348    #[serde(default)]
349    issue_warnings: Vec<IssueWarning>,
350}
351
352impl Flight {
353    /// A flight of `rocket` in `environment` from a vertical, frictionless rail `rail_length_m`
354    /// long, measured from the rocket's aft end at the start to the rail's top. Set the rest on
355    /// the builder; [`FlightBuilder::fly`] flies it.
356    #[must_use]
357    pub fn builder<'a>(
358        rocket: &'a Rocket,
359        environment: &'a Environment,
360        rail_length_m: f64,
361    ) -> FlightBuilder<'a> {
362        FlightBuilder {
363            rocket,
364            environment,
365            rail: Rail::vertical(rail_length_m),
366            inclination_deg: None,
367            heading_deg: None,
368            settings: FlightSettings::default(),
369            drag_model: None,
370            drag_table: None,
371            separations: Vec::new(),
372        }
373    }
374
375    /// Every metric of the flight: apogee, peaks, stability margins, landings.
376    #[must_use]
377    pub fn summary(&self) -> &FlightSummary {
378        &self.summary
379    }
380
381    /// The flight itself: its events, its end, and every body's.
382    #[must_use]
383    pub fn result(&self) -> &FlightResult {
384        &self.result
385    }
386
387    /// The apogee's height above the launch site, m; `None` if the flight ended before it.
388    #[must_use]
389    pub fn apogee_m(&self) -> Option<f64> {
390        self.summary
391            .apogee
392            .map(|apogee| apogee.height_above_ground_m)
393    }
394
395    /// When the apogee came, s after launch.
396    #[must_use]
397    pub fn apogee_time_s(&self) -> Option<f64> {
398        self.summary.apogee.map(|apogee| apogee.time_s)
399    }
400
401    /// The top speed, m/s.
402    #[must_use]
403    pub fn max_speed_m_s(&self) -> Option<f64> {
404        self.summary.max_speed_m_s.map(|peak| peak.value)
405    }
406
407    /// The top Mach number.
408    #[must_use]
409    pub fn max_mach(&self) -> Option<f64> {
410        self.summary.max_mach.map(|peak| peak.value)
411    }
412
413    /// The speed as the rocket left the rail, m/s.
414    #[must_use]
415    pub fn rail_exit_speed_m_s(&self) -> Option<f64> {
416        self.summary.rail_exit_speed_m_s.map(|peak| peak.value)
417    }
418
419    /// Where and how fast the rocket landed; `None` if it never reached the ground.
420    #[must_use]
421    pub fn landing(&self) -> Option<&Landing> {
422        self.summary.landing.as_ref()
423    }
424
425    /// Where the flight's numbers deserve less trust: unstable under power (a static margin below
426    /// zero while a motor burns, so its apogee is not a prediction), and where it goes past what
427    /// hpr's numbers have been checked for: faster than the fastest validated flight, at a high
428    /// angle of attack, outside the core band or beyond the envelope ([`hpr_sim::envelope`]).
429    /// Empty for a flight that raises none.
430    #[must_use]
431    pub fn envelope_flags(&self) -> Vec<EnvelopeFlag> {
432        self.summary.envelope_flags()
433    }
434
435    /// The known errors in hpr's drag, a separated part's drag and the stability margin this
436    /// flight meets, each by its issue number, with the parts whose shape meets its condition and
437    /// which way its numbers lean ([`hpr_sim::issues`]). A flight on a drag model or table of its
438    /// own ([`FlightBuilder::drag_model`], [`FlightBuilder::drag_table`]), whose drag isn't hpr's,
439    /// meets none of hpr's drag issues but keeps the separated parts' (#179, #354), and the
440    /// stability ones unless its model has a normal-force table of its own. Empty for a flight that meets none, and for a record saved before the
441    /// warnings were kept.
442    #[must_use]
443    pub fn issue_warnings(&self) -> &[IssueWarning] {
444        &self.issue_warnings
445    }
446}