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}