hpr_sim/flight.rs
1//! A flight from ignition to the ground: the pad, the rail and free flight, their events, and how
2//! the flight ends.
3//!
4//! [`Simulation::run`] integrates the rigid-body equations (`crate::dynamics`) phase by phase:
5//!
6//! - **Pad:** the state holds still until the force along the rail exceeds friction (liftoff).
7//! - **Rail:** one degree of freedom along the rail until the last guide leaves its top (rail
8//! exit, `crate::rail`). If the rocket stops on the rail it falls back to the pad phase.
9//! - **Free:** six degrees of freedom until the ground.
10//! - **Descent:** once a recovery device deploys, a point mass under the open devices' drag area
11//! (`crate::recovery`), with the attitude frozen where it deployed.
12//!
13//! Every ignition, thrust-curve knot and burnout is a stop time, so no step straddles a change in
14//! the thrust's slope or the start or end of a burn, and each interval knows which motors burn.
15//! Each motor burns on its own clock from its ignition ([`hpr_design::Ignition`]).
16//!
17//! **Staging.** A [`Separation`] whose nose body still has a motor to burn is powered: that body,
18//! the sustainer, flies on in six degrees of freedom on its own stages' aerodynamics and mass, and
19//! the aft body, the booster, descends to its landing as a point mass ([`FlightResult::bodies`]).
20//! The state is the nose tip's, which the sustainer keeps, so it carries straight across the split
21//! (the decision record on staging, [ADR-074][adr-074]). Liftoff, rail
22//! exit, apogee (the center of mass's height rate crossing zero), ground contact (its ellipsoidal
23//! height reaching the site's) and user events are located by the integrator's event finder.
24//!
25//! A flight ends on the ground ([`Termination::GroundHit`]), with no liftoff by the last burnout
26//! ([`Termination::NoLiftoff`]), stalled back onto the pad ([`Termination::StalledOnRail`]), at
27//! the time cap ([`Termination::TimeCap`]) or at the step limit ([`Termination::StepLimit`]).
28//! Anything else that stops it is an error. With no recovery device the flight is ballistic to
29//! the ground.
30//!
31//! Method: `docs/physics/flight.md` and `docs/physics/recovery.md`.
32//!
33//! [adr-074]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-074-ignition-times-and-powered-staging-the-sustainer-flies-on-as-a-rigid-body-2026-09-25
34
35use std::fmt;
36use std::ops::ControlFlow;
37use std::sync::Arc;
38
39use hpr_aero::{AeroModel, DragModel, DragTable, NormalForceTable};
40use hpr_core::DVec3;
41use hpr_design::checks::has_errors;
42use hpr_design::{LaidOut, Rocket};
43use serde::{Deserialize, Serialize};
44
45use crate::dynamics::{Conditions, Evaluation, Phase, Vehicle};
46use crate::environment::Environment;
47use crate::error::SimError;
48use crate::events::Direction;
49use crate::integrator::{
50 Adaptive, Advance, IntegrationError, Integrator, Method, OdeSystem, Stats, Step,
51};
52use crate::metrics::Stability;
53use crate::pieces::{Ejection, Pieces};
54use crate::rail::{Guides, Rail};
55use crate::recorder::{FlightStep, Observer, Sample};
56use crate::recovery::{self, BodyEvent, BodyFlight, BodySample, Device, Run, Separation, Trigger};
57use crate::releases::{MassRelease, ReleasedFlight, Releases};
58use crate::shifts::{MassShift, Shifts};
59use crate::staging::Sustainer;
60use crate::state::{STATE_LEN, State};
61
62/// The airspeed below which a body with no attitude of its own is taken to be still in the air, m/s:
63/// the way an ejection's push points is then up rather than along its drift (ADR-086).
64const STILL_AIR_M_S: f64 = 1e-3;
65
66/// How the refusals of a trigger read for a mass shift or a mass release, where a device's would
67/// name a device.
68struct TriggerWords {
69 /// A height that is not positive and finite.
70 height: &'static str,
71 /// A time before launch.
72 time: &'static str,
73 /// A motor that isn't there, for a delay.
74 delay_motor: &'static str,
75 /// A motor with no ejection delay in seconds.
76 no_delay: &'static str,
77 /// A motor with no ignition known before the flight.
78 never: &'static str,
79}
80
81/// A mass shift's words.
82const SHIFT_WORDS: TriggerWords = TriggerWords {
83 height: "height above the launch site at which a part starts to move, m",
84 time: "start time of a mass shift after launch, s",
85 delay_motor: "index of the motor whose delay starts a mass shift",
86 no_delay: "the motor whose delay starts a mass shift has no ejection delay in seconds (it is \
87 plugged, or its delay is unset)",
88 never: "index of the motor a mass shift is timed from (it has no ignition time before the \
89 flight, so the shift could never start)",
90};
91
92/// A mass release's words.
93const RELEASE_WORDS: TriggerWords = TriggerWords {
94 height: "height above the launch site at which a part is released, m",
95 time: "time of a mass release after launch, s",
96 delay_motor: "index of the motor whose delay releases a part",
97 no_delay: "the motor whose delay releases a part has no ejection delay in seconds (it is \
98 plugged, or its delay is unset)",
99 never: "index of the motor a mass release is timed from (it has no ignition time before the \
100 flight, so the part could never be released)",
101};
102
103/// How a flight is integrated and when it gives up.
104#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
105#[serde(default, deny_unknown_fields)]
106pub struct FlightSettings {
107 /// The integration method and tolerances.
108 pub method: Method,
109 /// The flight stops with [`Termination::TimeCap`] at this time after launch, s.
110 pub max_time_s: f64,
111 /// The flight stops with [`Termination::StepLimit`] after this many attempted steps.
112 pub step_limit: u64,
113 /// Fly a design whose checks report errors.
114 pub accept_design_errors: bool,
115}
116
117impl Default for FlightSettings {
118 /// Dormand–Prince 5(4) with `rtol = atol = 1e-8` and unit weights (1.1 ms and an apogee
119 /// converged to 3e-5 m for a Level 2 flight, `docs/physics/flight.md`), a one-hour time cap, a
120 /// limit of 10⁶ steps, and design errors refused.
121 fn default() -> Self {
122 Self {
123 method: Method::DormandPrince54(Adaptive::default()),
124 max_time_s: 3600.0,
125 step_limit: crate::integrator::DEFAULT_STEP_LIMIT,
126 accept_design_errors: false,
127 }
128 }
129}
130
131/// What happened.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134#[non_exhaustive]
135pub enum EventKind {
136 /// The force along the rail first exceeded friction.
137 Liftoff,
138 /// The last rail guide left the top of the rail.
139 RailExit,
140 /// Every motor lit, or due to light at a known time, has burned out: the last motor's thrust
141 /// curve ended. It is recorded again if a motor lights afterwards, as a sustainer lit by its
142 /// separation does.
143 Burnout,
144 /// The center of mass's height rate crossed zero from above.
145 Apogee,
146 /// The center of mass reached the launch site's ellipsoidal height, descending.
147 GroundHit,
148 /// A recovery device's charge fired, by its index in the flight's devices.
149 Trigger(usize),
150 /// A recovery device deployed (line stretch), its lag after the trigger, by its index. The
151 /// first deployment starts the descent phase. A device that was released before its charge
152 /// fired never deploys.
153 Deployment(usize),
154 /// A recovery device was released, by its index: the device that releases it is fully open
155 /// from this instant, so the drag area never dips between them.
156 Release(usize),
157 /// The stack came apart at its separation's stage boundary; the descents of the bodies that
158 /// don't fly on are in [`FlightResult::bodies`].
159 Separation,
160 /// A piece left the airframe at an ejection, by its index in the flight's ejections
161 /// ([`crate::Ejection`]); the bodies it leaves are in [`FlightResult::bodies`].
162 Ejection(usize),
163 /// A part started to move along the airframe, by its index in the flight's mass shifts
164 /// ([`crate::MassShift`]).
165 Shift(usize),
166 /// A part left the airframe, by its index in the flight's mass releases
167 /// ([`crate::MassRelease`]); its own flight is in [`FlightResult::released`], and the event's
168 /// sample is the rocket just before it left.
169 MassRelease(usize),
170 /// A user event, by its index in the order added.
171 User(usize),
172 /// A motor lit after launch, by its index in [`hpr_design::Assembly::motors`].
173 Ignition(usize),
174}
175
176/// An event and the flight's sample at it.
177#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
178pub struct FlightEvent {
179 /// What happened.
180 pub kind: EventKind,
181 /// The flight at that instant.
182 pub sample: Sample,
183}
184
185/// Why a flight ended ([Loft lesson L25][l25]: every way of stopping is named).
186///
187/// [l25]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l25
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum Termination {
192 /// The center of mass reached the ground.
193 GroundHit,
194 /// Every motor burned out before the rocket lifted off.
195 NoLiftoff,
196 /// The rocket lifted off but stopped on the rail and every motor has burned out.
197 StalledOnRail,
198 /// The time cap was reached.
199 TimeCap,
200 /// The integrator's step limit was reached.
201 StepLimit,
202 /// The stack separated, and each body flew on as its own descent
203 /// ([`FlightResult::bodies`]).
204 Separated,
205}
206
207/// A finished flight.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct FlightResult {
210 /// Why it ended.
211 pub termination: Termination,
212 /// Its events, in order.
213 pub events: Vec<FlightEvent>,
214 /// The flight where it ended. After a [`Termination::Separated`] this is the **stack** at the
215 /// separation, not a landing: the landings are in `bodies` (see [`Self::landings`]). After a
216 /// powered separation it is the sustainer's.
217 pub final_sample: Sample,
218 /// The integrator's work.
219 pub stats: Stats,
220 /// The descents of the separated bodies, in body order: every body that flew on its own when
221 /// the flight ended with [`Termination::Separated`] (pieces whose parting never fired land as
222 /// one body), the booster alone after a powered separation (the sustainer's
223 /// flight is the rest of this result), and none otherwise.
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub bodies: Vec<BodyFlight>,
226 /// The flights of the parts released in flight ([`crate::MassRelease`]), in the order they
227 /// left; none without releases. They are not among [`Self::bodies`] or [`Self::landings`].
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub released: Vec<ReleasedFlight>,
230}
231
232impl FlightResult {
233 /// Whether every separated body in [`Self::bodies`] landed. A flight that ends with
234 /// [`Termination::Separated`] says only that the stack came apart: each body's own
235 /// [`BodyFlight::termination`] says whether it reached the ground, and a body can run out of
236 /// time or steps on its own. After a powered separation the sustainer's own landing is
237 /// [`Self::termination`].
238 #[must_use]
239 pub fn bodies_landed(&self) -> bool {
240 !self.bodies.is_empty()
241 && self
242 .bodies
243 .iter()
244 .all(|body| body.termination == Termination::GroundHit)
245 }
246
247 /// Where the flight put things on the ground: the final sample's position when it (or after a
248 /// powered separation, the sustainer) landed, then each separated body's landing.
249 #[must_use]
250 pub fn landings(&self) -> Vec<BodySample> {
251 let bodies = self
252 .bodies
253 .iter()
254 .filter(|body| body.termination == Termination::GroundHit)
255 .map(|body| body.final_sample);
256 if self.termination == Termination::GroundHit {
257 std::iter::once(BodySample {
258 time_s: self.final_sample.time_s,
259 cg_enu_m: self.final_sample.cg_enu_m,
260 cg_velocity_enu_m_s: self.final_sample.cg_velocity_enu_m_s,
261 height_above_ground_m: self.final_sample.height_above_ground_m,
262 vertical_speed_m_s: self.final_sample.vertical_speed_m_s,
263 airspeed_m_s: self.final_sample.airspeed_m_s,
264 recovery_drag_area_m2: self.final_sample.recovery_drag_area_m2,
265 mass_kg: self.final_sample.mass_kg,
266 })
267 .chain(bodies)
268 .collect()
269 } else {
270 bodies.collect()
271 }
272 }
273
274 /// The first event of `kind`.
275 #[must_use]
276 pub fn event(&self, kind: EventKind) -> Option<&FlightEvent> {
277 self.events.iter().find(|event| event.kind == kind)
278 }
279}
280
281/// A user event: `function(sample)` crossing zero in `direction` during free flight. It is
282/// recorded and the flight continues.
283pub struct UserEvent {
284 /// A name for reports.
285 pub name: String,
286 /// The crossing direction.
287 pub direction: Direction,
288 /// The event function.
289 pub function: Box<dyn Fn(&Sample) -> f64 + Send + Sync>,
290}
291
292impl fmt::Debug for UserEvent {
293 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294 f.debug_struct("UserEvent")
295 .field("name", &self.name)
296 .field("direction", &self.direction)
297 .finish_non_exhaustive()
298 }
299}
300
301/// A rocket, its surroundings, its rail and its settings, ready to fly.
302#[derive(Debug)]
303pub struct Simulation {
304 vehicle: Vehicle,
305 environment: Environment,
306 rail: Rail,
307 guides: Guides,
308 settings: FlightSettings,
309 user_events: Vec<UserEvent>,
310 devices: Vec<Device>,
311 /// The trigger times known before the flight, one per device: a time after launch or a
312 /// motor's delay after its burnout, and `None` for the triggers the flight watches for.
313 trigger_times_s: Vec<Option<f64>>,
314 /// The separations, in the order they fire, each further forward than the one before.
315 separations: Vec<Separation>,
316 /// Each separation's trigger time, when it is one that is known before the flight.
317 separation_times_s: Vec<Option<f64>>,
318 /// The pieces that leave the airframe, and when.
319 ejections: Vec<Ejection>,
320 /// Each ejection's trigger time, when it is one that is known before the flight.
321 ejection_times_s: Vec<Option<f64>>,
322 /// The parts that move along the airframe, in the order given.
323 shifts: Vec<MassShift>,
324 /// The parts released in flight, in the order given.
325 releases: Vec<MassRelease>,
326 /// Each released part as the design places it.
327 release_parts: Releases,
328 /// Each release's trigger time, when it is one that is known before the flight.
329 release_times_s: Vec<Option<f64>>,
330 /// The design, kept to build a sustainer's models from at a powered separation.
331 rocket: Rocket,
332 /// The configuration flown.
333 configuration_id: String,
334 /// Whether the aerodynamics were replaced by a table or a drag model, which is the whole
335 /// stack's only.
336 aero_overridden: bool,
337 /// Whether the recovery charges are held: no device on the stack fires, whatever its trigger
338 /// ([`crate::metrics::optimum_delays`] flies the ascent so).
339 recovery_held: bool,
340}
341
342impl Simulation {
343 /// Assembles `rocket` in configuration `configuration_id`, runs its checks and builds its
344 /// aerodynamic model.
345 ///
346 /// # Errors
347 ///
348 /// [`SimError::DesignChecks`] if the checks report errors and the settings don't accept them;
349 /// errors assembling the design or building its aerodynamics; a bad rail, rail geometry or
350 /// time cap.
351 pub fn new(
352 rocket: &Rocket,
353 configuration_id: &str,
354 environment: Environment,
355 rail: Rail,
356 settings: FlightSettings,
357 ) -> Result<Self, SimError> {
358 Self::from_laid_out(
359 rocket.lay_out()?,
360 configuration_id,
361 environment,
362 rail,
363 settings,
364 )
365 }
366
367 /// As [`Simulation::new`], on a design laid out already ([`hpr_design::LaidOut`]). The checks
368 /// and the assembly share its layout, which is most of the cost of building a simulation; a
369 /// Monte Carlo run lays out each flight from the nominal one's
370 /// ([`hpr_design::LaidOut::relay`]).
371 ///
372 /// # Errors
373 ///
374 /// As [`Simulation::new`].
375 pub fn from_laid_out(
376 laid_out: LaidOut,
377 configuration_id: &str,
378 environment: Environment,
379 rail: Rail,
380 settings: FlightSettings,
381 ) -> Result<Self, SimError> {
382 let findings = laid_out.check()?;
383 if has_errors(&findings) && !settings.accept_design_errors {
384 return Err(SimError::DesignChecks(findings));
385 }
386 settings.method.validate()?;
387 if !(settings.max_time_s.is_finite() && settings.max_time_s > 0.0) {
388 return Err(SimError::Domain {
389 what: "time cap",
390 value: settings.max_time_s,
391 });
392 }
393 rail.validate()?;
394 let (rocket, assembly) = laid_out.into_assembly(configuration_id)?;
395 let aero = AeroModel::new(&assembly.layout)?;
396 // No separation yet, so a motor lit by one has no time.
397 let ignition_s = assembly.ignition_times_s(|_| None);
398 let guides = Guides::of(&assembly);
399 let travel = guides.exit_travel_m(rail.length_m);
400 if travel <= 0.0 {
401 return Err(SimError::Domain {
402 what: "rail travel to exit (the rail is shorter than the aft end's lead on the last guide)",
403 value: travel,
404 });
405 }
406 Ok(Self {
407 vehicle: Vehicle::lit(assembly, aero, ignition_s)?,
408 environment,
409 rail,
410 guides,
411 settings,
412 user_events: Vec::new(),
413 devices: Vec::new(),
414 trigger_times_s: Vec::new(),
415 separations: Vec::new(),
416 separation_times_s: Vec::new(),
417 ejections: Vec::new(),
418 ejection_times_s: Vec::new(),
419 shifts: Vec::new(),
420 releases: Vec::new(),
421 release_parts: Releases::default(),
422 release_times_s: Vec::new(),
423 rocket,
424 configuration_id: configuration_id.to_owned(),
425 aero_overridden: false,
426 recovery_held: false,
427 })
428 }
429
430 /// Flies another tool's `C_D0(M)` table instead of the drag buildup, and instead of any drag
431 /// model ([`Simulation::with_drag_model`]). A negative coefficient where the flight meets one
432 /// stops it with [`hpr_aero::AeroError::Domain`].
433 #[must_use]
434 pub fn with_drag_table(mut self, table: DragTable) -> Self {
435 self.vehicle.aero = self.vehicle.aero.clone().with_drag_table(table);
436 self.aero_overridden = true;
437 self
438 }
439
440 /// Flies a drag model of your own instead of the drag buildup, and instead of any drag table
441 /// ([`hpr_aero::custom`]). The model gives the zero-lift drag coefficient on the rocket's
442 /// reference area, not rescaled; the flight scales it for the angle of attack, and the
443 /// normal force, center of pressure, roll and damping stay hpr's. A model's own errors reach
444 /// the caller as [`SimError::Aero`] around [`hpr_aero::AeroError::DragModel`]. Like a table,
445 /// the model is the whole stack's: a flight with a powered separation refuses it at the
446 /// separation, since the sustainer would fly on without it.
447 ///
448 /// # Examples
449 ///
450 /// Valetudo with a drag coefficient of 0.45 at every speed:
451 ///
452 /// ```
453 /// use hpr_aero::{AeroError, DragModel, DragQuery};
454 /// use hpr_core::geodesy::Geodetic;
455 /// use hpr_design::Rocket;
456 /// use hpr_sim::{Environment, EventKind, FlightSettings, Rail, Simulation};
457 ///
458 /// #[derive(Debug)]
459 /// struct Constant(f64);
460 ///
461 /// impl DragModel for Constant {
462 /// fn zero_lift_drag(&self, _query: &DragQuery<'_>) -> Result<f64, AeroError> {
463 /// Ok(self.0)
464 /// }
465 /// }
466 ///
467 /// let rocket: Rocket = serde_json::from_str(include_str!(
468 /// "../../../validation/designs/rocketpy-valetudo.json"
469 /// ))?;
470 /// let site = Geodetic::from_degrees(32.99, -106.97, 1400.0)?;
471 /// // The apogee's height with a constant drag coefficient `cd`.
472 /// let apogee_m = |cd: f64| -> Result<f64, Box<dyn std::error::Error>> {
473 /// let flight = Simulation::new(
474 /// &rocket,
475 /// "example",
476 /// Environment::standard(site)?,
477 /// Rail::vertical(3.0),
478 /// FlightSettings::default(),
479 /// )?
480 /// .with_drag_model(Constant(cd))
481 /// .run(&mut ())?;
482 /// let apogee = flight.event(EventKind::Apogee).ok_or("no apogee")?;
483 /// Ok(apogee.sample.cg_enu_m.z)
484 /// };
485 /// assert!(apogee_m(0.9)? < apogee_m(0.45)?);
486 /// # Ok::<(), Box<dyn std::error::Error>>(())
487 /// ```
488 #[must_use]
489 pub fn with_drag_model(self, model: impl DragModel + 'static) -> Self {
490 self.with_shared_drag_model(Arc::new(model))
491 }
492
493 /// As [`Simulation::with_drag_model`], with a model already shared, as when one model flies
494 /// many simulations.
495 #[must_use]
496 pub fn with_shared_drag_model(mut self, model: Arc<dyn DragModel>) -> Self {
497 self.vehicle.aero = self.vehicle.aero.clone().with_shared_drag_model(model);
498 self.aero_overridden = true;
499 self
500 }
501
502 /// Multiplies the rocket's zero-lift drag coefficient by `scale`, whatever gives it: hpr's
503 /// buildup, a drag table or a drag model ([`hpr_aero::AeroModel::with_drag_scale`]). Unlike
504 /// a table or a model it is not the whole stack's: a sustainer lit at a powered separation
505 /// keeps the same scale. Recovery devices' drag is their own and is not scaled. A Monte Carlo
506 /// run disperses drag this way (`hpr_analysis::montecarlo`).
507 ///
508 /// # Errors
509 ///
510 /// [`SimError::Aero`] for a scale that is negative or not finite.
511 pub fn with_drag_scale(mut self, scale: f64) -> Result<Self, SimError> {
512 self.vehicle.aero = self.vehicle.aero.clone().with_drag_scale(scale)?;
513 Ok(self)
514 }
515
516 /// Keeps the aft base's whole drag while a motor burns, as OpenRocket 24.12 does, instead of
517 /// taking the burning motor's cross-section off it
518 /// ([`hpr_aero::AeroModel::with_full_base_drag_under_power`]). A sustainer lit at a powered
519 /// separation keeps the same rule. For sizing a difference from OpenRocket, not a better model
520 /// ([ADR-097][adr-097]).
521 ///
522 /// [adr-097]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-097-a-cause-in-the-drag-sized-by-hpr-flying-openrockets-drag-2026-09-28
523 #[must_use]
524 pub fn with_full_base_drag_under_power(mut self) -> Self {
525 self.vehicle.aero = self.vehicle.aero.clone().with_full_base_drag_under_power();
526 self
527 }
528
529 /// Flies another tool's normal force and center of pressure, against Mach number and angle of
530 /// attack, instead of hpr's own ([`hpr_aero::NormalForceTable`], read from a RASAero II
531 /// export). The table sets the static normal force at the center of mass's airflow; the pitch
532 /// and yaw damping stay hpr's, from the airspeed the rotation adds at each component, since a
533 /// table has none (the decision record on normal-force overrides, [ADR-032][adr-032]). The
534 /// flight still refuses Mach 5 and faster, where hpr's components, which give that damping,
535 /// end.
536 ///
537 /// [adr-032]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-032-normal-force-overrides-from-rasaero-ii-the-static-force-replaced-hprs-damping-kept-2026-09-19
538 ///
539 /// # Errors
540 ///
541 /// [`SimError::Aero`] around [`hpr_aero::AeroError::Domain`] for a center of pressure in the
542 /// table outside the rocket ([`hpr_aero::AeroModel::with_normal_force_table`]).
543 ///
544 /// # Examples
545 ///
546 /// Valetudo from a 3 m rail on a small export with invented numbers: 9 per radian, and the
547 /// center of pressure 55 inches from the nose tip.
548 ///
549 /// ```
550 /// use hpr_aero::NormalForceTable;
551 /// use hpr_core::geodesy::Geodetic;
552 /// use hpr_design::Rocket;
553 /// use hpr_sim::{Environment, EventKind, FlightSettings, Rail, Simulation};
554 ///
555 /// let rocket: Rocket = serde_json::from_str(include_str!(
556 /// "../../../validation/designs/rocketpy-valetudo.json"
557 /// ))?;
558 /// // The text of a RASAero II export; a program would read it from the file.
559 /// let export = "Mach,Alpha,CN,CN Potential,CP\n\
560 /// 0,0,0,0,55\n\
561 /// 1,0,0,0,55\n\
562 /// 0,2,0.314159,0.314159,55\n\
563 /// 1,2,0.314159,0.314159,55\n";
564 /// // On RASAero II's reference, the body's largest section, which hpr rescales to the
565 /// // rocket's reference area.
566 /// let table = NormalForceTable::from_rasaero_csv(export)?;
567 /// let site = Geodetic::from_degrees(32.99, -106.97, 1400.0)?;
568 /// let simulation = Simulation::new(
569 /// &rocket,
570 /// "example",
571 /// Environment::standard(site)?,
572 /// Rail::vertical(3.0),
573 /// FlightSettings::default(),
574 /// )?
575 /// .with_normal_force_table(table)?;
576 /// let flight = simulation.run(&mut ())?;
577 /// assert!(flight.event(EventKind::Apogee).is_some());
578 /// # Ok::<(), Box<dyn std::error::Error>>(())
579 /// ```
580 pub fn with_normal_force_table(mut self, table: NormalForceTable) -> Result<Self, SimError> {
581 self.vehicle.aero = self.vehicle.aero.clone().with_normal_force_table(table)?;
582 self.aero_overridden = true;
583 Ok(self)
584 }
585
586 /// Adds a user event, checked during free flight and the descent.
587 #[must_use]
588 pub fn with_event(mut self, event: UserEvent) -> Self {
589 self.user_events.push(event);
590 self
591 }
592
593 /// Flies with these recovery devices, in the order given: a device's index in this list names
594 /// it in [`EventKind`] and in [`crate::recovery::Device::released_by`].
595 ///
596 /// # Errors
597 ///
598 /// [`SimError::Domain`] for a device whose drag area, lag, inflation, trigger or release index
599 /// is outside its domain, or whose trigger names a motor that isn't there or has no ejection
600 /// delay in seconds.
601 pub fn with_recovery(mut self, devices: Vec<Device>) -> Result<Self, SimError> {
602 self.trigger_times_s = recovery::plan(
603 &devices,
604 &self.vehicle.assembly.motors,
605 self.vehicle.ignition_s(),
606 )?;
607 if self.parts() {
608 // Already given a separation or ejections, so the bodies are known; otherwise the
609 // check waits for them, and for the flight, so that the builders work in any order.
610 check_bodies(&devices, self.body_count(), false)?;
611 }
612 self.devices = devices;
613 Ok(self)
614 }
615
616 /// The recovery devices.
617 #[must_use]
618 pub fn recovery(&self) -> &[Device] {
619 &self.devices
620 }
621
622 /// Flies with a separation: at its trigger the stack comes apart at the stage boundary, and
623 /// each body descends under its own devices ([`crate::recovery::Separation`]); or, when the
624 /// nose's body still has a motor to burn, it flies on as a sustainer and the aft body
625 /// descends. The same as [`Self::with_separations`] with this one alone.
626 ///
627 /// Call this after [`Self::with_recovery`]: it checks the devices against the bodies.
628 ///
629 /// # Errors
630 ///
631 /// [`SimError::Domain`] if the design has no stage aft of the split, if a body carries no
632 /// device (the descent has no airframe drag, so it would fall as if in a vacuum), if the
633 /// trigger is out of its domain, or if its time is known and an aft body's motor burns past
634 /// it (but for a lit motor a powered separation drops burning when it says so,
635 /// [`Separation::drops_burning`]), or if it is timed from a motor with no ignition known
636 /// before the flight, so that it could never fire; [`SimError::Parting`] if an ejection
637 /// already given parts at its stage boundary. The same checks run again if [`Self::with_recovery`] is called afterwards, so the
638 /// builders can be given in any order. A device on a body that nothing makes is refused when
639 /// the flight starts, since an ejection given later can make it.
640 pub fn with_separation(self, separation: Separation) -> Result<Self, SimError> {
641 self.with_separations(vec![separation])
642 }
643
644 /// Flies with several separations, in the order they fire, each at a stage boundary further
645 /// forward than the one before: a stack that drops its stages one at a time under power, as
646 /// a three-stage rocket does. Separation `k` makes body `k + 1`, the stages between its
647 /// boundary and the one before it (or the tail); body 0 keeps the nose. With more than one,
648 /// each must leave the nose's body a motor to burn, so that it flies on as a sustainer and the
649 /// part behind descends ([`crate::recovery::Separation`]); a lone separation may also end the
650 /// ascent, as [`Self::with_separation`] says. An empty list flies none.
651 ///
652 /// Call this after [`Self::with_recovery`]: it checks the devices against the bodies.
653 ///
654 /// # Errors
655 ///
656 /// As [`Self::with_separation`], for each separation; [`SimError::Domain`] as well if the
657 /// boundaries don't move forward in the order given, or if two of the times known before the
658 /// flight come in the other order; [`SimError::Unsupported`] for more than one separation in
659 /// a flight with ejections, whose pieces a sustainer's cut design doesn't track. In flight, a
660 /// separation that leaves nothing ahead of it to burn, in a flight with more than one, is an
661 /// error, as is one that fires before the separation ahead of it in the list.
662 pub fn with_separations(mut self, separations: Vec<Separation>) -> Result<Self, SimError> {
663 if !separations.is_empty() {
664 self.check_no_moving_parts()?;
665 }
666 let stages = self.vehicle.assembly.layout.stages.len();
667 for (index, separation) in separations.iter().enumerate() {
668 if separation.stages_of(1, stages).is_none() {
669 return Err(SimError::Domain {
670 what: "stage boundary of a separation (there is no stage aft of it)",
671 value: separation.after_stage as f64,
672 });
673 }
674 if index > 0 && separation.after_stage >= separations[index - 1].after_stage {
675 return Err(SimError::Domain {
676 what: "stage boundary of a separation (each must be forward of the one \
677 before it, as the stack drops its stages from the tail)",
678 value: separation.after_stage as f64,
679 });
680 }
681 let dropped = Separation::stages_of_body(&separations[..=index], index + 1, stages);
682 if !dropped.is_some_and(|(first, last)| {
683 hangs_together(&self.vehicle.assembly.layout.stages, first, last)
684 }) {
685 return Err(SimError::Domain {
686 what: "stage boundary of a separation (what it drops must hang together: a \
687 parallel stage hung on a stage it keeps can't go with a stage behind)",
688 value: separation.after_stage as f64,
689 });
690 }
691 }
692 if separations.len() > 1 && !self.ejections.is_empty() {
693 return Err(Self::separations_and_ejections());
694 }
695 if !self.ejections.is_empty() {
696 // Its boundary may be a joint an ejection parts at too.
697 Pieces::new(
698 &self.rocket,
699 &self.vehicle.assembly,
700 &separations,
701 &self.ejections,
702 )?;
703 }
704 if !self.devices.is_empty() && !separations.is_empty() {
705 // With the devices already given they are checked now; given afterwards they are
706 // checked then, and either way again when the flight starts.
707 check_bodies(
708 &self.devices,
709 1 + separations.len() + self.ejections.len(),
710 false,
711 )?;
712 }
713 let mut times_s = Vec::with_capacity(separations.len());
714 for &separation in &separations {
715 if let Trigger::Altitude {
716 height_above_ground_m,
717 } = separation.trigger
718 && !(height_above_ground_m.is_finite() && height_above_ground_m > 0.0)
719 {
720 return Err(SimError::Domain {
721 what: "height above the launch site at which the stack separates, m",
722 value: height_above_ground_m,
723 });
724 }
725 let time_s = recovery::trigger_time_s(
726 separation.trigger,
727 &self.vehicle.assembly.motors,
728 self.vehicle.ignition_s(),
729 )?;
730 if let Some(time_s) = time_s {
731 // A body's mass is held constant through its descent. For a trigger whose time is
732 // known now, say so now rather than in the middle of a flight.
733 let powered = separation.powered_at(&self.vehicle.assembly, time_s);
734 self.check_aft_body_spent(separation, self.vehicle.ignition_s(), time_s, powered)?;
735 }
736 if let (None, Trigger::MotorDelay { motor } | Trigger::Burnout { motor, .. }) =
737 (time_s, separation.trigger)
738 {
739 // Timed from a motor with no ignition known before the flight: one lit by a
740 // separation, or one that never lights. It would never fire, and the stack would
741 // land whole. (A `Time` always has its time.)
742 return Err(SimError::Domain {
743 what: "index of the motor a separation is timed from (it has no ignition time \
744 before the separation, so the separation could never fire)",
745 value: motor as f64,
746 });
747 }
748 if let (Some(time_s), Some(before_s)) = (
749 time_s,
750 times_s.iter().rev().find_map(|time: &Option<f64>| *time),
751 ) && time_s < before_s
752 {
753 return Err(SimError::Domain {
754 what: "time of a separation, s (it comes before the separation ahead of it \
755 in the list, which drops the stages behind it)",
756 value: time_s,
757 });
758 }
759 times_s.push(time_s);
760 }
761 self.separation_times_s = times_s;
762 self.separations = separations;
763 Ok(self)
764 }
765
766 /// Flies with ejections: at each one's trigger a piece leaves the airframe, at the joint aft of
767 /// a body component or as a payload from inside it, and each body flies on to its own landing
768 /// under its own devices ([`crate::Ejection`]). With a separation as well, the separation's aft
769 /// body is body 1 and ejection `k` makes body `k + 2`; without one, ejection `k` makes body
770 /// `k + 1`. A flight with more than one separation takes none.
771 ///
772 /// Call this after [`Self::with_recovery`]: it checks the devices against the bodies. The
773 /// builders can be given in any order, and the checks run again when the flight starts.
774 ///
775 /// # Errors
776 ///
777 /// [`SimError::Parting`] for a parting the design can't make; [`SimError::Domain`] if a body
778 /// carries no device, if a trigger or impulse is out of its domain, if its time is known and a
779 /// motor burns past it, or if it is timed from a motor with no ignition known before the
780 /// flight; [`SimError::Unsupported`] with more than one separation. A device on a body that
781 /// nothing makes, and a pushed payload in the nose's piece, are
782 /// refused when the flight starts. In flight, an ejection that fires while a motor burns, or
783 /// ahead of a separation that would light one, is an error, and so are a powered separation in
784 /// a flight with ejections and a pushed payload whose section's forward joint hasn't parted.
785 pub fn with_ejections(mut self, ejections: Vec<Ejection>) -> Result<Self, SimError> {
786 if !ejections.is_empty() {
787 self.check_no_moving_parts()?;
788 if self.separations.len() > 1 {
789 return Err(Self::separations_and_ejections());
790 }
791 }
792 Pieces::new(
793 &self.rocket,
794 &self.vehicle.assembly,
795 &self.separations,
796 &ejections,
797 )?;
798 let bodies = 1 + self.separations.len() + ejections.len();
799 if !self.devices.is_empty() && !ejections.is_empty() {
800 check_bodies(&self.devices, bodies, false)?;
801 }
802 let mut times_s = Vec::with_capacity(ejections.len());
803 for ejection in &ejections {
804 if let Trigger::Altitude {
805 height_above_ground_m,
806 } = ejection.trigger
807 && !(height_above_ground_m.is_finite() && height_above_ground_m > 0.0)
808 {
809 return Err(SimError::Domain {
810 what: "height above the launch site at which a piece is ejected, m",
811 value: height_above_ground_m,
812 });
813 }
814 if !(ejection.impulse_n_s.is_finite() && ejection.impulse_n_s >= 0.0) {
815 return Err(SimError::Domain {
816 what: "impulse of an ejection, N·s (zero or more: it pushes the two sides \
817 apart)",
818 value: ejection.impulse_n_s,
819 });
820 }
821 let time_s = recovery::trigger_time_s(
822 ejection.trigger,
823 &self.vehicle.assembly.motors,
824 self.vehicle.ignition_s(),
825 )?;
826 if let Some(time_s) = time_s {
827 // Every body is a point mass of constant mass once the airframe parts.
828 self.check_spent(self.vehicle.ignition_s(), time_s)?;
829 }
830 if let (None, Trigger::MotorDelay { motor } | Trigger::Burnout { motor, .. }) =
831 (time_s, ejection.trigger)
832 {
833 return Err(SimError::Domain {
834 what: "index of the motor an ejection is timed from (it has no ignition time \
835 before the flight, so the ejection could never fire)",
836 value: motor as f64,
837 });
838 }
839 times_s.push(time_s);
840 }
841 self.ejections = ejections;
842 self.ejection_times_s = times_s;
843 Ok(self)
844 }
845
846 /// The ejections, in the order given.
847 #[must_use]
848 pub fn ejections(&self) -> &[Ejection] {
849 &self.ejections
850 }
851
852 /// Flies with parts that move along the airframe ([`MassShift`]), in the order given: a
853 /// shift's index in this list names it in [`EventKind::Shift`]. A shift with a trigger known
854 /// before the flight (a time, or a motor's burnout or delay) starts then; the flight watches
855 /// for the apogee and for a height, descending, as it does for a recovery device's.
856 ///
857 /// # Errors
858 ///
859 /// [`SimError::Shift`] for a part that can't move ([`MassShift`] says which);
860 /// [`SimError::Domain`] for a travel, duration or trigger outside its domain, or a trigger on
861 /// a motor with no ignition known before the flight, which could never fire (a shift that
862 /// starts before the rocket leaves the rail is refused by [`Self::run`] when it comes);
863 /// [`SimError::Unsupported`] with a separation or ejections, whose pieces are fixed before the
864 /// flight with every part where the design puts it.
865 pub fn with_shifts(mut self, shifts: Vec<MassShift>) -> Result<Self, SimError> {
866 if !shifts.is_empty() && self.parts() {
867 return Err(Self::shifts_and_partings());
868 }
869 if !shifts.is_empty() && !self.releases.is_empty() {
870 return Err(Self::releases_and_shifts());
871 }
872 let mut starts_s = Vec::with_capacity(shifts.len());
873 for shift in &shifts {
874 starts_s.push(self.planned_s(shift.trigger, &SHIFT_WORDS)?);
875 }
876 self.vehicle.shifts =
877 Shifts::new(&self.rocket, &self.vehicle.assembly, &shifts, &starts_s)?;
878 self.shifts = shifts;
879 Ok(self)
880 }
881
882 /// The mass shifts, in the order given.
883 #[must_use]
884 pub fn shifts(&self) -> &[MassShift] {
885 &self.shifts
886 }
887
888 /// Flies with parts released in flight ([`MassRelease`]), in the order given: a release's
889 /// index in this list names it in [`EventKind::MassRelease`] and in
890 /// [`FlightResult::released`]. A release with a trigger known before the flight (a time, or a
891 /// motor's burnout or delay) comes then; the flight watches for the apogee and for a height,
892 /// descending, as it does for a recovery device's.
893 ///
894 /// # Errors
895 ///
896 /// [`SimError::MassRelease`] for a part that can't be released ([`MassRelease`] says which);
897 /// [`SimError::Domain`] for a drag area or trigger outside its domain, or a trigger on a
898 /// motor with no ignition known before the flight, which could never fire (a release that
899 /// comes before the rocket leaves the rail is refused by [`Self::run`] when it comes);
900 /// [`SimError::Unsupported`] with a separation, ejections or mass shifts, whose parts are
901 /// fixed before the flight with every part where the design puts it.
902 pub fn with_releases(mut self, releases: Vec<MassRelease>) -> Result<Self, SimError> {
903 if !releases.is_empty() && (self.parts() || !self.shifts.is_empty()) {
904 return Err(Self::releases_and_shifts());
905 }
906 let mut times_s = Vec::with_capacity(releases.len());
907 for release in &releases {
908 times_s.push(self.planned_s(release.trigger, &RELEASE_WORDS)?);
909 }
910 self.release_parts = Releases::new(&self.rocket, &self.vehicle.assembly, &releases)?;
911 self.release_times_s = times_s;
912 self.releases = releases;
913 Ok(self)
914 }
915
916 /// The releases, in the order given.
917 #[must_use]
918 pub fn releases(&self) -> &[MassRelease] {
919 &self.releases
920 }
921
922 /// The time a shift's or a release's `trigger` gives before the flight, if it gives one,
923 /// refusing a trigger outside its domain in `words`. The checks are a device's; the errors
924 /// that name a device are put in `words`.
925 fn planned_s(&self, trigger: Trigger, words: &TriggerWords) -> Result<Option<f64>, SimError> {
926 if let Trigger::Altitude {
927 height_above_ground_m,
928 } = trigger
929 && !(height_above_ground_m.is_finite() && height_above_ground_m > 0.0)
930 {
931 return Err(SimError::Domain {
932 what: words.height,
933 value: height_above_ground_m,
934 });
935 }
936 let time_s = recovery::trigger_time_s(
937 trigger,
938 &self.vehicle.assembly.motors,
939 self.vehicle.ignition_s(),
940 )
941 .map_err(|error| match error {
942 SimError::Domain { what, value } => SimError::Domain {
943 what: match what {
944 "deployment time after launch, s" => words.time,
945 "index of the motor whose delay fires a device" => words.delay_motor,
946 "the motor firing a device has no ejection delay in seconds (it is plugged, \
947 or its delay is unset)" => words.no_delay,
948 other => other,
949 },
950 value,
951 },
952 other => other,
953 })?;
954 if let (None, Trigger::MotorDelay { motor } | Trigger::Burnout { motor, .. }) =
955 (time_s, trigger)
956 {
957 return Err(SimError::Domain {
958 what: words.never,
959 value: motor as f64,
960 });
961 }
962 Ok(time_s)
963 }
964
965 /// The stack's mass properties at `t_s` as `flight` flew it: the design's, its motors burned
966 /// to `t_s`, with each part that moves where it was then. `flight` must be a flight of this
967 /// simulation; nothing checks that it is. A shift whose trigger is known before the flight (a
968 /// time, or a motor's burnout or delay) starts then, whether or not `flight` got that far; one
969 /// the flight watched for starts where `flight` records it ([`EventKind::Shift`]), and hasn't
970 /// started if it doesn't. A part released at or before `t_s` is gone: its release came when
971 /// its trigger's time is known before the flight, and otherwise where `flight` records it
972 /// ([`EventKind::MassRelease`]). They are the whole stack's, in body axes about its center of
973 /// mass, before any separation (and a flight with a separation has no shifts or releases).
974 #[must_use]
975 pub fn mass_properties(&self, flight: &FlightResult, t_s: f64) -> hpr_design::MassProperties {
976 let mut shifts = self.vehicle.shifts.clone();
977 for index in 0..self.shifts.len() {
978 if let Some(event) = flight.event(EventKind::Shift(index)) {
979 shifts.start(index, event.sample.time_s);
980 }
981 }
982 let mut gone: Vec<(f64, usize)> = (0..self.releases.len())
983 .filter_map(|index| {
984 self.release_times_s[index]
985 .or_else(|| {
986 flight
987 .event(EventKind::MassRelease(index))
988 .map(|event| event.sample.time_s)
989 })
990 .filter(|&time_s| time_s <= t_s)
991 .map(|time_s| (time_s, index))
992 })
993 .collect();
994 // Taken out in the order the flight takes them, so the sums are the ones it flew.
995 gone.sort_by(|a, b| a.0.total_cmp(&b.0));
996 let assembly = &self.vehicle.assembly;
997 let structure = gone
998 .iter()
999 .fold(assembly.layout.structure, |structure, &(_, index)| {
1000 structure.without_part(self.release_parts.part(index))
1001 });
1002 let whole = assembly.mass_properties_lit_on(structure, t_s, self.vehicle.ignition_s());
1003 shifts.apply(whole, t_s)
1004 }
1005
1006 /// The stack's apogee at `sample`: its event, and the trigger of each of the stack's own
1007 /// devices waiting for it, whose deployment becomes a stop time (below `cap`).
1008 fn reach_apogee(
1009 &self,
1010 sample: Sample,
1011 run: &mut Run,
1012 (stops, cap): (&mut Vec<f64>, f64),
1013 events: &mut Vec<FlightEvent>,
1014 observer: &mut dyn Observer,
1015 ) {
1016 let mut record = |kind: EventKind| {
1017 let event = FlightEvent { kind, sample };
1018 observer.event(&event);
1019 events.push(event);
1020 };
1021 record(EventKind::Apogee);
1022 let t = sample.time_s;
1023 for device in 0..self.devices.len() {
1024 // Only the stack's own: a body's device waits for its body, which finds its own
1025 // apogee.
1026 if !self.recovery_held
1027 && self.devices[device].trigger == Trigger::Apogee
1028 && run.pending(device)
1029 && self.acts_before_separation(device)
1030 {
1031 let deploy_s = run.trigger(&self.devices, device, t);
1032 insert_stop(stops, deploy_s, cap);
1033 record(EventKind::Trigger(device));
1034 }
1035 }
1036 }
1037
1038 /// Refuses a separation or ejections in a flight with mass shifts or releases.
1039 fn check_no_moving_parts(&self) -> Result<(), SimError> {
1040 if !self.releases.is_empty() {
1041 Err(Self::releases_and_shifts())
1042 } else if self.shifts.is_empty() {
1043 Ok(())
1044 } else {
1045 Err(Self::shifts_and_partings())
1046 }
1047 }
1048
1049 /// The refusal of mass releases with a separation, ejections or mass shifts.
1050 fn releases_and_shifts() -> SimError {
1051 SimError::Unsupported {
1052 what: "a mass release in a flight with a separation, ejections or mass shifts (the \
1053 parts are fixed before the flight, with every part where the design puts it)",
1054 }
1055 }
1056
1057 /// The refusal of several separations with ejections.
1058 fn separations_and_ejections() -> SimError {
1059 SimError::Unsupported {
1060 what: "ejections in a flight with more than one separation (a sustainer flies on a cut \
1061 design whose components aren't the pieces the ejections were given for)",
1062 }
1063 }
1064
1065 /// The refusal of mass shifts with a separation or ejections.
1066 fn shifts_and_partings() -> SimError {
1067 SimError::Unsupported {
1068 what: "a mass shift in a flight with a separation or ejections (the pieces are fixed \
1069 before the flight, with every part where the design puts it)",
1070 }
1071 }
1072
1073 /// The drag area of piece `piece` tumbling on its own, for a device on the body it leads:
1074 /// [`crate::recovery::DeviceDrag::tumbling`]'s model over the piece's own body components and
1075 /// fin sets. Piece 0 is the nose's, the separation makes piece 1, and each ejection the next
1076 /// ([`crate::Ejection`]), so piece `k` leads body `k`. Call it after [`Self::with_ejections`]
1077 /// and [`Self::with_separation`], which fix the pieces.
1078 ///
1079 /// It is the piece's own area only: a body that still carries another section, until that
1080 /// section's own parting, tumbles with its lead piece's area. The model was fitted to whole
1081 /// model rockets tumbling, so a lone nose cone is outside its fit: see the recovery page's
1082 /// [tumble section](https://nrdptel.github.io/hpr-sim/physics/recovery.html#tumble).
1083 ///
1084 /// # Errors
1085 ///
1086 /// [`SimError::Domain`] for a piece the airframe doesn't part into, a payload (it has no
1087 /// body tube or fin of its own), and as [`crate::recovery::DeviceDrag::tumbling`].
1088 pub fn tumbling_piece(&self, piece: usize) -> Result<recovery::DeviceDrag, SimError> {
1089 Pieces::new(
1090 &self.rocket,
1091 &self.vehicle.assembly,
1092 &self.separations,
1093 &self.ejections,
1094 )?
1095 .tumbling(piece, &self.vehicle.assembly)
1096 }
1097
1098 /// Refuses an ejection at `t_s` while any motor lit at `ignition_s` burns: once the airframe
1099 /// parts, every body is a point mass of constant mass.
1100 fn check_spent(&self, ignition_s: &[Option<f64>], t_s: f64) -> Result<(), SimError> {
1101 for (placed, ignition) in self.vehicle.assembly.motors.iter().zip(ignition_s) {
1102 if let Some(ignition_s) = ignition {
1103 let burnout_s = ignition_s + placed.mounted.motor.burnout_time_s();
1104 if burnout_s > t_s {
1105 return Err(SimError::Domain {
1106 what: "time of an ejection (every motor must have burned out by then; \
1107 this is when one of them does)",
1108 value: burnout_s,
1109 });
1110 }
1111 }
1112 }
1113 Ok(())
1114 }
1115
1116 /// Refuses a separation at `t_s` while a motor of its aft body, lit at `ignition_s`, burns or
1117 /// is still to light: that body descends as a point mass of constant mass. A `powered`
1118 /// separation, whose nose body has a motor burning or lighting then, may drop a motor still
1119 /// burning when it says so ([`Separation::drops_burning`], ADR-172): the body's descent leaves
1120 /// out the thrust that motor had left ([`BodyFlight::impulse_left_n_s`]). A motor still to
1121 /// light is refused either way.
1122 fn check_aft_body_spent(
1123 &self,
1124 separation: Separation,
1125 ignition_s: &[Option<f64>],
1126 t_s: f64,
1127 powered: bool,
1128 ) -> Result<(), SimError> {
1129 for (placed, ignition) in self.vehicle.assembly.motors.iter().zip(ignition_s) {
1130 if placed.stage <= separation.after_stage {
1131 continue;
1132 }
1133 if let Some(ignition_s) = ignition {
1134 let burnout_s = ignition_s + placed.mounted.motor.burnout_time_s();
1135 let dropped_burning = separation.drops_burning && powered && *ignition_s < t_s;
1136 if burnout_s > t_s && !dropped_burning {
1137 return Err(SimError::Domain {
1138 what: "time of a separation (the aft body's motors must have burned out \
1139 by then; this is when one of them does)",
1140 value: burnout_s,
1141 });
1142 }
1143 }
1144 }
1145 Ok(())
1146 }
1147
1148 /// The first separation, if the flight has one.
1149 #[must_use]
1150 pub fn separation(&self) -> Option<Separation> {
1151 self.separations.first().copied()
1152 }
1153
1154 /// The separations, in the order they fire.
1155 #[must_use]
1156 pub fn separations(&self) -> &[Separation] {
1157 &self.separations
1158 }
1159
1160 /// The assembled design.
1161 #[must_use]
1162 pub fn assembly(&self) -> &hpr_design::Assembly {
1163 &self.vehicle.assembly
1164 }
1165
1166 /// The aerodynamic model.
1167 #[must_use]
1168 pub fn aero(&self) -> &AeroModel {
1169 &self.vehicle.aero
1170 }
1171
1172 /// Takes `other`'s supersonic table for this flight's aerodynamic model where the two would
1173 /// build the same table ([`AeroModel::share_supersonic_table`]), so that it is built once for
1174 /// both: for many flights of one airframe, as a Monte Carlo run's. `other` is usually
1175 /// another simulation's [`Simulation::aero`]. The flight is unchanged, bit for bit. A
1176 /// sustainer's model, built at a powered separation, still builds its own table every
1177 /// flight. Returns `true` if the table is now shared.
1178 pub fn share_supersonic_table(&mut self, other: &AeroModel) -> bool {
1179 self.vehicle.aero.share_supersonic_table(other)
1180 }
1181
1182 /// The rail guides.
1183 #[must_use]
1184 pub fn guides(&self) -> Guides {
1185 self.guides
1186 }
1187
1188 /// The rail.
1189 #[must_use]
1190 pub fn rail(&self) -> Rail {
1191 self.rail
1192 }
1193
1194 /// The environment.
1195 #[must_use]
1196 pub fn environment(&self) -> &Environment {
1197 &self.environment
1198 }
1199
1200 /// The settings.
1201 #[must_use]
1202 pub fn settings(&self) -> FlightSettings {
1203 self.settings
1204 }
1205
1206 /// The state at ignition: on the rail, aft end at its foot, at rest.
1207 #[must_use]
1208 pub fn initial_state(&self) -> State {
1209 let attitude = self.rail.attitude();
1210 State {
1211 position_enu_m: self.rail.direction_enu() * self.guides.aft_station_m,
1212 velocity_enu_m_s: DVec3::ZERO,
1213 attitude,
1214 body_rate_rad_s: DVec3::ZERO,
1215 }
1216 }
1217
1218 /// This simulation with its recovery charges held: the stack's devices never fire, so it
1219 /// coasts through its apogee as if every delay were long. A separation that lights a motor
1220 /// ahead of it still happens, and a separated body's devices act as they would; one that
1221 /// doesn't is held with the charges, and so is a mass shift or release fired by a motor's
1222 /// delay: the charge that would fire it doesn't. User events, which can't be copied, are left
1223 /// out.
1224 pub(crate) fn with_recovery_held(&self) -> Self {
1225 let on_charge = |trigger: Trigger| matches!(trigger, Trigger::MotorDelay { .. });
1226 let mut vehicle = self.vehicle.clone();
1227 for (index, shift) in self.shifts.iter().enumerate() {
1228 if on_charge(shift.trigger) {
1229 vehicle.shifts.hold(index);
1230 }
1231 }
1232 Self {
1233 vehicle,
1234 environment: self.environment.clone(),
1235 rail: self.rail,
1236 guides: self.guides,
1237 settings: self.settings,
1238 user_events: Vec::new(),
1239 devices: self.devices.clone(),
1240 // No charge fires, so none of their times is a stop.
1241 trigger_times_s: vec![None; self.devices.len()],
1242 separations: self.separations.clone(),
1243 separation_times_s: self.separation_times_s.clone(),
1244 ejections: self.ejections.clone(),
1245 ejection_times_s: self.ejection_times_s.clone(),
1246 shifts: self.shifts.clone(),
1247 releases: self.releases.clone(),
1248 release_parts: self.release_parts.clone(),
1249 release_times_s: self
1250 .releases
1251 .iter()
1252 .zip(&self.release_times_s)
1253 .map(|(release, time)| time.filter(|_| !on_charge(release.trigger)))
1254 .collect(),
1255 rocket: self.rocket.clone(),
1256 configuration_id: self.configuration_id.clone(),
1257 aero_overridden: self.aero_overridden,
1258 recovery_held: true,
1259 }
1260 }
1261
1262 /// Flies from ignition on the pad until the flight ends.
1263 ///
1264 /// # Errors
1265 ///
1266 /// [`SimError`] from the models or the integrator (other than the step limit, which is a
1267 /// [`Termination`]), or from the observer. The checks that wait for every builder run here
1268 /// too: a device on a body nothing makes, and a pushed payload in the nose's piece
1269 /// ([`Self::with_ejections`]). A mass shift that starts, or a mass release that comes, before
1270 /// the rocket leaves the rail is [`SimError::Domain`] ([`Self::with_shifts`],
1271 /// [`Self::with_releases`]), as is a release that steps the rest's center of mass below the
1272 /// ground while it climbs, and a separation or ejection whose time is known before the flight
1273 /// and comes before the rocket leaves the rail ([`Self::with_separations`],
1274 /// [`Self::with_ejections`]).
1275 pub fn run(&self, observer: &mut dyn Observer) -> Result<FlightResult, SimError> {
1276 self.fly(0.0, self.initial_state(), Phase::Pad, observer)
1277 }
1278
1279 /// Flies freely from `state` at `t0_s` seconds after launch, as after a rail exit or from a
1280 /// restart: the motors burn as their curves say at that time.
1281 ///
1282 /// # Errors
1283 ///
1284 /// As [`Self::run`].
1285 pub fn run_free(
1286 &self,
1287 t0_s: f64,
1288 state: State,
1289 observer: &mut dyn Observer,
1290 ) -> Result<FlightResult, SimError> {
1291 self.fly(t0_s, state, Phase::Free, observer)
1292 }
1293
1294 /// The sample at `(t, y)` in `phase` during the interval `window`, with `drag_area_m2` of
1295 /// recovery devices open.
1296 fn sample(
1297 &self,
1298 vehicle: &Vehicle,
1299 phase: Phase,
1300 window: (f64, f64),
1301 t: f64,
1302 y: &[f64; STATE_LEN],
1303 drag_area_m2: f64,
1304 ) -> Result<Sample, SimError> {
1305 let evaluation = self.evaluate(vehicle, phase, window, t, y, drag_area_m2)?;
1306 Ok(sample_of(phase, t, y, &evaluation))
1307 }
1308
1309 fn evaluate(
1310 &self,
1311 vehicle: &Vehicle,
1312 phase: Phase,
1313 window: (f64, f64),
1314 t: f64,
1315 y: &[f64; STATE_LEN],
1316 drag_area_m2: f64,
1317 ) -> Result<Evaluation, SimError> {
1318 vehicle.evaluate(
1319 &self.environment,
1320 self.rail.friction_coefficient,
1321 Conditions {
1322 phase,
1323 window,
1324 drag_area_m2,
1325 },
1326 t,
1327 y,
1328 )
1329 }
1330
1331 fn fly(
1332 &self,
1333 t0: f64,
1334 state: State,
1335 start_phase: Phase,
1336 observer: &mut dyn Observer,
1337 ) -> Result<FlightResult, SimError> {
1338 if !t0.is_finite() || t0 < 0.0 || t0 >= self.settings.max_time_s {
1339 return Err(SimError::Domain {
1340 what: "start time (must be from ignition and before the time cap)",
1341 value: t0,
1342 });
1343 }
1344 // The builders can be given in either order, and the last one wins, so the devices and
1345 // the bodies are checked against each other here as well.
1346 check_bodies(&self.devices, self.body_count(), true)?;
1347 if self
1348 .ejections
1349 .iter()
1350 .any(|ejection| ejection.impulse_n_s > 0.0)
1351 {
1352 Pieces::new(
1353 &self.rocket,
1354 &self.vehicle.assembly,
1355 &self.separations,
1356 &self.ejections,
1357 )?
1358 .check_pushed_payloads(&self.ejections)?;
1359 }
1360 // Which parts have left; one that left before the flight's start isn't recorded, and the
1361 // stack starts without it.
1362 let mut released: Vec<bool> = self
1363 .release_times_s
1364 .iter()
1365 .map(|time| time.is_some_and(|time_s| time_s < t0))
1366 .collect();
1367 // The stack once a part has left it.
1368 let mut lightened: Option<Vehicle> = released.contains(&true).then(|| {
1369 let mut stack = self.vehicle.clone();
1370 // In the order they left, as the flight would have taken them out.
1371 let mut gone: Vec<(f64, usize)> = (0..released.len())
1372 .filter_map(|index| Some((self.release_times_s[index]?, index)))
1373 .filter(|&(_, index)| released[index])
1374 .collect();
1375 gone.sort_by(|a, b| a.0.total_cmp(&b.0));
1376 for (_, index) in gone {
1377 lighten(&mut stack.assembly, self.release_parts.part(index));
1378 }
1379 stack
1380 });
1381 if start_phase == Phase::Free {
1382 let height = self
1383 .evaluate(
1384 lightened.as_ref().unwrap_or(&self.vehicle),
1385 Phase::Free,
1386 (t0, t0),
1387 t0,
1388 &state.to_array(),
1389 0.0,
1390 )?
1391 .height_above_ground_m;
1392 if height <= 0.0 {
1393 return Err(SimError::Domain {
1394 what: "starting height of the center of mass above the ground",
1395 value: height,
1396 });
1397 }
1398 }
1399 let mut integrator = Integrator::new(self.settings.method, t0, state.to_array())?
1400 .with_step_limit(self.settings.step_limit);
1401 let cap = self.settings.max_time_s;
1402 // What a powered separation changes: the trigger and ignition times that count from it,
1403 // the vehicle flying, and the booster's descent.
1404 let mut trigger_times_s = self.trigger_times_s.clone();
1405 let mut ignition_s = self.vehicle.ignition_s().to_vec();
1406 let mut ignited: Vec<bool> = ignition_s
1407 .iter()
1408 .map(|ignition| ignition.is_some_and(|time| time <= t0))
1409 .collect();
1410 let mut sustainer: Option<Vehicle> = None;
1411 // The stack once a shift the flight watched for has started, with its start set.
1412 let mut shifted: Option<Vehicle> = None;
1413 // Which shifts have started; one that started before the flight's start isn't recorded.
1414 let mut shift_started: Vec<bool> = (0..self.shifts.len())
1415 .map(|index| {
1416 self.vehicle
1417 .shifts
1418 .start_s(index)
1419 .is_some_and(|start_s| start_s < t0)
1420 })
1421 .collect();
1422 let mut released_flights: Vec<ReleasedFlight> = Vec::new();
1423 // How many separations have fired under power, so the next to come is
1424 // `separations[staged]`, and when each did.
1425 let mut staged = 0;
1426 let mut staged_at_s: Vec<f64> = Vec::new();
1427 let mut booster: Vec<BodyFlight> = Vec::new();
1428 let mut stops = self.vehicle.thrust_knots_s();
1429 stops.push(cap);
1430 stops.extend(trigger_times_s.iter().flatten().copied());
1431 stops.extend(self.separation_times_s.iter().flatten().copied());
1432 stops.extend(self.ejection_times_s.iter().flatten().copied());
1433 stops.extend(self.vehicle.shifts.knots_s());
1434 stops.extend(self.release_times_s.iter().flatten().copied());
1435 stops.retain(|t| *t <= cap);
1436 stops.sort_by(f64::total_cmp);
1437 stops.dedup();
1438 let mut burnout_s = self.vehicle.burnout_s();
1439 let rail_origin = state.position_enu_m;
1440 let exit_travel_m = self.guides.exit_travel_m(self.rail.length_m);
1441
1442 let mut phase = start_phase;
1443 // When a device froze the stack's attitude, which says nothing of its axis after that.
1444 let mut frozen_at_s = None;
1445 let mut lifted = start_phase != Phase::Pad;
1446 let mut burnout_recorded = t0 >= burnout_s;
1447 let mut separated = false;
1448 // Whether the stack came apart with nothing left to burn, by a separation or a charge,
1449 // while it still climbed: before its apogee, rising.
1450 let mut climbing = false;
1451 // The splits that have happened, in piece order: the separation, then the ejections.
1452 let mut opened = vec![false; self.body_count() - 1];
1453 // An unpowered separation or an ejection that a held flight skips.
1454 let mut separation_held = false;
1455 let mut events: Vec<FlightEvent> = Vec::new();
1456 let mut run = Run::new(self.devices.len());
1457 let record = |events: &mut Vec<FlightEvent>, observer: &mut dyn Observer, kind, sample| {
1458 let event = FlightEvent { kind, sample };
1459 observer.event(&event);
1460 events.push(event);
1461 };
1462
1463 let termination = loop {
1464 let t = integrator.time_s();
1465 let mut y = *integrator.state();
1466 if t >= cap {
1467 break Termination::TimeCap;
1468 }
1469
1470 // Mass shifts: one whose start is known begins at that stop time; the flight watches
1471 // for the apogee and the heights of the rest, as it does for a device's. A shift
1472 // makes no step in the state, so the integrator carries on.
1473 for (index, started) in shift_started.iter_mut().enumerate() {
1474 if *started {
1475 continue;
1476 }
1477 let stack = shifted.as_ref().unwrap_or(&self.vehicle);
1478 let window = (t, next_stop(&stops, t, cap));
1479 let area = self.ascent_drag_area_m2(&run, t);
1480 let starts = match stack.shifts.start_s(index) {
1481 Some(start_s) => t >= start_s,
1482 None if matches!(phase, Phase::Free | Phase::Descent) => {
1483 match self.shifts[index].trigger {
1484 // At or past the apogee, as a device's apogee trigger is.
1485 Trigger::Apogee => {
1486 self.evaluate(stack, phase, window, t, &y, area)?
1487 .vertical_speed_m_s
1488 <= 0.0
1489 }
1490 Trigger::Altitude {
1491 height_above_ground_m,
1492 } => {
1493 let e = self.evaluate(stack, phase, window, t, &y, area)?;
1494 e.vertical_speed_m_s < 0.0
1495 && e.height_above_ground_m <= height_above_ground_m
1496 }
1497 Trigger::Time { .. }
1498 | Trigger::MotorDelay { .. }
1499 | Trigger::Burnout { .. } => false,
1500 }
1501 }
1502 None => false,
1503 };
1504 if !starts {
1505 continue;
1506 }
1507 if matches!(phase, Phase::Pad | Phase::Rail) {
1508 // The rail has no stop at its foot: a part thrown aft on the pad could push
1509 // the rocket up the rail and leave it there.
1510 return Err(SimError::Domain {
1511 what: "start time of a mass shift, s (it must start once the rocket has \
1512 left the rail)",
1513 value: t,
1514 });
1515 }
1516 if stack.shifts.start_s(index).is_none() {
1517 shifted
1518 .get_or_insert_with(|| self.vehicle.clone())
1519 .shifts
1520 .start(index, t);
1521 }
1522 let stack = shifted.as_ref().unwrap_or(&self.vehicle);
1523 for stop_s in stack.shifts.stops_s(index) {
1524 if stop_s > t {
1525 insert_stop(&mut stops, stop_s, cap);
1526 }
1527 }
1528 *started = true;
1529 let window = (t, next_stop(&stops, t, cap));
1530 let sample = self.sample(stack, phase, window, t, &y, area)?;
1531 record(&mut events, observer, EventKind::Shift(index), sample);
1532 }
1533
1534 // Mass releases: one whose time is known comes at that stop time; the flight watches
1535 // for the apogee and the heights of the rest, as it does for a device's. The part
1536 // leaves with the velocity its center had in the airframe, the stack flies on from
1537 // the same state without it, and the part falls on its own. The passes repeat until
1538 // no part leaves, since a release can make the apogee another release waits for.
1539 let mut landed: Option<Sample> = None;
1540 loop {
1541 // The stack as the pass begins, which the apogee and height triggers are judged
1542 // on, so that the parts listed before one don't decide whether it leaves. Past
1543 // the recorded apogee the stack is coming down, whatever a release did to its
1544 // center's speed; with none (a flight started falling) its own speed says.
1545 let apogee_recorded = events.iter().any(|event| event.kind == EventKind::Apogee);
1546 let start = if matches!(phase, Phase::Free | Phase::Descent)
1547 && released.iter().zip(&self.releases).any(|(gone, release)| {
1548 !gone
1549 && matches!(release.trigger, Trigger::Apogee | Trigger::Altitude { .. })
1550 }) {
1551 let stack = lightened.as_ref().unwrap_or(&self.vehicle);
1552 let window = (t, next_stop(&stops, t, cap));
1553 let area = self.ascent_drag_area_m2(&run, t);
1554 Some(self.evaluate(stack, phase, window, t, &y, area)?)
1555 } else {
1556 None
1557 };
1558 let at_apogee = start
1559 .as_ref()
1560 .is_some_and(|e| apogee_recorded || e.vertical_speed_m_s <= 0.0);
1561 let descending = start
1562 .as_ref()
1563 .is_some_and(|e| apogee_recorded || e.vertical_speed_m_s < 0.0);
1564 // The rocket's vertical speed just before the first part left in this pass.
1565 let mut rising: Option<f64> = None;
1566 for (index, gone) in released.iter_mut().enumerate() {
1567 if *gone {
1568 continue;
1569 }
1570 let stack = lightened.as_ref().unwrap_or(&self.vehicle);
1571 let window = (t, next_stop(&stops, t, cap));
1572 let area = self.ascent_drag_area_m2(&run, t);
1573 let leaves = match self.release_times_s[index] {
1574 Some(time_s) => t >= time_s,
1575 None if matches!(phase, Phase::Free | Phase::Descent) => {
1576 match self.releases[index].trigger {
1577 // At the flight's apogee, as a device's apogee trigger is, or
1578 // past one it didn't see (a flight started falling).
1579 Trigger::Apogee => at_apogee,
1580 Trigger::Altitude {
1581 height_above_ground_m,
1582 } => {
1583 descending
1584 && start.as_ref().is_some_and(|e| {
1585 e.height_above_ground_m <= height_above_ground_m
1586 })
1587 }
1588 Trigger::Time { .. }
1589 | Trigger::MotorDelay { .. }
1590 | Trigger::Burnout { .. } => false,
1591 }
1592 }
1593 None => false,
1594 };
1595 if !leaves {
1596 continue;
1597 }
1598 if matches!(phase, Phase::Pad | Phase::Rail) {
1599 // On the pad or the rail the part has nowhere to go.
1600 return Err(SimError::Domain {
1601 what: "time of a mass release, s (it must come once the rocket has left \
1602 the rail)",
1603 value: t,
1604 });
1605 }
1606 let sample = self.sample(stack, phase, window, t, &y, area)?;
1607 record(&mut events, observer, EventKind::MassRelease(index), sample);
1608 rising.get_or_insert(sample.vertical_speed_m_s);
1609 // Its center, and the velocity that point had: `v_O + ω × c` in `L` (the body
1610 // rates are zero in the descent, whose attitude is frozen).
1611 let part = self.release_parts.part(index);
1612 let state = State::from_array(&y);
1613 let omega = if phase == Phase::Free {
1614 state.body_rate_rad_s
1615 } else {
1616 DVec3::ZERO
1617 };
1618 let velocity_enu_m_s = state.velocity_enu_m_s
1619 + state.unit_attitude().mul_vec3(omega.cross(part.cg_m));
1620 released_flights.push(self.fly_released(
1621 index,
1622 t,
1623 (state.point_enu_m(part.cg_m), velocity_enu_m_s),
1624 part.mass_kg,
1625 )?);
1626 lighten(
1627 &mut lightened
1628 .get_or_insert_with(|| self.vehicle.clone())
1629 .assembly,
1630 part,
1631 );
1632 *gone = true;
1633 }
1634 let (Some(rising), Some(stack)) = (rising, lightened.as_ref()) else {
1635 break;
1636 };
1637 // The mass steps here, so the integrator starts afresh from the same state: the
1638 // nose tip's, which the stack keeps.
1639 integrator.reset(t, y)?;
1640 let window = (t, next_stop(&stops, t, cap));
1641 let area = self.ascent_drag_area_m2(&run, t);
1642 let sample = self.sample(stack, phase, window, t, &y, area)?;
1643 if sample.height_above_ground_m <= 0.0 {
1644 if sample.vertical_speed_m_s > 0.0 {
1645 // Below the ground and climbing: not a landing.
1646 return Err(SimError::Domain {
1647 what: "height of the rest's center of mass above the ground after a \
1648 mass release, m (it steps below the ground while climbing)",
1649 value: sample.height_above_ground_m,
1650 });
1651 }
1652 // The rest's center stepped to the ground or below it, which the ground
1653 // event, a crossing from above, would never see: it has landed.
1654 landed = Some(sample);
1655 break;
1656 }
1657 // The rest's center moves at `v_O + ω × cg'`, not as the rocket's did: a part let
1658 // go just before the apogee can leave it already falling, and the apogee the
1659 // flight watches for, its vertical speed falling through zero, is then here.
1660 if rising > 0.0
1661 && sample.vertical_speed_m_s <= 0.0
1662 && events.iter().all(|event| event.kind != EventKind::Apogee)
1663 {
1664 self.reach_apogee(sample, &mut run, (&mut stops, cap), &mut events, observer);
1665 }
1666 }
1667 if let Some(sample) = landed {
1668 record(&mut events, observer, EventKind::GroundHit, sample);
1669 break Termination::GroundHit;
1670 }
1671 let vehicle = sustainer
1672 .as_ref()
1673 .or(shifted.as_ref())
1674 .or(lightened.as_ref())
1675 .unwrap_or(&self.vehicle);
1676
1677 // Motors lit after launch: their ignitions are stop times, so each is found here at
1678 // its own time.
1679 for index in 0..ignition_s.len() {
1680 if !ignited[index] && ignition_s[index].is_some_and(|time| t >= time) {
1681 ignited[index] = true;
1682 let window = (t, next_stop(&stops, t, cap));
1683 let area = self.ascent_drag_area_m2(&run, t);
1684 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1685 record(&mut events, observer, EventKind::Ignition(index), sample);
1686 }
1687 }
1688
1689 // Recovery: fire the charges whose time or height has come, then deploy the devices
1690 // whose lag has run out. A lag of zero deploys in the same pass, and a deployment can
1691 // release another device, so this repeats until nothing more happens. Only the
1692 // devices of body 0 act before a separation: a device meant for another body has a
1693 // drag area computed for that body (a booster's tumbling area, say), which is not a
1694 // model of the whole stack.
1695 while !self.recovery_held
1696 && !self.devices.is_empty()
1697 && matches!(phase, Phase::Free | Phase::Descent)
1698 {
1699 let mut again = false;
1700 let window = (t, next_stop(&stops, t, cap));
1701 let area = self.ascent_drag_area_m2(&run, t);
1702 // One evaluation serves every device in the pass: they all ask about the same
1703 // `(t, y)`. It is only made when a pending device needs the flight's state, and
1704 // it is not one of the integrator's, so `Stats` doesn't count it.
1705 let mut here: Option<Evaluation> = None;
1706 for index in 0..self.devices.len() {
1707 if !run.pending(index) || !self.acts_before_separation(index) {
1708 continue;
1709 }
1710 // `plan` gives `trigger_times_s` one entry per device, in order.
1711 let fires = match self.devices[index].trigger {
1712 Trigger::Time { .. }
1713 | Trigger::MotorDelay { .. }
1714 | Trigger::Burnout { .. } => trigger_times_s
1715 .get(index)
1716 .copied()
1717 .flatten()
1718 .is_some_and(|time| t >= time),
1719 // At or past the apogee, which is RocketPy's own trigger (`y[5] < 0`)
1720 // widened to include a flight that starts exactly at its apogee: with
1721 // `< 0` such a flight has no crossing for the apogee event to find
1722 // either, and would wait for the next interval. The apogee event fires
1723 // this too, and whichever comes first wins.
1724 Trigger::Apogee => {
1725 let e = cached(&mut here, || {
1726 self.evaluate(vehicle, phase, window, t, &y, area)
1727 })?;
1728 e.vertical_speed_m_s <= 0.0
1729 }
1730 Trigger::Altitude {
1731 height_above_ground_m,
1732 } => {
1733 // An altimeter's main setting: descending, at or below the height.
1734 // A rocket already below it at apogee fires there, as the event on
1735 // the height never crosses it (RocketPy's numeric trigger). Past the
1736 // recorded apogee the rocket is descending, even while a part let go
1737 // there has the rest's center rising for a moment (beyond RocketPy,
1738 // which has no releases; without one the rule is the same).
1739 let e = cached(&mut here, || {
1740 self.evaluate(vehicle, phase, window, t, &y, area)
1741 })?;
1742 (e.vertical_speed_m_s < 0.0
1743 || events.iter().any(|event| event.kind == EventKind::Apogee))
1744 && e.height_above_ground_m <= height_above_ground_m
1745 }
1746 };
1747 if fires {
1748 let deploy_s = run.trigger(&self.devices, index, t);
1749 insert_stop(&mut stops, deploy_s, cap);
1750 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1751 record(&mut events, observer, EventKind::Trigger(index), sample);
1752 again = true;
1753 }
1754 }
1755 for index in 0..self.devices.len() {
1756 if !run.waiting(index)
1757 || run.deploy_s(index) > t
1758 || !self.acts_before_separation(index)
1759 {
1760 continue;
1761 }
1762 if run.released_s(index).is_some_and(|released| released <= t) {
1763 // Cut away before its own charge fired: the canopy never flies.
1764 run.abandon(index);
1765 again = true;
1766 continue;
1767 }
1768 let evaluation = cached(&mut here, || {
1769 self.evaluate(vehicle, phase, window, t, &y, area)
1770 })?;
1771 let full_s = run.deploy(&self.devices, index, t, evaluation.airspeed_m_s);
1772 insert_stop(&mut stops, full_s, cap);
1773 if phase != Phase::Descent {
1774 // The descent is a point mass: the attitude freezes where it deployed and
1775 // the body rates go, snubbed by the lines and the canopy. The state's
1776 // velocity is the nose tip's, so it is shifted to keep the center of mass
1777 // moving as it was: dropping `ω` while holding `v_O` would change the
1778 // center of mass's momentum with nothing to do it (found in review).
1779 // Written this way it reads as what it is; the `ṙ_cg` terms cancel, so
1780 // the net shift is `q(ω × r_cg)`.
1781 phase = Phase::Descent;
1782 frozen_at_s = Some(t);
1783 let mut frozen = State::from_array(&y);
1784 frozen.velocity_enu_m_s = evaluation.cg_velocity_enu_m_s
1785 - frozen.unit_attitude().mul_vec3(evaluation.mass.cg_rate_m_s);
1786 frozen.body_rate_rad_s = DVec3::ZERO;
1787 y = frozen.to_array();
1788 integrator.reset(t, y)?;
1789 here = None;
1790 }
1791 let area = self.ascent_drag_area_m2(&run, t);
1792 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1793 record(&mut events, observer, EventKind::Deployment(index), sample);
1794 again = true;
1795 }
1796 // A release happens when the device that releases it is fully open, which is a
1797 // stop time, so the drag area never dips between a drogue and a filling main.
1798 for index in 0..self.devices.len() {
1799 if run.release_due(index, t) && self.acts_before_separation(index) {
1800 run.release(index);
1801 let area = self.ascent_drag_area_m2(&run, t);
1802 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1803 record(&mut events, observer, EventKind::Release(index), sample);
1804 again = true;
1805 }
1806 }
1807 if !again {
1808 break;
1809 }
1810 }
1811
1812 // A separation or an ejection known to come while the rocket is still on the pad or
1813 // the rail: the stack can't come apart there, and firing it at the rail exit instead
1814 // would be a different flight (#231). Refused, as an early shift or release is.
1815 if self.parts() && !separation_held && matches!(phase, Phase::Pad | Phase::Rail) {
1816 let separations = self.separation_times_s.iter().skip(staged).map(|time_s| {
1817 (
1818 time_s,
1819 "time of a separation, s (it must come once the rocket has left the rail)",
1820 )
1821 });
1822 let ejections = self.ejection_times_s.iter().map(|time_s| {
1823 (
1824 time_s,
1825 "time of an ejection, s (it must come once the rocket has left the rail)",
1826 )
1827 });
1828 for (time_s, what) in separations.chain(ejections) {
1829 if time_s.is_some_and(|time_s| t >= time_s) {
1830 return Err(SimError::Domain { what, value: t });
1831 }
1832 }
1833 }
1834
1835 // The separations and the ejections: the same triggers as a device's. When the next
1836 // separation fires with the nose's body still to burn, that body flies on as the
1837 // sustainer and the part behind it descends; otherwise the ascent ends and every body
1838 // flies on as a point mass.
1839 if self.parts()
1840 && (staged < self.separations.len() || (staged == 0 && !self.ejections.is_empty()))
1841 && !separation_held
1842 && matches!(phase, Phase::Free | Phase::Descent)
1843 {
1844 let window = (t, next_stop(&stops, t, cap));
1845 let area = self.ascent_drag_area_m2(&run, t);
1846 let mut here: Option<Evaluation> = None;
1847 let mut fires = |trigger: Trigger, time_s: Option<f64>| -> Result<bool, SimError> {
1848 Ok(match trigger {
1849 Trigger::Time { .. }
1850 | Trigger::MotorDelay { .. }
1851 | Trigger::Burnout { .. } => time_s.is_some_and(|time| t >= time),
1852 // At or past the apogee, as a device's apogee trigger is.
1853 Trigger::Apogee => {
1854 cached(&mut here, || {
1855 self.evaluate(vehicle, phase, window, t, &y, area)
1856 })?
1857 .vertical_speed_m_s
1858 <= 0.0
1859 }
1860 Trigger::Altitude {
1861 height_above_ground_m,
1862 } => {
1863 let e = cached(&mut here, || {
1864 self.evaluate(vehicle, phase, window, t, &y, area)
1865 })?;
1866 e.vertical_speed_m_s < 0.0
1867 && e.height_above_ground_m <= height_above_ground_m
1868 }
1869 })
1870 };
1871 let pending = self.separations.get(staged).copied();
1872 let separates = match pending {
1873 Some(separation) => fires(separation.trigger, self.separation_times_s[staged])?,
1874 None => false,
1875 };
1876 if !separates {
1877 // One further down the list can't come first: the stages it drops are still
1878 // held by the ones behind them.
1879 for later in staged + 1..self.separations.len() {
1880 if fires(
1881 self.separations[later].trigger,
1882 self.separation_times_s[later],
1883 )? {
1884 return Err(SimError::Domain {
1885 what: "time of a separation, s (it fires before the separation \
1886 ahead of it in the list, which drops the stages behind it)",
1887 value: t,
1888 });
1889 }
1890 }
1891 }
1892 let mut ejected = Vec::new();
1893 for (index, ejection) in self.ejections.iter().enumerate() {
1894 if fires(ejection.trigger, self.ejection_times_s[index])? {
1895 ejected.push(index);
1896 }
1897 }
1898 if separates || !ejected.is_empty() {
1899 // The stack's motors as the separation lights them: one lit by it counts from
1900 // now. A body's mass is held constant through its descent, so the booster's
1901 // motors must be spent.
1902 let mut lit = ignition_s.clone();
1903 // The separation, when it fires with a motor ahead of it still to burn.
1904 let mut powered: Option<Separation> = None;
1905 if let Some(separation) = pending.filter(|_| separates) {
1906 // A motor lit by an earlier separation counts from that one.
1907 lit = self.vehicle.assembly.ignition_times_s(|stage| {
1908 (stage == separation.after_stage).then_some(t).or_else(|| {
1909 self.separations
1910 .iter()
1911 .zip(&staged_at_s)
1912 .find(|(earlier, _)| earlier.after_stage == stage)
1913 .map(|(_, &at_s)| at_s)
1914 })
1915 });
1916 let burning = self.vehicle.assembly.motors.iter().zip(&lit).any(
1917 |(placed, ignition)| {
1918 placed.stage <= separation.after_stage
1919 && ignition.is_some_and(|ignition| {
1920 ignition + placed.mounted.motor.burnout_time_s() > t
1921 })
1922 },
1923 );
1924 powered = burning.then_some(separation);
1925 // Checked before a held flight holds it, so that the delay's flight
1926 // refuses what the flown one would.
1927 self.check_aft_body_spent(separation, &lit, t, burning)?;
1928 if !burning && self.separations.len() > 1 {
1929 // Once every body is a point mass there is no sustainer left to part
1930 // again, and the bodies a sustainer already dropped have flown.
1931 return Err(SimError::Domain {
1932 what: "time of a separation that leaves nothing ahead of it to \
1933 burn, in a flight with more than one separation (each \
1934 must hand the flight on to a sustainer)",
1935 value: t,
1936 });
1937 }
1938 }
1939 if powered.is_some() && !self.ejections.is_empty() {
1940 // The sustainer flies on a cut design whose components aren't the
1941 // pieces the ejections were given for.
1942 return Err(SimError::Domain {
1943 what: "time of a powered separation in a flight with ejections (the \
1944 pieces of a sustainer aren't tracked)",
1945 value: t,
1946 });
1947 }
1948 if !ejected.is_empty() {
1949 // Every body is a point mass of constant mass once the airframe parts.
1950 self.check_spent(&lit, t)?;
1951 if let Some(separation) = pending.filter(|_| !separates)
1952 && self
1953 .vehicle
1954 .assembly
1955 .ignition_times_s(|stage| {
1956 (stage == separation.after_stage).then_some(t)
1957 })
1958 .iter()
1959 .zip(&lit)
1960 .any(|(would, is)| would.is_some() && is.is_none())
1961 {
1962 return Err(SimError::Domain {
1963 what: "time of an ejection before the separation that lights a \
1964 motor (the pieces would never light it)",
1965 value: t,
1966 });
1967 }
1968 }
1969 if self.recovery_held && powered.is_none() {
1970 // With nothing ahead of it left to burn it is part of the recovery, so it
1971 // is held with the charges.
1972 separation_held = true;
1973 continue;
1974 }
1975 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1976 if separates {
1977 record(&mut events, observer, EventKind::Separation, sample);
1978 }
1979 for &index in &ejected {
1980 record(&mut events, observer, EventKind::Ejection(index), sample);
1981 }
1982 let Some(separation) = powered else {
1983 if separates {
1984 opened[staged] = true;
1985 }
1986 climbing = events.iter().all(|event| event.kind != EventKind::Apogee)
1987 && State::from_array(&y).velocity_enu_m_s.z > 0.0;
1988 let first = self.separations.len();
1989 for &index in &ejected {
1990 opened[first + index] = true;
1991 }
1992 ignition_s = lit;
1993 separated = true;
1994 break Termination::Separated;
1995 };
1996 if phase == Phase::Descent {
1997 // The descent is a point mass under canopies; a sustainer under thrust
1998 // is not something it can fly.
1999 return Err(SimError::Domain {
2000 what: "time of a separation whose nose body still has a motor to \
2001 burn (it comes after a recovery device opened on that body)",
2002 value: t,
2003 });
2004 }
2005 if self.aero_overridden {
2006 return Err(SimError::Domain {
2007 what: "time of a powered separation (a drag table or drag model, \
2008 or a normal-force table, is the whole stack's, and the \
2009 sustainer has none)",
2010 value: t,
2011 });
2012 }
2013 // Built only now, so an unpowered separation never needs the cut design.
2014 let model = Sustainer::of(
2015 &self.rocket,
2016 &self.configuration_id,
2017 &self.vehicle.assembly,
2018 separation,
2019 )?;
2020 let lit_here = model.motors.iter().map(|&index| lit[index]).collect();
2021 let aero = if self.vehicle.aero.full_base_drag_under_power() {
2022 model.aero.with_full_base_drag_under_power()
2023 } else {
2024 model.aero
2025 }
2026 .with_drag_scale(self.vehicle.aero.drag_scale())?;
2027 let flown = Vehicle::lit(model.assembly, aero, lit_here)?;
2028 // The part it drops is the next body, flown alone: the nose's flies on, and those
2029 // dropped before have flown.
2030 opened[staged] = true;
2031 booster.extend(self.fly_bodies(
2032 t,
2033 &State::from_array(&y),
2034 &mut run,
2035 &lit,
2036 (
2037 &opened,
2038 Some(staged + 1),
2039 frozen_at_s.is_some_and(|frozen_s| frozen_s < t),
2040 ),
2041 )?);
2042 // The booster's descent has no airframe drag, and a powered separation comes
2043 // near the top speed, so a coast to its device would climb as if in a vacuum
2044 // (twice the sustainer's apogee in review). Its device must open at once.
2045 let open_at_split = self.devices.iter().enumerate().any(|(index, device)| {
2046 device.body == staged + 1
2047 && run.devices[index]
2048 .deployed_s
2049 .is_some_and(|deployed_s| deployed_s <= t)
2050 });
2051 if !open_at_split {
2052 return Err(SimError::Domain {
2053 what: "time of a powered separation (the booster falls with no \
2054 airframe drag, so a device on it must open at the separation: \
2055 one triggered at `Time { time_s: 0.0 }` with no lag does, as \
2056 a booster's devices act only once it flies)",
2057 value: t,
2058 });
2059 }
2060 trigger_times_s =
2061 recovery::plan(&self.devices, &self.vehicle.assembly.motors, &lit)?;
2062 for time in trigger_times_s
2063 .iter()
2064 .flatten()
2065 .copied()
2066 .chain(flown.thrust_knots_s())
2067 {
2068 if time > t {
2069 insert_stop(&mut stops, time, cap);
2070 }
2071 }
2072 if flown.burnout_s() > t {
2073 burnout_recorded = false;
2074 }
2075 burnout_s = flown.burnout_s();
2076 ignition_s = lit;
2077 staged += 1;
2078 staged_at_s.push(t);
2079 sustainer = Some(flown);
2080 // The mass steps here, so the integrator starts afresh from the same state:
2081 // the nose tip's, which the sustainer keeps.
2082 integrator.reset(t, y)?;
2083 continue;
2084 }
2085 }
2086
2087 let next = next_stop(&stops, t, cap);
2088 let window = (t, next);
2089 if phase == Phase::Pad {
2090 if self
2091 .evaluate(vehicle, Phase::Pad, window, t, &y, 0.0)?
2092 .rail_force_n
2093 > 0.0
2094 {
2095 phase = Phase::Rail;
2096 lifted = true;
2097 let sample = self.sample(vehicle, phase, window, t, &y, 0.0)?;
2098 record(&mut events, observer, EventKind::Liftoff, sample);
2099 } else if t >= burnout_s {
2100 break if lifted {
2101 Termination::StalledOnRail
2102 } else {
2103 Termination::NoLiftoff
2104 };
2105 }
2106 }
2107 // Once a part has left, the rest's center sits apart from where the rocket's was, and
2108 // can rise again for a moment after the rocket's apogee: the flight has one.
2109 let apogee_pending = !(released.contains(&true)
2110 && events.iter().any(|event| event.kind == EventKind::Apogee));
2111 let watches = self.watches(
2112 phase,
2113 &run,
2114 (
2115 (!separation_held
2116 && (staged < self.separations.len() || (staged == 0 && self.parts())))
2117 .then_some(staged),
2118 apogee_pending,
2119 ),
2120 (&shift_started, &released),
2121 );
2122 let mut system = PhaseSystem {
2123 simulation: self,
2124 vehicle,
2125 phase,
2126 window,
2127 rail_origin,
2128 exit_travel_m,
2129 canopies: Canopies {
2130 devices: &self.devices,
2131 run: &run,
2132 body: self.parts().then_some(0),
2133 },
2134 watches: &watches,
2135 observer: &mut *observer,
2136 failure: None,
2137 cache: None,
2138 };
2139 let outcome = integrator.advance(&mut system, next);
2140 if let Some(error) = system.failure.take() {
2141 return Err(error);
2142 }
2143 let outcome = match outcome {
2144 Ok(outcome) => outcome,
2145 Err(IntegrationError::StepLimit { .. }) => break Termination::StepLimit,
2146 Err(IntegrationError::Derivative { source, .. }) => return Err(source),
2147 Err(error) => return Err(SimError::Integration(Box::new(error))),
2148 };
2149 let t = integrator.time_s();
2150 let y = *integrator.state();
2151 let area = self.ascent_drag_area_m2(&run, t);
2152 // Burnout is a stop time, but an event can end the step on it first.
2153 if !burnout_recorded && t >= burnout_s {
2154 burnout_recorded = true;
2155 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
2156 record(&mut events, observer, EventKind::Burnout, sample);
2157 }
2158 match outcome {
2159 Advance::Reached => {}
2160 Advance::Events => {
2161 let fired = integrator.fired_events().to_vec();
2162 let mut ground = false;
2163 let mut next_phase = phase;
2164 for index in fired {
2165 // `event_count` is `watches.len()`, and the integrator numbers its events
2166 // by it, so a fired index is always in range.
2167 match watches[index] {
2168 Watch::RailExit => {
2169 next_phase = Phase::Free;
2170 let sample =
2171 self.sample(vehicle, Phase::Free, window, t, &y, area)?;
2172 record(&mut events, observer, EventKind::RailExit, sample);
2173 }
2174 Watch::RailStall => next_phase = Phase::Pad,
2175 Watch::RailForce => {
2176 next_phase = Phase::Rail;
2177 lifted = true;
2178 let sample =
2179 self.sample(vehicle, Phase::Rail, window, t, &y, area)?;
2180 record(&mut events, observer, EventKind::Liftoff, sample);
2181 }
2182 // The watch stays armed past the apogee until a part leaves, and can
2183 // find the same root again a step later (#242): the flight has one.
2184 Watch::Apogee
2185 if events.iter().any(|event| event.kind == EventKind::Apogee) => {}
2186 Watch::Apogee => {
2187 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
2188 self.reach_apogee(
2189 sample,
2190 &mut run,
2191 (&mut stops, cap),
2192 &mut events,
2193 observer,
2194 );
2195 }
2196 Watch::Ground => {
2197 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
2198 record(&mut events, observer, EventKind::GroundHit, sample);
2199 ground = true;
2200 }
2201 Watch::Altitude(device) => {
2202 if !self.recovery_held && run.pending(device) {
2203 let deploy_s = run.trigger(&self.devices, device, t);
2204 insert_stop(&mut stops, deploy_s, cap);
2205 let sample =
2206 self.sample(vehicle, phase, window, t, &y, area)?;
2207 record(
2208 &mut events,
2209 observer,
2210 EventKind::Trigger(device),
2211 sample,
2212 );
2213 }
2214 }
2215 Watch::SeparationHeight(_)
2216 | Watch::EjectionHeight(_)
2217 | Watch::ShiftHeight(_)
2218 | Watch::ReleaseHeight(_) => {
2219 // The separation, ejection, shift or release itself fires at the
2220 // top of the next pass, which is where its burnout check and its
2221 // bodies live, where a shift's start is set, and where a part
2222 // leaves.
2223 }
2224 Watch::User(user) => {
2225 let sample = self.sample(vehicle, phase, window, t, &y, area)?;
2226 record(&mut events, observer, EventKind::User(user), sample);
2227 }
2228 }
2229 }
2230 if ground {
2231 break Termination::GroundHit;
2232 }
2233 if next_phase == Phase::Pad && phase == Phase::Rail {
2234 // Stopped on the rail: at rest where it stopped.
2235 let mut at_rest = State::from_array(&y);
2236 at_rest.velocity_enu_m_s = DVec3::ZERO;
2237 integrator.reset(t, at_rest.to_array())?;
2238 }
2239 phase = next_phase;
2240 }
2241 _ => {}
2242 }
2243 };
2244
2245 let t = integrator.time_s();
2246 let y = *integrator.state();
2247 let vehicle = sustainer
2248 .as_ref()
2249 .or(shifted.as_ref())
2250 .or(lightened.as_ref())
2251 .unwrap_or(&self.vehicle);
2252 let next = next_stop(&stops, t, f64::INFINITY);
2253 let area = self.ascent_drag_area_m2(&run, t);
2254 let final_sample = self.sample(vehicle, phase, (t, next.max(t)), t, &y, area)?;
2255 let bodies = if separated {
2256 let bodies = self.fly_bodies(
2257 t,
2258 &State::from_array(&y),
2259 &mut run,
2260 &ignition_s,
2261 (
2262 &opened,
2263 None,
2264 frozen_at_s.is_some_and(|frozen_s| frozen_s < t),
2265 ),
2266 )?;
2267 // Each part falls as a point with only its open devices' drag. One parted on the way
2268 // up whose device fired by the split but waits out its lag would climb through the
2269 // lag with no drag at all, as if in a vacuum, where the real part has its airframe's
2270 // (a deployment lag drawn by a Monte Carlo run did, in review): refused by name. A
2271 // device set for the part's own apogee leaves it climbing as before, which the
2272 // `.ork` reader refuses for the part that keeps the nose (ADR-165).
2273 if climbing {
2274 for body in bodies.iter().filter(|body| body.start_sample.time_s == t) {
2275 let mine = || {
2276 self.devices
2277 .iter()
2278 .enumerate()
2279 .filter(|(_, device)| device.body == body.body)
2280 .map(|(index, _)| &run.devices[index])
2281 };
2282 let open_at_split = mine()
2283 .any(|device| device.deployed_s.is_some_and(|deployed_s| deployed_s <= t));
2284 let lagging = mine().any(|device| {
2285 device
2286 .triggered_s
2287 .is_some_and(|triggered_s| triggered_s <= t)
2288 });
2289 if lagging && !open_at_split {
2290 return Err(SimError::Domain {
2291 what: "time of a split with nothing left to burn, before \
2292 apogee (a part's device fired by then but opens after its \
2293 lag, and the part would climb through the lag with no \
2294 airframe drag)",
2295 value: t,
2296 });
2297 }
2298 }
2299 }
2300 bodies
2301 } else {
2302 booster
2303 };
2304 Ok(FlightResult {
2305 termination,
2306 events,
2307 final_sample,
2308 stats: integrator.stats(),
2309 bodies,
2310 released: released_flights,
2311 })
2312 }
2313
2314 /// Flies the bodies of a stack that came apart at `t` to their landings, with the stack's
2315 /// motors lit at `ignition_s`: `open[split]` says which splits happened (the separations, then
2316 /// the ejections), `only` flies that body alone, the one a powered separation drops while the
2317 /// rest fly on or have already flown, and `frozen`
2318 /// says a device froze the stack's attitude before `t` (one that opens as the stack parts
2319 /// leaves the axis it had then).
2320 ///
2321 /// Each body is a point mass with its own pieces' and motors' mass, starting where its own
2322 /// center of mass was with the velocity that point already had, plus the push of each
2323 /// ejection's impulse on its side. The bodies share the flight's devices and their progress: a
2324 /// device that had already opened stays open on whichever body carries it. A body that parts
2325 /// again on the way down hands the piece that leaves to a body of its own, flown after it.
2326 fn fly_bodies(
2327 &self,
2328 t: f64,
2329 state: &State,
2330 run: &mut Run,
2331 ignition_s: &[Option<f64>],
2332 (open, only, frozen): (&[bool], Option<usize>, bool),
2333 ) -> Result<Vec<BodyFlight>, SimError> {
2334 if !self.parts() {
2335 return Ok(Vec::new());
2336 }
2337 let pieces = Pieces::new(
2338 &self.rocket,
2339 &self.vehicle.assembly,
2340 &self.separations,
2341 &self.ejections,
2342 )?;
2343 let mut open = open.to_vec();
2344 open.resize(pieces.count() - 1, false);
2345 let attitude = state.unit_attitude();
2346 // A trigger on a motor the separation lit has its time only now.
2347 let trigger_times_s =
2348 recovery::plan(&self.devices, &self.vehicle.assembly.motors, ignition_s)?;
2349 let leaders = pieces.leaders(&open);
2350 let mut starts = Vec::new();
2351 for body in 0..pieces.count() {
2352 if leaders[body] != body || only.is_some_and(|only| only != body) {
2353 continue;
2354 }
2355 let mass = pieces.mass_properties(
2356 |piece| leaders[piece] == body,
2357 &self.vehicle.assembly,
2358 t,
2359 ignition_s,
2360 );
2361 // Its own center of mass, and the velocity that point had: `v_O + ω × r` in `L`.
2362 let cg_enu_m = state.point_enu_m(mass.cg_m);
2363 let velocity_enu_m_s =
2364 state.velocity_enu_m_s + attitude.mul_vec3(state.body_rate_rad_s.cross(mass.cg_m));
2365 starts.push(Start {
2366 body,
2367 t_s: t,
2368 cg_enu_m,
2369 velocity_enu_m_s,
2370 mass_kg: checked_body_mass(mass.mass_kg)?,
2371 });
2372 }
2373 // Each split that parts the stack here pushes its two sides apart: along the airframe's
2374 // axis while it flies with nothing open, and by its velocity through the air once a
2375 // device has frozen its attitude at some earlier time (ADR-086).
2376 let firing: Vec<usize> = (0..open.len()).filter(|&index| open[index]).collect();
2377 if firing
2378 .iter()
2379 .any(|&index| self.split_impulse_n_s(index) > 0.0)
2380 {
2381 let nose_ward = if frozen {
2382 let (mut kg, mut momentum, mut moment) = (0.0, DVec3::ZERO, DVec3::ZERO);
2383 for start in &starts {
2384 kg += start.mass_kg;
2385 momentum += start.velocity_enu_m_s * start.mass_kg;
2386 moment += start.cg_enu_m * start.mass_kg;
2387 }
2388 let hanging =
2389 run.hung_before(&self.devices, |index| self.acts_before_separation(index), t);
2390 self.nose_ward_enu(moment / kg, momentum / kg, hanging)?
2391 } else {
2392 attitude.mul_vec3(DVec3::Z)
2393 };
2394 self.push_apart(
2395 (&firing, &pieces, &open, &leaders),
2396 nose_ward,
2397 &mut starts,
2398 t,
2399 )?;
2400 }
2401 let mut queue: std::collections::VecDeque<Start> = starts.into();
2402 let mut bodies = Vec::new();
2403 while let Some(start) = queue.pop_front() {
2404 let mut split = Split {
2405 pieces: &pieces,
2406 open: &mut open,
2407 ignition_s,
2408 queue: &mut queue,
2409 };
2410 bodies.push(self.fly_body(start, &mut split, run, &trigger_times_s)?);
2411 }
2412 bodies.sort_by_key(|body| body.body);
2413 Ok(bodies)
2414 }
2415
2416 /// Flies one body as a point mass from its `start` to its landing, parting it on the way
2417 /// down at each of its splits that fires.
2418 fn fly_body(
2419 &self,
2420 start: Start,
2421 split: &mut Split<'_>,
2422 run: &mut Run,
2423 trigger_times_s: &[Option<f64>],
2424 ) -> Result<BodyFlight, SimError> {
2425 let Start {
2426 body,
2427 t_s: t0,
2428 cg_enu_m,
2429 velocity_enu_m_s,
2430 mut mass_kg,
2431 } = start;
2432 let impulse_left_n_s = {
2433 let leaders = split.pieces.leaders(split.open);
2434 split.pieces.impulse_left_n_s(
2435 |piece| leaders[piece] == body,
2436 &self.vehicle.assembly,
2437 t0,
2438 split.ignition_s,
2439 )
2440 };
2441 let cap = self.settings.max_time_s;
2442 let start = [
2443 cg_enu_m.x,
2444 cg_enu_m.y,
2445 cg_enu_m.z,
2446 velocity_enu_m_s.x,
2447 velocity_enu_m_s.y,
2448 velocity_enu_m_s.z,
2449 ];
2450 let mut integrator = Integrator::new(self.settings.method, t0, start)?
2451 .with_step_limit(self.settings.step_limit);
2452 // Every time this body's devices already have: their trigger times, and (for a device
2453 // that was triggered, deployed or released before the separation) its deployment, the
2454 // end of its filling and its release. Without these the descent would step straight past
2455 // them (found in review).
2456 let mut stops: Vec<f64> = Vec::new();
2457 for index in 0..self.devices.len() {
2458 if self.devices[index].body != body {
2459 continue;
2460 }
2461 stops.extend(trigger_times_s.get(index).copied().flatten());
2462 stops.extend(run.times_of(index));
2463 }
2464 // And its splits' known times.
2465 let mut leaders = split.pieces.leaders(split.open);
2466 for index in split.pending(&leaders, body) {
2467 stops.extend(self.split(index).1);
2468 }
2469 stops.retain(|time| *time > t0 && *time <= cap);
2470 stops.push(cap);
2471 stops.sort_by(f64::total_cmp);
2472 stops.dedup();
2473 let mut events: Vec<BodyEvent> = Vec::new();
2474 let start_sample = self.body_sample(body, mass_kg, t0, &start, run)?;
2475 if start_sample.height_above_ground_m <= 0.0 {
2476 // The ground event is a falling crossing, so a body that starts below the site would
2477 // integrate underground to the time cap.
2478 return Err(SimError::Domain {
2479 what: "starting height of a separated body's center of mass above the ground",
2480 value: start_sample.height_above_ground_m,
2481 });
2482 }
2483 let mine: Vec<usize> = (0..self.devices.len())
2484 .filter(|index| self.devices[*index].body == body)
2485 .collect();
2486
2487 let termination = loop {
2488 let t = integrator.time_s();
2489 let mut y = *integrator.state();
2490 if t >= cap {
2491 break Termination::TimeCap;
2492 }
2493 // Charges, deployments and releases, as in the main loop.
2494 loop {
2495 let mut again = false;
2496 let sample = self.body_sample(body, mass_kg, t, &y, run)?;
2497 for &index in &mine {
2498 if !run.pending(index) {
2499 continue;
2500 }
2501 let fires = match self.devices[index].trigger {
2502 Trigger::Time { .. }
2503 | Trigger::MotorDelay { .. }
2504 | Trigger::Burnout { .. } => trigger_times_s
2505 .get(index)
2506 .copied()
2507 .flatten()
2508 .is_some_and(|time| t >= time),
2509 Trigger::Apogee => sample.vertical_speed_m_s <= 0.0,
2510 Trigger::Altitude {
2511 height_above_ground_m,
2512 } => {
2513 sample.vertical_speed_m_s < 0.0
2514 && sample.height_above_ground_m <= height_above_ground_m
2515 }
2516 };
2517 if fires {
2518 let deploy_s = run.trigger(&self.devices, index, t);
2519 insert_stop(&mut stops, deploy_s, cap);
2520 events.push(BodyEvent {
2521 kind: EventKind::Trigger(index),
2522 sample,
2523 after: None,
2524 });
2525 again = true;
2526 }
2527 }
2528 for &index in &mine {
2529 if !run.waiting(index) || run.deploy_s(index) > t {
2530 continue;
2531 }
2532 if run.released_s(index).is_some_and(|released| released <= t) {
2533 run.abandon(index);
2534 again = true;
2535 continue;
2536 }
2537 let full_s = run.deploy(&self.devices, index, t, sample.airspeed_m_s);
2538 insert_stop(&mut stops, full_s, cap);
2539 events.push(BodyEvent {
2540 kind: EventKind::Deployment(index),
2541 sample: self.body_sample(body, mass_kg, t, &y, run)?,
2542 after: None,
2543 });
2544 again = true;
2545 }
2546 for &index in &mine {
2547 if run.release_due(index, t) {
2548 run.release(index);
2549 events.push(BodyEvent {
2550 kind: EventKind::Release(index),
2551 sample: self.body_sample(body, mass_kg, t, &y, run)?,
2552 after: None,
2553 });
2554 again = true;
2555 }
2556 }
2557 if !again {
2558 break;
2559 }
2560 }
2561
2562 // Its splits whose trigger has come part it at once, as the stack parts at the first
2563 // parting: each piece they make starts here, at this body's point and velocity plus
2564 // the pushes on its sides, and is flown after it. Which fire, and the way the pushes
2565 // point, are decided on the body as the pass starts, so neither depends on the order
2566 // the splits were given in (found in review: taking them one at a time let a push
2567 // delay a split, or share one charge's push with pieces another then parted). A split
2568 // that hasn't fired is asked again at the same instant, on the pushed body.
2569 let pending = split.pending(&leaders, body);
2570 let fires = |index: usize, sample: &BodySample| {
2571 let (trigger, time_s, _) = self.split(index);
2572 match trigger {
2573 Trigger::Time { .. } | Trigger::MotorDelay { .. } | Trigger::Burnout { .. } => {
2574 time_s.is_some_and(|time| t >= time)
2575 }
2576 Trigger::Apogee => sample.vertical_speed_m_s <= 0.0,
2577 Trigger::Altitude {
2578 height_above_ground_m,
2579 } => {
2580 sample.vertical_speed_m_s < 0.0
2581 && sample.height_above_ground_m <= height_above_ground_m
2582 }
2583 }
2584 };
2585 let before = if pending.is_empty() {
2586 None
2587 } else {
2588 Some(self.body_sample(body, mass_kg, t, &y, run)?)
2589 };
2590 let firing: Vec<usize> = before.map_or_else(Vec::new, |before| {
2591 pending
2592 .iter()
2593 .copied()
2594 .filter(|&index| fires(index, &before))
2595 .collect()
2596 });
2597 if let (Some(before), false) = (before, firing.is_empty()) {
2598 // A separation still to come here lights no motor: an ejection ahead of one that
2599 // would is refused in the ascent.
2600 for &index in &firing {
2601 split.open[index] = true;
2602 }
2603 leaders = split.pieces.leaders(split.open);
2604 let mass_of = |lead: usize| {
2605 checked_body_mass(
2606 split
2607 .pieces
2608 .mass_properties(
2609 |piece| leaders[piece] == lead,
2610 &self.vehicle.assembly,
2611 t,
2612 split.ignition_s,
2613 )
2614 .mass_kg,
2615 )
2616 };
2617 // This body, then the body each split makes, led by the split's piece.
2618 let mut parts = vec![Start {
2619 body,
2620 t_s: t,
2621 cg_enu_m: before.cg_enu_m,
2622 velocity_enu_m_s: before.cg_velocity_enu_m_s,
2623 mass_kg: mass_of(body)?,
2624 }];
2625 for &index in &firing {
2626 parts.push(Start {
2627 body: index + 1,
2628 mass_kg: mass_of(index + 1)?,
2629 ..parts[0]
2630 });
2631 }
2632 if firing
2633 .iter()
2634 .any(|&index| self.split_impulse_n_s(index) > 0.0)
2635 {
2636 let hanging =
2637 run.hung_before(&self.devices, |index| self.devices[index].body == body, t);
2638 let nose_ward =
2639 self.nose_ward_enu(before.cg_enu_m, before.cg_velocity_enu_m_s, hanging)?;
2640 let open = &*split.open;
2641 self.push_apart(
2642 (&firing, split.pieces, open, &leaders),
2643 nose_ward,
2644 &mut parts,
2645 t,
2646 )?;
2647 }
2648 mass_kg = parts[0].mass_kg;
2649 y[3] = parts[0].velocity_enu_m_s.x;
2650 y[4] = parts[0].velocity_enu_m_s.y;
2651 y[5] = parts[0].velocity_enu_m_s.z;
2652 let after = self.body_sample(body, mass_kg, t, &y, run)?;
2653 for &index in &firing {
2654 events.push(BodyEvent {
2655 kind: self.split(index).2,
2656 sample: before,
2657 after: Some(after),
2658 });
2659 }
2660 split.queue.extend(parts.into_iter().skip(1));
2661 // The mass steps here, and a push steps the velocity, so the integrator starts
2662 // afresh from the new state.
2663 integrator.reset(t, y)?;
2664 continue;
2665 }
2666
2667 let next = next_stop(&stops, t, cap);
2668 // The heights this body watches for: its devices' and its splits'.
2669 let mut watches: Vec<f64> = mine
2670 .iter()
2671 .filter(|index| run.pending(**index))
2672 .filter_map(|index| match self.devices[*index].trigger {
2673 Trigger::Altitude {
2674 height_above_ground_m,
2675 } => Some(height_above_ground_m),
2676 _ => None,
2677 })
2678 .collect();
2679 for &index in &pending {
2680 if let Trigger::Altitude {
2681 height_above_ground_m,
2682 } = self.split(index).0
2683 {
2684 watches.push(height_above_ground_m);
2685 }
2686 }
2687 // Every body watches for its own apogee: an apogee charge on a body separated while
2688 // climbing would never fire without it (found in review), and since the ascent ends
2689 // at the separation this is the only place a staged flight can record a peak.
2690 let apogee = events.iter().all(|event| event.kind != EventKind::Apogee);
2691 let mut system = BodySystem {
2692 simulation: self,
2693 body,
2694 mass_kg,
2695 run,
2696 apogee,
2697 watches: &watches,
2698 failure: None,
2699 };
2700 let outcome = integrator.advance(&mut system, next);
2701 if let Some(error) = system.failure.take() {
2702 return Err(error);
2703 }
2704 let outcome = match outcome {
2705 Ok(outcome) => outcome,
2706 Err(IntegrationError::StepLimit { .. }) => break Termination::StepLimit,
2707 Err(IntegrationError::Derivative { source, .. }) => return Err(source),
2708 Err(error) => return Err(SimError::Integration(Box::new(error))),
2709 };
2710 if let Advance::Events = outcome {
2711 let fired = integrator.fired_events().to_vec();
2712 let t = integrator.time_s();
2713 let y = *integrator.state();
2714 if apogee && fired.contains(&1) {
2715 // The body's own apogee: the ascent's ended at the separation, so this is the
2716 // only place a staged flight can record one.
2717 events.push(BodyEvent {
2718 kind: EventKind::Apogee,
2719 sample: self.body_sample(body, mass_kg, t, &y, run)?,
2720 after: None,
2721 });
2722 }
2723 if fired.contains(&0) {
2724 events.push(BodyEvent {
2725 kind: EventKind::GroundHit,
2726 sample: self.body_sample(body, mass_kg, t, &y, run)?,
2727 after: None,
2728 });
2729 break Termination::GroundHit;
2730 }
2731 }
2732 };
2733
2734 let t = integrator.time_s();
2735 let y = *integrator.state();
2736 let final_sample = self.body_sample(body, mass_kg, t, &y, run)?;
2737 if termination == Termination::GroundHit
2738 && !mine
2739 .iter()
2740 .any(|&index| run.devices[index].deployed_s.is_some())
2741 {
2742 // Every body must carry a device, and its device must actually open: a trigger that
2743 // never becomes true (a timer set after the body lands, say) would
2744 // otherwise drop the body with no drag at all, which is a wrong number rather than a
2745 // missing feature (found in review). A device that opened on the stack before the
2746 // separation counts: its deployment is in the flight's events, not the body's.
2747 return Err(SimError::Domain {
2748 what: "a separated body reached the ground with no device open (its triggers \
2749 never fired); the body",
2750 value: body as f64,
2751 });
2752 }
2753 let pieces: Vec<usize> = (0..leaders.len())
2754 .filter(|piece| leaders[*piece] == body)
2755 .collect();
2756 Ok(BodyFlight {
2757 body,
2758 stages: split.pieces.stages(|piece| leaders[piece] == body),
2759 pieces,
2760 mass_kg,
2761 start_sample,
2762 termination,
2763 events,
2764 final_sample,
2765 stats: integrator.stats(),
2766 impulse_left_n_s,
2767 })
2768 }
2769
2770 /// One separated body at `(t, y)`, where `y` is its center of mass and that point's velocity.
2771 fn body_sample(
2772 &self,
2773 body: usize,
2774 mass_kg: f64,
2775 t: f64,
2776 y: &[f64; 6],
2777 run: &Run,
2778 ) -> Result<BodySample, SimError> {
2779 self.point_sample(t, y, mass_kg, run.body_drag_area_m2(&self.devices, body, t))
2780 }
2781
2782 /// A point mass of `mass_kg` under drag area `drag_area_m2` at `(t, y)`, where `y` is its
2783 /// position and velocity in the launch frame: a separated body, or a released part.
2784 pub(crate) fn point_sample(
2785 &self,
2786 t: f64,
2787 y: &[f64; 6],
2788 mass_kg: f64,
2789 drag_area_m2: f64,
2790 ) -> Result<BodySample, SimError> {
2791 let cg_enu_m = DVec3::new(y[0], y[1], y[2]);
2792 let velocity_enu_m_s = DVec3::new(y[3], y[4], y[5]);
2793 let frame = self.environment.earth.frame();
2794 let height_above_ground_m =
2795 frame.geodetic_from_enu(cg_enu_m)?.height_m - frame.origin().height_m;
2796 let (up_enu, wind_enu) = self.up_and_wind_enu(cg_enu_m)?;
2797 Ok(BodySample {
2798 time_s: t,
2799 cg_enu_m,
2800 cg_velocity_enu_m_s: velocity_enu_m_s,
2801 height_above_ground_m,
2802 vertical_speed_m_s: up_enu.dot(velocity_enu_m_s),
2803 airspeed_m_s: (velocity_enu_m_s - wind_enu).length(),
2804 recovery_drag_area_m2: drag_area_m2,
2805 mass_kg,
2806 })
2807 }
2808
2809 /// The rates of a point mass of `mass_kg` under drag area `drag_area_m2`, whose position and
2810 /// velocity in the launch frame are `y`: a separated body, or a released part. The equations
2811 /// are the descent phase's (`docs/physics/recovery.md`) with no thrust and no airframe:
2812 /// `m a = −½ ρ (C_D S) |v − w| (v − w) + m (g + a_Coriolis)`.
2813 pub(crate) fn point_mass_derivative(
2814 &self,
2815 y: &[f64; 6],
2816 mass_kg: f64,
2817 drag_area_m2: f64,
2818 ) -> Result<[f64; 6], SimError> {
2819 let cg_enu_m = DVec3::new(y[0], y[1], y[2]);
2820 let velocity_enu_m_s = DVec3::new(y[3], y[4], y[5]);
2821 let environment = &self.environment;
2822 let frame = environment.earth.frame();
2823 let geodetic = frame.geodetic_from_enu(cg_enu_m)?;
2824 let height_msl_m = geodetic.height_m - environment.geoid_undulation_m;
2825 let air = environment.air_at(height_msl_m)?;
2826 let wind_enu = environment.wind_enu_m_s(height_msl_m)?;
2827 let gravity_enu = environment.earth.gravity_enu_mps2(cg_enu_m)?;
2828 let coriolis_enu = environment
2829 .earth
2830 .rotation_acceleration_enu_mps2(velocity_enu_m_s);
2831 let air_velocity = velocity_enu_m_s - wind_enu;
2832 let speed = air_velocity.length();
2833 let drag_enu = if air.density_kg_m3 > 0.0 && speed > 0.0 && drag_area_m2 > 0.0 {
2834 air_velocity * (-0.5 * air.density_kg_m3 * drag_area_m2 * speed / mass_kg)
2835 } else {
2836 DVec3::ZERO
2837 };
2838 let acceleration = drag_enu + gravity_enu + coriolis_enu;
2839 Ok([
2840 velocity_enu_m_s.x,
2841 velocity_enu_m_s.y,
2842 velocity_enu_m_s.z,
2843 acceleration.x,
2844 acceleration.y,
2845 acceleration.z,
2846 ])
2847 }
2848
2849 /// The way the nose of a body with no attitude of its own is taken to point, as a unit vector
2850 /// in the launch frame, for the push of an ejection (ADR-086): a body `hanging` from a device
2851 /// points its forward end against its velocity through the air, toward the device, assumed to
2852 /// have left through that end; any other points its nose along that velocity, as a stable
2853 /// airframe does. Below [`STILL_AIR_M_S`] that velocity is drift or round-off rather
2854 /// than a flight path, and the nose is taken to point up.
2855 fn nose_ward_enu(
2856 &self,
2857 cg_enu_m: DVec3,
2858 velocity_enu_m_s: DVec3,
2859 hanging: bool,
2860 ) -> Result<DVec3, SimError> {
2861 let (up_enu, wind_enu) = self.up_and_wind_enu(cg_enu_m)?;
2862 let through_air = velocity_enu_m_s - wind_enu;
2863 let speed_m_s = through_air.length();
2864 Ok(if speed_m_s < STILL_AIR_M_S {
2865 up_enu
2866 } else if hanging {
2867 -through_air / speed_m_s
2868 } else {
2869 through_air / speed_m_s
2870 })
2871 }
2872
2873 /// The local vertical and the wind at the point `cg_enu_m` of the launch frame.
2874 fn up_and_wind_enu(&self, cg_enu_m: DVec3) -> Result<(DVec3, DVec3), SimError> {
2875 let frame = self.environment.earth.frame();
2876 let geodetic = frame.geodetic_from_enu(cg_enu_m)?;
2877 let up_ecef = DVec3::new(
2878 geodetic.latitude_rad.cos() * geodetic.longitude_rad.cos(),
2879 geodetic.latitude_rad.cos() * geodetic.longitude_rad.sin(),
2880 geodetic.latitude_rad.sin(),
2881 );
2882 let up_enu = frame.ecef_from_enu_rotation().transpose() * up_ecef;
2883 let height_msl_m = geodetic.height_m - self.environment.geoid_undulation_m;
2884 let wind_enu = self.environment.wind_enu_m_s(height_msl_m)?;
2885 Ok((up_enu, wind_enu))
2886 }
2887
2888 /// Whether the airframe can come apart: it has a separation or an ejection.
2889 fn parts(&self) -> bool {
2890 !self.separations.is_empty() || !self.ejections.is_empty()
2891 }
2892
2893 /// How many bodies the airframe can come apart into: one per piece.
2894 fn body_count(&self) -> usize {
2895 1 + self.separations.len() + self.ejections.len()
2896 }
2897
2898 /// Pushes apart the bodies in `parts` (each a body's start, by its lead piece) at the splits
2899 /// `firing`, all parting at `t`: each split's own piece's body takes `±J` along `nose_ward`,
2900 /// `+J` if the piece is forward of the split, and the body on its other side the opposite,
2901 /// each over its own mass, so the momentum is unchanged (ADR-086). `open` says which splits
2902 /// have happened, these among them, and `leaders` gives each piece's body after them.
2903 ///
2904 /// # Errors
2905 ///
2906 /// [`SimError::Domain`] for a pushed payload whose section's forward joint hasn't parted, so
2907 /// that it has no way out forward.
2908 fn push_apart(
2909 &self,
2910 (firing, pieces, open, leaders): (&[usize], &Pieces, &[bool], &[usize]),
2911 nose_ward: DVec3,
2912 parts: &mut [Start],
2913 t: f64,
2914 ) -> Result<(), SimError> {
2915 for &index in firing {
2916 let impulse_n_s = self.split_impulse_n_s(index);
2917 if impulse_n_s == 0.0 {
2918 continue;
2919 }
2920 let piece = index + 1;
2921 if let Some(host) = pieces.host(piece)
2922 && !(host > 0 && open[host - 1])
2923 {
2924 return Err(SimError::Domain {
2925 what: "time of a pushed payload's ejection (it leaves forward, and the joint \
2926 forward of the section that carries it has not parted by then)",
2927 value: t,
2928 });
2929 }
2930 let (other, forward) = pieces.across(piece);
2931 let push = nose_ward * if forward { impulse_n_s } else { -impulse_n_s };
2932 for (lead, push) in [(leaders[piece], push), (leaders[other], -push)] {
2933 let part = parts.iter_mut().find(|part| part.body == lead);
2934 // Both sides of a split that fires here fly: only a sustainer's body is left out,
2935 // and a powered separation is refused with ejections, whose splits alone push.
2936 debug_assert!(part.is_some(), "a pushed body {lead} doesn't fly");
2937 if let Some(part) = part {
2938 part.velocity_enu_m_s += push / part.mass_kg;
2939 }
2940 }
2941 }
2942 Ok(())
2943 }
2944
2945 /// The impulse of split `split`, in piece order, N·s: an ejection's own, and none for a
2946 /// separation.
2947 fn split_impulse_n_s(&self, split: usize) -> f64 {
2948 match split.checked_sub(self.separations.len()) {
2949 Some(ejection) => self.ejections[ejection].impulse_n_s,
2950 None => 0.0,
2951 }
2952 }
2953
2954 /// Split `split` of the flight, in piece order (the separations first, then the ejections):
2955 /// its trigger, its time when that is known before the flight, and its event.
2956 fn split(&self, split: usize) -> (Trigger, Option<f64>, EventKind) {
2957 match self.separations.get(split) {
2958 Some(separation) => (
2959 separation.trigger,
2960 self.separation_times_s[split],
2961 EventKind::Separation,
2962 ),
2963 None => {
2964 let ejection = split - self.separations.len();
2965 (
2966 self.ejections[ejection].trigger,
2967 self.ejection_times_s[ejection],
2968 EventKind::Ejection(ejection),
2969 )
2970 }
2971 }
2972 }
2973
2974 /// The drag area acting on the stack before it comes apart, m²: body 0's devices, which with
2975 /// no separation or ejection is all of them.
2976 fn ascent_drag_area_m2(&self, run: &Run, t: f64) -> f64 {
2977 if self.parts() {
2978 run.body_drag_area_m2(&self.devices, 0, t)
2979 } else {
2980 run.drag_area_m2(&self.devices, t)
2981 }
2982 }
2983
2984 /// Whether device `index` acts on the stack before it comes apart: only body 0's do.
2985 fn acts_before_separation(&self, index: usize) -> bool {
2986 !self.parts() || self.devices[index].body == 0
2987 }
2988
2989 /// What the integrator watches for in `phase`, in event order.
2990 fn watches(
2991 &self,
2992 phase: Phase,
2993 run: &Run,
2994 (separation_pending, apogee_pending): (Option<usize>, bool),
2995 (shift_started, released): (&[bool], &[bool]),
2996 ) -> Vec<Watch> {
2997 match phase {
2998 Phase::Pad => vec![Watch::RailForce],
2999 Phase::Rail => vec![Watch::RailExit, Watch::RailStall],
3000 Phase::Free | Phase::Descent => {
3001 let mut watches = if apogee_pending {
3002 vec![Watch::Apogee, Watch::Ground]
3003 } else {
3004 vec![Watch::Ground]
3005 };
3006 for (index, device) in self.devices.iter().enumerate() {
3007 if matches!(device.trigger, Trigger::Altitude { .. })
3008 && run.pending(index)
3009 && self.acts_before_separation(index)
3010 {
3011 watches.push(Watch::Altitude(index));
3012 }
3013 }
3014 // The next separation's own height, so it is located rather than polled at the
3015 // next boundary that happens to exist.
3016 if let Some((next, Trigger::Altitude { .. })) = separation_pending
3017 .and_then(|next| Some((next, self.separations.get(next)?.trigger)))
3018 {
3019 watches.push(Watch::SeparationHeight(next));
3020 }
3021 if separation_pending == Some(0) {
3022 for (index, ejection) in self.ejections.iter().enumerate() {
3023 if matches!(ejection.trigger, Trigger::Altitude { .. }) {
3024 watches.push(Watch::EjectionHeight(index));
3025 }
3026 }
3027 }
3028 for (index, shift) in self.shifts.iter().enumerate() {
3029 if matches!(shift.trigger, Trigger::Altitude { .. })
3030 && !shift_started.get(index).copied().unwrap_or(true)
3031 {
3032 watches.push(Watch::ShiftHeight(index));
3033 }
3034 }
3035 for (index, release) in self.releases.iter().enumerate() {
3036 if matches!(release.trigger, Trigger::Altitude { .. })
3037 && !released.get(index).copied().unwrap_or(true)
3038 {
3039 watches.push(Watch::ReleaseHeight(index));
3040 }
3041 }
3042 watches.extend((0..self.user_events.len()).map(Watch::User));
3043 watches
3044 }
3045 }
3046 }
3047}
3048
3049/// Whether `stages[first..=last]` hang together as one dropped body: each after the first hangs on
3050/// one of them, a parallel stage on the stage it is hung on ([`hpr_design::PlacedStage::hung_on`]),
3051/// an axial stage on the axial stage ahead of it. A separation's boundary drops every stage after
3052/// it, so a parallel stage hung on a stage the nose keeps must not lie behind a stage it drops
3053/// with it (the decision record on parallel stages, [ADR-171][adr-171]).
3054///
3055/// [adr-171]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-171-a-parallel-stage-is-a-pod-set-that-separates-2026-10-05
3056fn hangs_together(stages: &[hpr_design::PlacedStage], first: usize, last: usize) -> bool {
3057 // The axial stage ahead of each stage, found in one pass.
3058 let mut axial_ahead = None;
3059 for (index, stage) in stages.iter().enumerate().take(last + 1) {
3060 let carrier = stage.hung_on.or(axial_ahead);
3061 if index > first && !carrier.is_some_and(|carrier| (first..=last).contains(&carrier)) {
3062 return false;
3063 }
3064 if stage.hung_on.is_none() {
3065 axial_ahead = Some(index);
3066 }
3067 }
3068 true
3069}
3070
3071/// Checks the devices against the `bodies` a separation and ejections make, whichever builder ran
3072/// last: one when there is neither.
3073///
3074/// When the airframe can part, every body needs at least one device, and no device may name a
3075/// body that doesn't exist. Without a parting there is only body 0, so a device that names another
3076/// would have its drag area counted on the whole rocket and never be flown on a body of its own.
3077/// A builder checks only the first (`complete` false): a separation or ejection given after it
3078/// can still make the body a device names, and an ejection's number counts the separation.
3079fn check_bodies(devices: &[Device], bodies: usize, complete: bool) -> Result<(), SimError> {
3080 if let Some(device) = devices
3081 .iter()
3082 .find(|device| complete && device.body >= bodies)
3083 {
3084 return Err(SimError::Domain {
3085 what: if bodies > 1 {
3086 "body a device is attached to (the separation and ejections don't make it)"
3087 } else {
3088 "body a device is attached to (there is no separation or ejection, so there is \
3089 only body 0)"
3090 },
3091 value: device.body as f64,
3092 });
3093 }
3094 if bodies > 1 {
3095 for body in 0..bodies {
3096 if !devices.iter().any(|device| device.body == body) {
3097 return Err(SimError::Domain {
3098 what: "recovery devices on a separated body (every body needs one: a descent \
3099 has no airframe drag)",
3100 value: body as f64,
3101 });
3102 }
3103 }
3104 }
3105 Ok(())
3106}
3107
3108/// The first stop after `t`, or `cap`.
3109fn next_stop(stops: &[f64], t: f64, cap: f64) -> f64 {
3110 stops.iter().copied().find(|stop| *stop > t).unwrap_or(cap)
3111}
3112
3113/// The evaluation for the recovery scan, made once per pass and reused.
3114fn cached(
3115 cache: &mut Option<Evaluation>,
3116 evaluate: impl FnOnce() -> Result<Evaluation, SimError>,
3117) -> Result<Evaluation, SimError> {
3118 if let Some(evaluation) = cache {
3119 return Ok(*evaluation);
3120 }
3121 let evaluation = evaluate()?;
3122 *cache = Some(evaluation);
3123 Ok(evaluation)
3124}
3125
3126/// Adds `t` to the sorted stop times, unless it is past `cap` or already there.
3127fn insert_stop(stops: &mut Vec<f64>, t: f64, cap: f64) {
3128 if !t.is_finite() || t > cap {
3129 return;
3130 }
3131 match stops.binary_search_by(|stop| stop.total_cmp(&t)) {
3132 Ok(_) => {}
3133 Err(index) => stops.insert(index, t),
3134 }
3135}
3136
3137/// What the integrator watches for, in the order the events are numbered.
3138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3139enum Watch {
3140 /// The pad's force margin rising through zero: liftoff.
3141 RailForce,
3142 /// The travel along the rail reaching the exit travel.
3143 RailExit,
3144 /// The speed along the rail falling through zero: a stall.
3145 RailStall,
3146 /// The center of mass's height rate falling through zero: apogee.
3147 Apogee,
3148 /// The center of mass reaching the ground, descending.
3149 Ground,
3150 /// A device's deployment height, descending.
3151 Altitude(usize),
3152 /// A separation's height, descending, by its index.
3153 SeparationHeight(usize),
3154 /// An ejection's height, descending, by its index.
3155 EjectionHeight(usize),
3156 /// A mass shift's height, descending, by its index.
3157 ShiftHeight(usize),
3158 /// A mass release's height, descending, by its index.
3159 ReleaseHeight(usize),
3160 /// A user event.
3161 User(usize),
3162}
3163
3164/// Where a body's own flight starts: its number, the time, its center of mass and that point's
3165/// velocity, and its mass.
3166#[derive(Debug, Clone, Copy)]
3167struct Start {
3168 body: usize,
3169 t_s: f64,
3170 cg_enu_m: DVec3,
3171 velocity_enu_m_s: DVec3,
3172 mass_kg: f64,
3173}
3174
3175/// What the bodies' flights share about the airframe's parting: its pieces, which splits have
3176/// happened, the motors' ignitions, and the bodies still to fly.
3177struct Split<'a> {
3178 pieces: &'a Pieces,
3179 open: &'a mut Vec<bool>,
3180 ignition_s: &'a [Option<f64>],
3181 queue: &'a mut std::collections::VecDeque<Start>,
3182}
3183
3184impl Split<'_> {
3185 /// The splits still to happen inside `body`, given each piece's body in `leaders`: those whose
3186 /// piece is still joined to it.
3187 fn pending(&self, leaders: &[usize], body: usize) -> Vec<usize> {
3188 (0..self.open.len())
3189 .filter(|&index| !self.open[index] && leaders[index + 1] == body)
3190 .collect()
3191 }
3192}
3193
3194/// Takes `part` out of `assembly`'s structure: a part released in flight.
3195fn lighten(assembly: &mut hpr_design::Assembly, part: &hpr_design::MassProperties) {
3196 assembly.layout.structure = assembly.layout.structure.without_part(part);
3197}
3198
3199/// A body's mass, refused unless it is finite and positive.
3200fn checked_body_mass(mass_kg: f64) -> Result<f64, SimError> {
3201 if mass_kg.is_finite() && mass_kg > 0.0 {
3202 Ok(mass_kg)
3203 } else {
3204 Err(SimError::Domain {
3205 what: "mass of a separated body, kg",
3206 value: mass_kg,
3207 })
3208 }
3209}
3210
3211/// One separated body as the integrator sees it: a point mass under its open devices' drag area,
3212/// with its center of mass and that point's velocity as the state.
3213///
3214/// The equations are the descent phase's (`docs/physics/recovery.md`) with no thrust and no
3215/// airframe: `m a = −½ ρ (C_D S)(t) |v − w| (v − w) + m (g + a_Coriolis)`.
3216struct BodySystem<'a> {
3217 simulation: &'a Simulation,
3218 body: usize,
3219 mass_kg: f64,
3220 run: &'a Run,
3221 /// Whether the body is watching for its own apogee, which is event 1 when it is.
3222 apogee: bool,
3223 /// The heights this body is watching for, after the apogee: its devices' deployment heights
3224 /// and its splits'.
3225 watches: &'a [f64],
3226 failure: Option<SimError>,
3227}
3228
3229impl BodySystem<'_> {
3230 /// The height above the site and the drag acceleration at `(t, y)`.
3231 fn sample(&self, t_s: f64, y: &[f64; 6]) -> Result<BodySample, SimError> {
3232 self.simulation
3233 .body_sample(self.body, self.mass_kg, t_s, y, self.run)
3234 }
3235}
3236
3237impl OdeSystem<6> for BodySystem<'_> {
3238 type Error = SimError;
3239
3240 fn derivative(&mut self, t_s: f64, y: &[f64; 6]) -> Result<[f64; 6], SimError> {
3241 let drag_area_m2 = self
3242 .run
3243 .body_drag_area_m2(&self.simulation.devices, self.body, t_s);
3244 self.simulation
3245 .point_mass_derivative(y, self.mass_kg, drag_area_m2)
3246 }
3247
3248 fn event_count(&self) -> usize {
3249 1 + usize::from(self.apogee) + self.watches.len()
3250 }
3251
3252 fn event_direction(&self, _index: usize) -> Direction {
3253 Direction::Falling
3254 }
3255
3256 fn event_value(&mut self, index: usize, t_s: f64, y: &[f64; 6]) -> f64 {
3257 let sample = match self.sample(t_s, y) {
3258 Ok(sample) => sample,
3259 Err(error) => {
3260 self.failure.get_or_insert(error);
3261 return f64::NAN;
3262 }
3263 };
3264 // The ground, then this body's apogee if it is looking for one, then each watched
3265 // device's deployment height. Every one is a falling crossing, so a device fires on the
3266 // way down only.
3267 if index == 0 {
3268 return sample.height_above_ground_m;
3269 }
3270 if self.apogee && index == 1 {
3271 return sample.vertical_speed_m_s;
3272 }
3273 let watch = index - 1 - usize::from(self.apogee);
3274 self.watches
3275 .get(watch)
3276 .map_or(f64::NAN, |height_m| sample.height_above_ground_m - height_m)
3277 }
3278}
3279
3280/// The recovery devices of a flight and their progress through it.
3281#[derive(Debug, Clone, Copy)]
3282struct Canopies<'a> {
3283 devices: &'a [Device],
3284 run: &'a Run,
3285 /// The body whose devices act, when a separation means only some of them do. `None` is all
3286 /// of them, which is what a flight without a separation has.
3287 body: Option<usize>,
3288}
3289
3290impl Canopies<'_> {
3291 /// The drag area at `t` of the devices that act on what is being flown, m². It has to match
3292 /// the loop's `ascent_drag_area_m2`, because this is what the equations and the recorded rows
3293 /// see.
3294 fn drag_area_m2(&self, t: f64) -> f64 {
3295 match self.body {
3296 Some(body) => self.run.body_drag_area_m2(self.devices, body, t),
3297 None => self.run.drag_area_m2(self.devices, t),
3298 }
3299 }
3300}
3301
3302fn sample_of(phase: Phase, t: f64, y: &[f64; STATE_LEN], e: &Evaluation) -> Sample {
3303 Sample {
3304 time_s: t,
3305 phase,
3306 state: State::from_array(y),
3307 cg_enu_m: e.cg_enu_m,
3308 cg_velocity_enu_m_s: e.cg_velocity_enu_m_s,
3309 height_above_ground_m: e.height_above_ground_m,
3310 vertical_speed_m_s: e.vertical_speed_m_s,
3311 acceleration_enu_m_s2: e.acceleration_enu_m_s2,
3312 airspeed_m_s: e.airspeed_m_s,
3313 mach: e.mach,
3314 angle_of_attack_rad: e.angle_of_attack_rad,
3315 dynamic_pressure_pa: e.dynamic_pressure_pa,
3316 axial_coefficient: e.axial_coefficient,
3317 thrust_n: e.thrust_n,
3318 mass_kg: e.mass.mass_kg,
3319 recovery_drag_area_m2: e.recovery_drag_area_m2,
3320 }
3321}
3322
3323/// One phase over one interval, as the integrator sees it.
3324struct PhaseSystem<'a> {
3325 simulation: &'a Simulation,
3326 vehicle: &'a Vehicle,
3327 phase: Phase,
3328 window: (f64, f64),
3329 rail_origin: DVec3,
3330 exit_travel_m: f64,
3331 canopies: Canopies<'a>,
3332 watches: &'a [Watch],
3333 observer: &'a mut dyn Observer,
3334 /// An error from an event function or the observer, returned after the integrator stops.
3335 failure: Option<SimError>,
3336 /// The last evaluation: the integrator evaluates the derivative at a step's end before the
3337 /// events there.
3338 cache: Option<(f64, [f64; STATE_LEN], Evaluation)>,
3339}
3340
3341impl PhaseSystem<'_> {
3342 /// The evaluation at `(t, y)`, from the cache when it holds that state. A reference, not a
3343 /// copy: an evaluation is a few hundred bytes, and the integrator asks for one at every
3344 /// stage and event check.
3345 fn evaluation(&mut self, t: f64, y: &[f64; STATE_LEN]) -> Result<&Evaluation, SimError> {
3346 if !matches!(&self.cache, Some((ct, cy, _)) if *ct == t && cy == y) {
3347 self.cache = None;
3348 }
3349 let (_, _, evaluation) = match &mut self.cache {
3350 Some(entry) => entry,
3351 slot @ None => slot.insert((
3352 t,
3353 *y,
3354 self.simulation.evaluate(
3355 self.vehicle,
3356 self.phase,
3357 self.window,
3358 t,
3359 y,
3360 self.canopies.drag_area_m2(t),
3361 )?,
3362 )),
3363 };
3364 Ok(evaluation)
3365 }
3366
3367 /// An event value, or NaN with the error kept for the caller.
3368 fn or_fail(&mut self, value: Result<f64, SimError>) -> f64 {
3369 match value {
3370 Ok(value) => value,
3371 Err(error) => {
3372 self.failure.get_or_insert(error);
3373 f64::NAN
3374 }
3375 }
3376 }
3377}
3378
3379impl OdeSystem<STATE_LEN> for PhaseSystem<'_> {
3380 type Error = SimError;
3381
3382 fn derivative(&mut self, t_s: f64, y: &[f64; STATE_LEN]) -> Result<[f64; STATE_LEN], SimError> {
3383 self.evaluation(t_s, y).map(|e| e.derivative)
3384 }
3385
3386 fn event_count(&self) -> usize {
3387 self.watches.len()
3388 }
3389
3390 fn event_direction(&self, index: usize) -> Direction {
3391 match self.watches.get(index) {
3392 Some(Watch::RailForce | Watch::RailExit) => Direction::Rising,
3393 Some(
3394 Watch::RailStall
3395 | Watch::Apogee
3396 | Watch::Ground
3397 | Watch::Altitude(_)
3398 | Watch::SeparationHeight(_)
3399 | Watch::EjectionHeight(_)
3400 | Watch::ShiftHeight(_)
3401 | Watch::ReleaseHeight(_),
3402 ) => Direction::Falling,
3403 Some(Watch::User(user)) => self
3404 .simulation
3405 .user_events
3406 .get(*user)
3407 .map_or(Direction::Either, |event| event.direction),
3408 None => Direction::Either,
3409 }
3410 }
3411
3412 fn event_value(&mut self, index: usize, t_s: f64, y: &[f64; STATE_LEN]) -> f64 {
3413 let state = State::from_array(y);
3414 let Some(watch) = self.watches.get(index).copied() else {
3415 return f64::NAN;
3416 };
3417 match watch {
3418 Watch::RailExit => {
3419 let along = self.simulation.rail.direction_enu();
3420 (state.position_enu_m - self.rail_origin).dot(along) - self.exit_travel_m
3421 }
3422 Watch::RailStall => state
3423 .velocity_enu_m_s
3424 .dot(self.simulation.rail.direction_enu()),
3425 Watch::Apogee => {
3426 let value = self.evaluation(t_s, y).map(|e| e.vertical_speed_m_s);
3427 self.or_fail(value)
3428 }
3429 Watch::Ground => {
3430 let value = self.evaluation(t_s, y).map(|e| e.height_above_ground_m);
3431 self.or_fail(value)
3432 }
3433 Watch::Altitude(device) => {
3434 let height_m = match self.simulation.devices.get(device).map(|d| d.trigger) {
3435 Some(Trigger::Altitude {
3436 height_above_ground_m,
3437 }) => height_above_ground_m,
3438 _ => return f64::NAN,
3439 };
3440 let value = self
3441 .evaluation(t_s, y)
3442 .map(|e| e.height_above_ground_m - height_m);
3443 self.or_fail(value)
3444 }
3445 Watch::SeparationHeight(_)
3446 | Watch::EjectionHeight(_)
3447 | Watch::ShiftHeight(_)
3448 | Watch::ReleaseHeight(_) => {
3449 let trigger = match watch {
3450 Watch::EjectionHeight(index) => {
3451 self.simulation.ejections.get(index).map(|e| e.trigger)
3452 }
3453 Watch::ShiftHeight(index) => {
3454 self.simulation.shifts.get(index).map(|s| s.trigger)
3455 }
3456 Watch::ReleaseHeight(index) => {
3457 self.simulation.releases.get(index).map(|r| r.trigger)
3458 }
3459 Watch::SeparationHeight(index) => {
3460 self.simulation.separations.get(index).map(|s| s.trigger)
3461 }
3462 _ => None,
3463 };
3464 let height_m = match trigger {
3465 Some(Trigger::Altitude {
3466 height_above_ground_m,
3467 }) => height_above_ground_m,
3468 _ => return f64::NAN,
3469 };
3470 let value = self
3471 .evaluation(t_s, y)
3472 .map(|e| e.height_above_ground_m - height_m);
3473 self.or_fail(value)
3474 }
3475 Watch::User(user) => {
3476 let phase = self.phase;
3477 let value = self.evaluation(t_s, y).map(|e| sample_of(phase, t_s, y, e));
3478 match value {
3479 Ok(sample) => self
3480 .simulation
3481 .user_events
3482 .get(user)
3483 .map_or(f64::NAN, |event| (event.function)(&sample)),
3484 Err(error) => self.or_fail(Err(error)),
3485 }
3486 }
3487 Watch::RailForce => {
3488 let value = self.evaluation(t_s, y).map(|e| e.rail_force_n);
3489 self.or_fail(value)
3490 }
3491 }
3492 }
3493
3494 fn accept_step(&mut self, step: &Step<STATE_LEN>) -> ControlFlow<()> {
3495 let view = StepView {
3496 simulation: self.simulation,
3497 vehicle: self.vehicle,
3498 phase: self.phase,
3499 window: self.window,
3500 canopies: self.canopies,
3501 step,
3502 };
3503 match self.observer.step(&view) {
3504 Ok(()) => ControlFlow::Continue(()),
3505 Err(error) => {
3506 self.failure.get_or_insert(error);
3507 ControlFlow::Break(())
3508 }
3509 }
3510 }
3511}
3512
3513/// An accepted step as the observer sees it.
3514struct StepView<'a> {
3515 simulation: &'a Simulation,
3516 vehicle: &'a Vehicle,
3517 phase: Phase,
3518 window: (f64, f64),
3519 canopies: Canopies<'a>,
3520 step: &'a Step<STATE_LEN>,
3521}
3522
3523impl FlightStep for StepView<'_> {
3524 fn phase(&self) -> Phase {
3525 self.phase
3526 }
3527
3528 fn start_s(&self) -> f64 {
3529 self.step.start_s()
3530 }
3531
3532 fn end_s(&self) -> f64 {
3533 self.step.end_s()
3534 }
3535
3536 fn state_at(&self, t_s: f64) -> State {
3537 State::from_array(&self.step.state_at(t_s))
3538 }
3539
3540 fn sample(&self, t_s: f64) -> Result<Sample, SimError> {
3541 self.simulation.sample(
3542 self.vehicle,
3543 self.phase,
3544 self.window,
3545 t_s,
3546 &self.step.state_at(t_s),
3547 self.canopies.drag_area_m2(t_s),
3548 )
3549 }
3550
3551 fn stability(&self, t_s: f64) -> Result<Stability, SimError> {
3552 let e = self.simulation.evaluate(
3553 self.vehicle,
3554 self.phase,
3555 self.window,
3556 t_s,
3557 &self.step.state_at(t_s),
3558 self.canopies.drag_area_m2(t_s),
3559 )?;
3560 crate::metrics::stability(
3561 &self.vehicle.aero,
3562 t_s,
3563 e.height_above_ground_m,
3564 e.dynamic_pressure_pa,
3565 -e.mass.cg_m.z,
3566 e.mach,
3567 )
3568 }
3569}