Skip to main content

hpr_analysis/
montecarlo.rs

1//! Monte Carlo dispersion: one rocket flown many times, each time with its uncertain inputs drawn
2//! afresh, to see how far its apogee and landing spread.
3//!
4//! **Guide:** [Monte Carlo dispersion][guide] explains what each dispersion does, with a worked
5//! run, and how far to trust the spread it gives.
6//!
7//! [guide]: https://nrdptel.github.io/hpr-sim/monte-carlo.html
8//!
9//! A [`MonteCarlo`] holds the nominal flight ([`FlightInputs`]) and a [`Dispersion`]: a standard
10//! deviation for each uncertain input. Each sample draws every dispersed input from a normal
11//! distribution about its nominal value, as RocketPy's stochastic classes do by default
12//! (RocketPy 1.13.0's `rocketpy/stochastic/stochastic_model.py:190-199`, a `(nominal, standard deviation)` pair is a normal
13//! distribution), flies the flight, and keeps what it drew and what the flight came to
14//! ([`Sample`]). A [`Run`] is the samples in order; [`Run::apogee`] is the spread of their
15//! apogees, with the samples that failed counted, not dropped, and [`Run::landing`] where they
16//! landed, whose ellipses are in [`crate::ellipse`].
17//!
18//! # Reproducibility
19//!
20//! Every number a sample draws comes from its own stream, keyed by the run's seed, the sample's
21//! index, the input and, for an input with several copies, which copy
22//! ([`SeededRng::for_stream`]). So sample `k` is the same flight whatever the number of samples,
23//! however they are spread over threads (`MonteCarlo::run_parallel`, with the `parallel`
24//! feature), and whichever other inputs are dispersed: turning on a drag dispersion doesn't
25//! change the wind a sample flies. On one platform a run is bit for bit the same every time.
26//!
27//! # What each dispersion does
28//!
29//! Each is a standard deviation, zero (the default) for an input left at its nominal value. A
30//! dispersion of zero draws nothing and changes nothing, so a run with no dispersion flies the
31//! nominal flight in every sample, bit for bit.
32//!
33//! | Field | Each sample flies |
34//! |---|---|
35//! | `dry_mass_sd_fraction` | each stage's mass without motors times `1 + σ z`, its inertia scaled with it ([`hpr_design::Overrides`]) |
36//! | `cg_sd_m` | each stage's center of mass moved `σ z` aft (forward when negative), its inertia about the center kept |
37//! | `drag_sd_fraction` | the rocket's zero-lift drag coefficient times `1 + σ z` ([`Simulation::with_drag_scale`]) |
38//! | `impulse_sd_fraction` | each motor's thrust and propellant mass both times `1 + σ z`, so its specific impulse is kept ([`dispersed_motor`]) |
39//! | `burn_time_sd_fraction` | each motor's thrust curve stretched in time by `1 + σ z`, its thrust divided by the same, so its impulse is kept |
40//! | `ejection_delay_sd_s` | each motor's ejection delay plus `σ z` seconds, not below zero |
41//! | `wind_speed_sd_fraction` | the wind at every height times `1 + σ z`, calm below zero ([`DispersedWind`]) |
42//! | `wind_heading_sd_rad` | the wind at every height turned `σ z` clockwise, about the nominal (forecast) direction |
43//! | `rail_elevation_sd_rad` | the rail's angle above the horizon plus `σ z`; past vertical it leans the other way |
44//! | `rail_azimuth_sd_rad` | the rail's heading plus `σ z`, about the nominal heading |
45//! | `deployment_lag_sd_s` | each recovery device's lag after its trigger plus `σ z` seconds, not below zero; a tumble ([`DeviceDrag::Tumble`]), which starts at the split, keeps its own |
46//!
47//! `z` is a standard normal deviate drawn for that sample, input and copy: a stage, a motor in the
48//! flown configuration (a cluster's motors share one), or a recovery device. A draw that leaves
49//! an input impossible (a negative mass, a rail below the horizon) fails that sample, which is
50//! counted in the run ([`Outcome::Failed`]). The two delays and the wind's speed are cut at zero
51//! instead, since a charge can't fire before its event and a wind can't blow at less than calm: a
52//! normal tail past zero becomes zero.
53//!
54//! # Speed
55//!
56//! Every sample flies a simulation of its own, built from its draw. None of the dispersed inputs
57//! changes the rocket's shape, so the samples share two things with the nominal design, and a run
58//! flies the same either way, bit for bit:
59//!
60//! - its parts as laid out ([`hpr_design::LaidOut::relay`]): a sample lays out only its stages
61//!   again, with their dispersed masses;
62//! - its supersonic table ([`hpr_aero::AeroModel::share_supersonic_table`]): a design that flies
63//!   past Mach 1.2 builds it once for the run, not once a flight.
64//!
65//! [`MonteCarlo::fly`] shares them too, for draws made by hand; [`FlightInputs::fly`] builds its
66//! own every time. A sustainer lit at a powered separation builds its own table in every flight.
67//!
68//! # Left out
69//!
70//! Dispersions are independent normals: no correlations between inputs, no other
71//! distributions. The rail's elevation is dispersed in the plane of its heading, so a vertical
72//! rail with only its elevation dispersed leans along one line, as RocketPy's does (an inclination
73//! and a heading, `rocketpy/stochastic/stochastic_flight.py:21-24`); a draw past vertical leans
74//! it the other way, so its [`Draw`] entry is not then the elevation flown. A cluster's motors are dispersed as one. Moving a stage's center of mass keeps
75//! its inertia about the center. The drag scale multiplies the zero-lift drag only, not the
76//! normal force or the moments. A thrust curve stretched in time keeps its shape. Nothing is
77//! dispersed in the atmosphere's temperature or pressure, a motor's ignition time, a recovery
78//! device's drag, a separation's trigger or delay, or anything of a flight's events that
79//! [`FlightInputs`] doesn't hold. A staged flight's separations fly in every sample as the nominal
80//! has them ([`FlightInputs::separations`]): a split timed in seconds stays at its time while the
81//! burn time moves, so a sample whose booster still burns then stops with the flight's error and
82//! counts as failed, unless the split may drop a burning motor ([`Separation::drops_burning`]).
83//! A part dropped on the way up with nothing left to burn, its device fired by the split but
84//! waiting out a drawn lag, would climb through the lag with no drag: the flight refuses it, and
85//! the sample counts as failed.
86
87use std::f64::consts::{FRAC_PI_2, PI};
88use std::sync::Arc;
89
90use hpr_aero::table::DragTable;
91use hpr_aero::{AeroModel, DragModel};
92use hpr_atmos::{AtmosError, Wind, WindSample};
93use hpr_core::DVec3;
94use hpr_core::random::SeededRng;
95use hpr_design::{LaidOut, Rocket};
96use hpr_motor::motor::{Propellant, PropellantColumn};
97use hpr_motor::{BatesGrains, Delay, SolidMotor, ThrustCurve};
98use hpr_sim::{
99    Device, DeviceDrag, Environment, FlightMetrics, FlightSettings, FlightSummary, Rail,
100    Separation, SimError, Simulation,
101};
102use serde::{Deserialize, Serialize};
103
104use crate::ellipse::Scatter;
105use crate::error::AnalysisError;
106use crate::statistics::Distribution;
107
108/// A drag override for the whole flight, in place of hpr's drag buildup: another tool's table or
109/// a model of your own ([`Simulation::with_drag_table`], [`Simulation::with_shared_drag_model`]).
110#[derive(Debug, Clone)]
111#[non_exhaustive]
112pub enum DragOverride {
113    /// A `C_D0(M)` table.
114    Table(DragTable),
115    /// A drag model.
116    Model(Arc<dyn DragModel>),
117}
118
119/// Everything one flight is flown from: the inputs of [`Simulation::new`] and the options a
120/// Monte Carlo run disperses. `hpr::FlightBuilder::inputs` makes one from the facade's builder.
121#[derive(Debug, Clone)]
122pub struct FlightInputs {
123    /// The design.
124    pub rocket: Rocket,
125    /// The id of the configuration flown: which motors are in it.
126    pub configuration_id: String,
127    /// The atmosphere and the wind.
128    pub environment: Environment,
129    /// The launch rail.
130    pub rail: Rail,
131    /// The integrator's settings.
132    pub settings: FlightSettings,
133    /// The recovery devices, none for a ballistic flight ([`Simulation::with_recovery`]).
134    pub recovery: Vec<Device>,
135    /// A drag table or model in place of hpr's drag buildup, if any.
136    pub drag: Option<DragOverride>,
137    /// The factor on the rocket's zero-lift drag ([`Simulation::with_drag_scale`]); 1 leaves it.
138    pub drag_scale: f64,
139    /// The stack's separations, in the order they fire, none for a flight that stays whole
140    /// ([`Simulation::with_separations`]).
141    pub separations: Vec<Separation>,
142}
143
144impl FlightInputs {
145    /// The inputs of a flight of `rocket`'s configuration `configuration_id`, with the default
146    /// integrator, no recovery, hpr's own drag and no drag scale.
147    pub fn new(
148        rocket: Rocket,
149        configuration_id: impl Into<String>,
150        environment: Environment,
151        rail: Rail,
152    ) -> Self {
153        Self {
154            rocket,
155            configuration_id: configuration_id.into(),
156            environment,
157            rail,
158            settings: FlightSettings::default(),
159            recovery: Vec::new(),
160            drag: None,
161            drag_scale: 1.0,
162            separations: Vec::new(),
163        }
164    }
165
166    /// The simulation these inputs fly.
167    ///
168    /// # Errors
169    ///
170    /// As [`Simulation::new`], [`Simulation::with_drag_scale`] (a scale that is negative or not
171    /// finite), [`Simulation::with_recovery`] and [`Simulation::with_separations`].
172    pub fn simulation(&self) -> Result<Simulation, SimError> {
173        self.simulation_on(self.rocket.lay_out()?)
174    }
175
176    /// As [`FlightInputs::simulation`], on `laid_out`, which is this design laid out.
177    fn simulation_on(&self, laid_out: LaidOut) -> Result<Simulation, SimError> {
178        let mut simulation = Simulation::from_laid_out(
179            laid_out,
180            &self.configuration_id,
181            self.environment.clone(),
182            self.rail,
183            self.settings,
184        )?;
185        match &self.drag {
186            Some(DragOverride::Table(table)) => {
187                simulation = simulation.with_drag_table(table.clone());
188            }
189            Some(DragOverride::Model(model)) => {
190                simulation = simulation.with_shared_drag_model(Arc::clone(model));
191            }
192            None => {}
193        }
194        if self.drag_scale != 1.0 {
195            simulation = simulation.with_drag_scale(self.drag_scale)?;
196        }
197        if !self.recovery.is_empty() {
198            simulation = simulation.with_recovery(self.recovery.clone())?;
199        }
200        if self.separations.is_empty() {
201            Ok(simulation)
202        } else {
203            simulation.with_separations(self.separations.clone())
204        }
205    }
206
207    /// Flies the flight to the ground and gives its metrics.
208    ///
209    /// # Errors
210    ///
211    /// As [`FlightInputs::simulation`], [`Simulation::run`] and
212    /// [`FlightMetrics::summary`].
213    pub fn fly(&self) -> Result<FlightSummary, SimError> {
214        self.fly_on(self.simulation()?)
215    }
216
217    /// As [`FlightInputs::fly`], on what a run's flights share with its nominal one, when it has
218    /// it: the design laid out from the nominal's ([`LaidOut::relay`]) and the nominal's
219    /// supersonic table ([`Simulation::share_supersonic_table`]). The flight is the same, bit for
220    /// bit.
221    fn fly_sharing(&self, shared: Option<&Shared>) -> Result<FlightSummary, SimError> {
222        let Some(shared) = shared else {
223            return self.fly();
224        };
225        let mut simulation = self.simulation_on(shared.laid_out.relay(self.rocket.clone())?)?;
226        simulation.share_supersonic_table(&shared.tables);
227        self.fly_on(simulation)
228    }
229
230    /// Flies `simulation`, built from these inputs, to the ground and gives its metrics.
231    fn fly_on(&self, simulation: Simulation) -> Result<FlightSummary, SimError> {
232        let mut metrics = FlightMetrics::new();
233        let result = simulation.run(&mut metrics)?;
234        metrics.summary(&result, &self.environment)
235    }
236}
237
238/// What a run's flights share with its nominal flight: the nominal design laid out, which each
239/// flight's layout starts from, and its aerodynamic model, whose supersonic table they fly on.
240#[derive(Debug, Clone)]
241struct Shared {
242    laid_out: LaidOut,
243    tables: AeroModel,
244}
245
246/// The standard deviation of each dispersed input; zero, the default, leaves an input at its
247/// nominal value. The module's docs say what each does to a flight.
248#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
249#[serde(default, deny_unknown_fields)]
250pub struct Dispersion {
251    /// Each stage's mass without motors, as a fraction of it (0.02 is 2%).
252    pub dry_mass_sd_fraction: f64,
253    /// Each stage's center of mass along the axis, m.
254    pub cg_sd_m: f64,
255    /// The rocket's zero-lift drag coefficient, as a fraction of it.
256    pub drag_sd_fraction: f64,
257    /// Each motor's total impulse, as a fraction of it, its propellant mass with it.
258    pub impulse_sd_fraction: f64,
259    /// Each motor's burn time, as a fraction of it, at the same impulse.
260    pub burn_time_sd_fraction: f64,
261    /// Each motor's ejection delay, s.
262    pub ejection_delay_sd_s: f64,
263    /// The wind's speed at every height, as a fraction of it.
264    pub wind_speed_sd_fraction: f64,
265    /// The wind's direction, rad, about the nominal one.
266    pub wind_heading_sd_rad: f64,
267    /// The rail's angle above the horizon, rad.
268    pub rail_elevation_sd_rad: f64,
269    /// The rail's heading, rad.
270    pub rail_azimuth_sd_rad: f64,
271    /// Each recovery device's lag after its trigger, s.
272    pub deployment_lag_sd_s: f64,
273}
274
275impl Dispersion {
276    /// The fields with their names, for checks.
277    fn named(&self) -> [(&'static str, f64); 11] {
278        [
279            ("dry-mass standard deviation", self.dry_mass_sd_fraction),
280            ("center-of-mass standard deviation (m)", self.cg_sd_m),
281            ("drag standard deviation", self.drag_sd_fraction),
282            ("impulse standard deviation", self.impulse_sd_fraction),
283            ("burn-time standard deviation", self.burn_time_sd_fraction),
284            (
285                "ejection-delay standard deviation (s)",
286                self.ejection_delay_sd_s,
287            ),
288            ("wind-speed standard deviation", self.wind_speed_sd_fraction),
289            (
290                "wind-heading standard deviation (rad)",
291                self.wind_heading_sd_rad,
292            ),
293            (
294                "rail-elevation standard deviation (rad)",
295                self.rail_elevation_sd_rad,
296            ),
297            (
298                "rail-azimuth standard deviation (rad)",
299                self.rail_azimuth_sd_rad,
300            ),
301            (
302                "deployment-lag standard deviation (s)",
303                self.deployment_lag_sd_s,
304            ),
305        ]
306    }
307
308    /// Checks that every standard deviation is finite and not negative.
309    ///
310    /// # Errors
311    ///
312    /// [`AnalysisError::Domain`] naming the first that isn't.
313    pub fn validate(&self) -> Result<(), AnalysisError> {
314        for (what, value) in self.named() {
315            if !(value.is_finite() && value >= 0.0) {
316                return Err(AnalysisError::Domain { what, value });
317            }
318        }
319        Ok(())
320    }
321}
322
323/// The dispersed inputs, each with its own stream ([`SeededRng::for_stream`]). The numbers are
324/// part of every seeded result: changing one changes the samples drawn.
325#[derive(Debug, Clone, Copy)]
326enum Input {
327    DryMass = 1,
328    CenterOfMass = 2,
329    Drag = 3,
330    Impulse = 4,
331    BurnTime = 5,
332    EjectionDelay = 6,
333    WindSpeed = 7,
334    WindHeading = 8,
335    RailElevation = 9,
336    RailAzimuth = 10,
337    DeploymentLag = 11,
338}
339
340/// What one sample drew: the factor or offset for each dispersed input, and its nominal value
341/// (1 or 0) for one left alone. The lists run over the design's stages, the flown
342/// configuration's motors and the recovery devices, in order. [`MonteCarlo::inputs`] turns a
343/// draw into the flight it flies.
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
345pub struct Draw {
346    /// Each stage's mass factor.
347    pub dry_mass_scale: Vec<f64>,
348    /// Each stage's center-of-mass shift, m aft.
349    pub cg_shift_m: Vec<f64>,
350    /// The drag factor.
351    pub drag_scale: f64,
352    /// Each motor's impulse factor.
353    pub impulse_scale: Vec<f64>,
354    /// Each motor's burn-time factor.
355    pub burn_time_scale: Vec<f64>,
356    /// Each motor's ejection-delay offset, s, before the cut at zero.
357    pub ejection_delay_offset_s: Vec<f64>,
358    /// The wind-speed factor, before the cut at zero.
359    pub wind_speed_scale: f64,
360    /// The wind's turn, rad clockwise.
361    pub wind_turn_rad: f64,
362    /// The rail's elevation offset, rad.
363    pub rail_elevation_offset_rad: f64,
364    /// The rail's heading offset, rad clockwise.
365    pub rail_azimuth_offset_rad: f64,
366    /// Each recovery device's lag offset, s, before the cut at zero.
367    pub deployment_lag_offset_s: Vec<f64>,
368}
369
370/// What a sample's flight came to.
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372#[serde(tag = "outcome", rename_all = "snake_case")]
373pub enum Outcome {
374    /// It flew: to the ground, or to a split with nothing left to burn, each part then to the
375    /// ground.
376    Flown {
377        /// Its metrics.
378        summary: Box<FlightSummary>,
379    },
380    /// Its inputs were refused or the flight stopped with an error.
381    Failed {
382        /// Where: making its inputs from the draw, or flying them.
383        at: FailedAt,
384        /// The error.
385        reason: String,
386    },
387}
388
389/// Where a sample failed.
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(rename_all = "snake_case")]
392pub enum FailedAt {
393    /// Making its inputs: the draw left one impossible, such as a motor with no impulse
394    /// ([`MonteCarlo::inputs`]).
395    Inputs,
396    /// Flying them: the flight refused them, such as a negative mass or a rail below the
397    /// horizon, or stopped with an error ([`FlightInputs::fly`]).
398    Flight,
399}
400
401/// One sample of a run: its index, what it drew, and its outcome.
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403pub struct Sample {
404    /// Its index in the run, from 0.
405    pub index: u64,
406    /// What it drew.
407    pub draw: Draw,
408    /// What its flight came to.
409    pub outcome: Outcome,
410}
411
412impl Sample {
413    /// The flight's metrics, `None` for a sample that failed.
414    pub fn summary(&self) -> Option<&FlightSummary> {
415        match &self.outcome {
416            Outcome::Flown { summary } => Some(summary),
417            Outcome::Failed { .. } => None,
418        }
419    }
420}
421
422/// The samples of a run, in order.
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424pub struct Run {
425    /// The seed.
426    pub seed: u64,
427    /// The samples, by index.
428    pub samples: Vec<Sample>,
429}
430
431impl Run {
432    /// The samples that failed.
433    pub fn failed(&self) -> impl Iterator<Item = &Sample> {
434        self.samples
435            .iter()
436            .filter(|sample| matches!(sample.outcome, Outcome::Failed { .. }))
437    }
438
439    /// The distribution of `value` over every sample: a failed sample, or one for which `value`
440    /// gives `None`, counts as tried with no value.
441    ///
442    /// # Errors
443    ///
444    /// [`AnalysisError::Domain`] for a value that isn't finite.
445    pub fn distribution(
446        &self,
447        value: impl Fn(&FlightSummary) -> Option<f64>,
448    ) -> Result<Distribution, AnalysisError> {
449        let values = self
450            .samples
451            .iter()
452            .filter_map(|sample| sample.summary().and_then(&value))
453            .collect();
454        Distribution::new(values, self.samples.len())
455    }
456
457    /// The distribution of the apogee's height above the ground, m.
458    ///
459    /// # Errors
460    ///
461    /// As [`Run::distribution`].
462    pub fn apogee(&self) -> Result<Distribution, AnalysisError> {
463        self.distribution(|summary| {
464            summary
465                .apogee
466                .as_ref()
467                .map(|apogee| apogee.height_above_ground_m)
468        })
469    }
470
471    /// Where the flights landed, east and north of the pad (m): the part that keeps the nose,
472    /// the stack, after a powered separation the sustainer, or after a split with nothing left to
473    /// burn part 0 ([`FlightSummary::nose_landing`]). A failed sample, or one that never landed,
474    /// counts as tried with no point. Its ellipses are in [`crate::ellipse`].
475    ///
476    /// # Errors
477    ///
478    /// [`AnalysisError::Domain`] for a coordinate that isn't finite, or is more than
479    /// [`Scatter::MAX_COORDINATE_M`] from the pad.
480    pub fn landing(&self) -> Result<Scatter, AnalysisError> {
481        let points = self
482            .samples
483            .iter()
484            .filter_map(|sample| sample.summary()?.nose_landing())
485            .map(|landing| [landing.east_m, landing.north_m])
486            .collect();
487        Scatter::new(points, self.samples.len())
488    }
489}
490
491/// A stage's nominal mass without motors and its center, as the layout places them.
492#[derive(Debug, Clone, Copy)]
493struct StageMass {
494    mass_kg: f64,
495    /// Aft of the stage's forward end, m: what [`hpr_design::Overrides::cg_aft_m`] sets.
496    cg_aft_m: f64,
497}
498
499/// A rocket's nominal flight and the dispersion of its inputs, ready to fly samples.
500#[derive(Debug, Clone)]
501pub struct MonteCarlo {
502    nominal: FlightInputs,
503    dispersion: Dispersion,
504    stages: Vec<StageMass>,
505    configuration: usize,
506    /// What the samples share with the nominal flight; `None` when the nominal design can't be
507    /// laid out or its aerodynamic model built, and then every sample fails on its own.
508    shared: Option<Shared>,
509}
510
511impl MonteCarlo {
512    /// A run of `nominal` dispersed by `dispersion`.
513    ///
514    /// # Errors
515    ///
516    /// - As [`Dispersion::validate`].
517    /// - [`AnalysisError::NoConfiguration`] if the design has no configuration of that id.
518    /// - [`AnalysisError::Design`] if the design can't be laid out.
519    /// - [`AnalysisError::Unsupported`] for a dispersed impulse or burn time on a motor whose
520    ///   propellant model [`dispersed_motor`] doesn't know.
521    pub fn new(nominal: FlightInputs, dispersion: Dispersion) -> Result<Self, AnalysisError> {
522        dispersion.validate()?;
523        let configuration = nominal
524            .rocket
525            .configurations
526            .iter()
527            .position(|c| c.id == nominal.configuration_id)
528            .ok_or_else(|| AnalysisError::NoConfiguration(nominal.configuration_id.clone()))?;
529        let layout = nominal.rocket.layout()?;
530        let stages = layout
531            .stages
532            .iter()
533            .map(|stage| StageMass {
534                mass_kg: stage.mass.mass_kg,
535                // Stations run aft from the nose; body z runs forward ([`hpr_design::Overrides`]).
536                cg_aft_m: -stage.mass.cg_m.z - stage.fore_station_m,
537            })
538            .collect();
539        if dispersion.impulse_sd_fraction > 0.0 || dispersion.burn_time_sd_fraction > 0.0 {
540            for mounted in &nominal.rocket.configurations[configuration].motors {
541                dispersed_motor(&mounted.motor, 1.0, 1.0)?;
542            }
543        }
544        let shared = nominal.rocket.lay_out().ok().and_then(|laid_out| {
545            let tables = AeroModel::new(laid_out.layout()).ok()?;
546            Some(Shared { laid_out, tables })
547        });
548        Ok(Self {
549            nominal,
550            dispersion,
551            stages,
552            configuration,
553            shared,
554        })
555    }
556
557    /// The nominal flight.
558    pub fn nominal(&self) -> &FlightInputs {
559        &self.nominal
560    }
561
562    /// The dispersion.
563    pub fn dispersion(&self) -> &Dispersion {
564        &self.dispersion
565    }
566
567    /// What sample `index` of a run seeded with `seed` draws.
568    pub fn draw(&self, seed: u64, index: u64) -> Draw {
569        let d = &self.dispersion;
570        let normal = |input: Input, copy: usize, sd: f64| {
571            if sd > 0.0 {
572                // Cast: a copy's index is far below 2⁶⁴.
573                sd * SeededRng::for_stream(seed, &[index, input as u64, copy as u64])
574                    .standard_normal()
575            } else {
576                0.0
577            }
578        };
579        let per = |count: usize, input: Input, sd: f64, base: f64| -> Vec<f64> {
580            (0..count)
581                .map(|copy| base + normal(input, copy, sd))
582                .collect()
583        };
584        let stages = self.nominal.rocket.stages.len();
585        let motors = self.nominal.rocket.configurations[self.configuration]
586            .motors
587            .len();
588        // A tumble isn't deployed, so it has no lag to disperse: its entry stays 0, and its
589        // stream is never drawn.
590        let lags = self
591            .nominal
592            .recovery
593            .iter()
594            .enumerate()
595            .map(|(copy, device)| match device.drag {
596                DeviceDrag::Tumble { .. } => 0.0,
597                _ => normal(Input::DeploymentLag, copy, d.deployment_lag_sd_s),
598            })
599            .collect();
600        Draw {
601            dry_mass_scale: per(stages, Input::DryMass, d.dry_mass_sd_fraction, 1.0),
602            cg_shift_m: per(stages, Input::CenterOfMass, d.cg_sd_m, 0.0),
603            drag_scale: 1.0 + normal(Input::Drag, 0, d.drag_sd_fraction),
604            impulse_scale: per(motors, Input::Impulse, d.impulse_sd_fraction, 1.0),
605            burn_time_scale: per(motors, Input::BurnTime, d.burn_time_sd_fraction, 1.0),
606            ejection_delay_offset_s: per(motors, Input::EjectionDelay, d.ejection_delay_sd_s, 0.0),
607            wind_speed_scale: 1.0 + normal(Input::WindSpeed, 0, d.wind_speed_sd_fraction),
608            wind_turn_rad: normal(Input::WindHeading, 0, d.wind_heading_sd_rad),
609            rail_elevation_offset_rad: normal(Input::RailElevation, 0, d.rail_elevation_sd_rad),
610            rail_azimuth_offset_rad: normal(Input::RailAzimuth, 0, d.rail_azimuth_sd_rad),
611            deployment_lag_offset_s: lags,
612        }
613    }
614
615    /// The flight inputs `draw` gives: the nominal ones, with each input whose entry isn't its
616    /// nominal value (a factor of 1, an offset of 0) changed as the module's docs say. An entry at
617    /// its nominal value leaves its input as it is, so a draw with no dispersion flies the
618    /// nominal flight bit for bit; any other entry is applied, whatever the dispersion, so a
619    /// draw read back or written by hand flies as it says.
620    ///
621    /// # Errors
622    ///
623    /// - [`AnalysisError::Count`] for a list whose length isn't the design's number of stages,
624    ///   the configuration's number of motors or the number of recovery devices.
625    /// - [`AnalysisError::Domain`] for an entry that isn't finite, and from
626    ///   [`dispersed_motor`] for an impulse or burn-time factor at or below zero.
627    /// - [`AnalysisError::Unsupported`] and [`AnalysisError::Motor`] as [`dispersed_motor`].
628    pub fn inputs(&self, draw: &Draw) -> Result<FlightInputs, AnalysisError> {
629        self.check(draw)?;
630        let mut inputs = self.nominal.clone();
631        for ((stage, nominal), (&scale, &shift)) in inputs
632            .rocket
633            .stages
634            .iter_mut()
635            .zip(&self.stages)
636            .zip(draw.dry_mass_scale.iter().zip(&draw.cg_shift_m))
637        {
638            if scale != 1.0 {
639                stage.overrides.mass_kg = Some(nominal.mass_kg * scale);
640                // An inertia the design sets replaces the scaled one, so it scales too.
641                if let Some(inertia) = &mut stage.overrides.inertia {
642                    for value in [
643                        &mut inertia.xx_kg_m2,
644                        &mut inertia.yy_kg_m2,
645                        &mut inertia.zz_kg_m2,
646                        &mut inertia.xy_kg_m2,
647                        &mut inertia.xz_kg_m2,
648                        &mut inertia.yz_kg_m2,
649                    ] {
650                        *value *= scale;
651                    }
652                }
653            }
654            if shift != 0.0 {
655                stage.overrides.cg_aft_m = Some(nominal.cg_aft_m + shift);
656            }
657        }
658        let motors = &mut inputs.rocket.configurations[self.configuration].motors;
659        for (mounted, ((&impulse, &burn), &delay)) in motors.iter_mut().zip(
660            draw.impulse_scale
661                .iter()
662                .zip(&draw.burn_time_scale)
663                .zip(&draw.ejection_delay_offset_s),
664        ) {
665            if impulse != 1.0 || burn != 1.0 {
666                mounted.motor = dispersed_motor(&mounted.motor, impulse, burn)?;
667            }
668            if delay != 0.0
669                && let Some(Delay::Seconds(delay_s)) = mounted.delay
670            {
671                mounted.delay = Some(Delay::Seconds((delay_s + delay).max(0.0)));
672            }
673        }
674        if draw.drag_scale != 1.0 {
675            inputs.drag_scale *= draw.drag_scale;
676        }
677        if draw.wind_speed_scale != 1.0 || draw.wind_turn_rad != 0.0 {
678            inputs.environment.wind = Arc::new(DispersedWind::new(
679                Arc::clone(&inputs.environment.wind),
680                draw.wind_speed_scale.max(0.0),
681                draw.wind_turn_rad,
682            )?);
683        }
684        if draw.rail_elevation_offset_rad != 0.0 || draw.rail_azimuth_offset_rad != 0.0 {
685            inputs.rail = tilted(
686                inputs.rail,
687                draw.rail_elevation_offset_rad,
688                draw.rail_azimuth_offset_rad,
689            );
690        }
691        for (device, &offset) in inputs
692            .recovery
693            .iter_mut()
694            .zip(&draw.deployment_lag_offset_s)
695        {
696            if offset != 0.0 {
697                device.lag_s = (device.lag_s + offset).max(0.0);
698            }
699        }
700        Ok(inputs)
701    }
702
703    /// Checks that `draw` has an entry for each stage, motor and recovery device, and that every
704    /// entry is finite.
705    fn check(&self, draw: &Draw) -> Result<(), AnalysisError> {
706        let stages = self.nominal.rocket.stages.len();
707        let motors = self.nominal.rocket.configurations[self.configuration]
708            .motors
709            .len();
710        let devices = self.nominal.recovery.len();
711        let lists: [(&'static str, &[f64], usize); 6] = [
712            (
713                "dry-mass factors, against the stages",
714                &draw.dry_mass_scale,
715                stages,
716            ),
717            (
718                "center-of-mass shifts, against the stages",
719                &draw.cg_shift_m,
720                stages,
721            ),
722            (
723                "impulse factors, against the motors",
724                &draw.impulse_scale,
725                motors,
726            ),
727            (
728                "burn-time factors, against the motors",
729                &draw.burn_time_scale,
730                motors,
731            ),
732            (
733                "ejection-delay offsets, against the motors",
734                &draw.ejection_delay_offset_s,
735                motors,
736            ),
737            (
738                "deployment-lag offsets, against the recovery devices",
739                &draw.deployment_lag_offset_s,
740                devices,
741            ),
742        ];
743        for (what, list, count) in lists {
744            if list.len() != count {
745                return Err(AnalysisError::Length {
746                    what,
747                    length: list.len(),
748                    expected: count,
749                });
750            }
751            if let Some(&bad) = list.iter().find(|v| !v.is_finite()) {
752                return Err(AnalysisError::Domain {
753                    what: "entry of a draw",
754                    value: bad,
755                });
756            }
757        }
758        for value in [
759            draw.drag_scale,
760            draw.wind_speed_scale,
761            draw.wind_turn_rad,
762            draw.rail_elevation_offset_rad,
763            draw.rail_azimuth_offset_rad,
764        ] {
765            if !value.is_finite() {
766                return Err(AnalysisError::Domain {
767                    what: "entry of a draw",
768                    value,
769                });
770            }
771        }
772        Ok(())
773    }
774
775    /// Flies `draw` ([`MonteCarlo::inputs`]) to the ground and gives its metrics, on what the
776    /// run's flights share (the module's *Speed*): as [`FlightInputs::fly`] on the same inputs,
777    /// bit for bit, but without laying out the parts or building a supersonic table again. For
778    /// flights drawn by hand, as a sensitivity analysis's ([`crate::sensitivity`]).
779    ///
780    /// # Errors
781    ///
782    /// As [`MonteCarlo::inputs`], and [`AnalysisError::Sim`] around the errors of
783    /// [`FlightInputs::fly`].
784    pub fn fly(&self, draw: &Draw) -> Result<FlightSummary, AnalysisError> {
785        Ok(self.inputs(draw)?.fly_sharing(self.shared.as_ref())?)
786    }
787
788    /// Draws and flies sample `index` of a run seeded with `seed`. A draw that makes an input
789    /// impossible, or a flight that stops with an error, is a failed sample, not an error.
790    pub fn sample(&self, seed: u64, index: u64) -> Sample {
791        let draw = self.draw(seed, index);
792        let outcome = match self.inputs(&draw) {
793            Err(error) => Outcome::Failed {
794                at: FailedAt::Inputs,
795                reason: error.to_string(),
796            },
797            Ok(inputs) => match inputs.fly_sharing(self.shared.as_ref()) {
798                Ok(summary) => Outcome::Flown {
799                    summary: Box::new(summary),
800                },
801                Err(error) => Outcome::Failed {
802                    at: FailedAt::Flight,
803                    reason: error.to_string(),
804                },
805            },
806        };
807        Sample {
808            index,
809            draw,
810            outcome,
811        }
812    }
813
814    /// Flies samples `0..count` of a run seeded with `seed`, one after another.
815    pub fn run(&self, seed: u64, count: u64) -> Run {
816        Run {
817            seed,
818            samples: (0..count).map(|index| self.sample(seed, index)).collect(),
819        }
820    }
821
822    /// As [`MonteCarlo::run`], with the samples spread over the threads of the current rayon
823    /// pool: the global one, or one a caller's `rayon::ThreadPool::install` sets up to choose
824    /// how many. The run is the same, bit for bit, whatever the number of threads.
825    #[cfg(feature = "parallel")]
826    pub fn run_parallel(&self, seed: u64, count: u64) -> Run {
827        use rayon::prelude::*;
828        Run {
829            seed,
830            samples: (0..count)
831                .into_par_iter()
832                .map(|index| self.sample(seed, index))
833                .collect(),
834        }
835    }
836}
837
838/// `motor` with its total impulse times `impulse_scale` and its burn time times
839/// `burn_time_scale`: thrust `F′(t) = (k/s) F(t/s)` and propellant mass `k m_p`, with `k` and `s`
840/// the two factors. The impulse is `k I` and the effective exhaust velocity `I/m_p` (the specific
841/// impulse) is kept, as a motor of the same propellant burning more or less of it would; the dry
842/// mass, the nozzle and the propellant's shape are kept. A column's mass is scaled; BATES grains'
843/// density, so their geometry and regression are kept.
844///
845/// # Errors
846///
847/// - [`AnalysisError::Domain`] for a factor that isn't finite and positive.
848/// - [`AnalysisError::Unsupported`] for a propellant model this doesn't know.
849/// - As [`SolidMotor::new`].
850pub fn dispersed_motor(
851    motor: &SolidMotor,
852    impulse_scale: f64,
853    burn_time_scale: f64,
854) -> Result<SolidMotor, AnalysisError> {
855    for (what, value) in [
856        ("impulse factor", impulse_scale),
857        ("burn-time factor", burn_time_scale),
858    ] {
859        if !(value.is_finite() && value > 0.0) {
860            return Err(AnalysisError::Domain { what, value });
861        }
862    }
863    let curve = motor.curve();
864    let thrust_scale = impulse_scale / burn_time_scale;
865    let curve = ThrustCurve::new(
866        curve
867            .times_s()
868            .iter()
869            .map(|t| t * burn_time_scale)
870            .collect(),
871        curve.thrusts_n().iter().map(|f| f * thrust_scale).collect(),
872    )?;
873    let propellant = match motor.propellant() {
874        Propellant::Column(column) => Propellant::Column(PropellantColumn {
875            mass_kg: column.mass_kg * impulse_scale,
876            ..*column
877        }),
878        Propellant::Grains(grains) => Propellant::Grains(BatesGrains {
879            density_kg_m3: grains.density_kg_m3 * impulse_scale,
880            ..*grains
881        }),
882        other => {
883            return Err(AnalysisError::Unsupported(format!(
884                "dispersing the impulse of a motor whose propellant is {other:?}"
885            )));
886        }
887    };
888    Ok(SolidMotor::new(
889        curve,
890        propellant,
891        motor.dry(),
892        motor.nozzle(),
893    )?)
894}
895
896/// `rail` with its elevation and heading moved by the offsets. An elevation past vertical leans
897/// the other way: `E′ = π − E`, the heading and roll turned half a turn, the same attitude
898/// (`R_z(−A) R_x(E − π/2) R_z(φ)`, `hpr_core::frames::LaunchAngles`).
899fn tilted(rail: Rail, elevation_offset_rad: f64, azimuth_offset_rad: f64) -> Rail {
900    let mut tilted = rail;
901    tilted.elevation_rad += elevation_offset_rad;
902    tilted.azimuth_rad += azimuth_offset_rad;
903    if tilted.elevation_rad > FRAC_PI_2 {
904        tilted.elevation_rad = PI - tilted.elevation_rad;
905        tilted.azimuth_rad += PI;
906        tilted.roll_rad += PI;
907    }
908    tilted
909}
910
911/// A wind model's wind, scaled and turned: at every height the velocity is `speed_scale` times
912/// the base model's, its horizontal part turned `turn_rad` clockwise seen from above. Turning
913/// the velocity turns the direction the wind blows from by the same angle, so a dispersed heading
914/// stays about the forecast's.
915#[derive(Debug, Clone)]
916pub struct DispersedWind {
917    base: Arc<dyn Wind>,
918    speed_scale: f64,
919    turn_rad: f64,
920}
921
922impl DispersedWind {
923    /// `base` scaled by `speed_scale` and turned `turn_rad` clockwise.
924    ///
925    /// # Errors
926    ///
927    /// [`AnalysisError::Domain`] for a scale that is negative or not finite, or a turn that isn't
928    /// finite.
929    pub fn new(
930        base: Arc<dyn Wind>,
931        speed_scale: f64,
932        turn_rad: f64,
933    ) -> Result<Self, AnalysisError> {
934        if !(speed_scale.is_finite() && speed_scale >= 0.0) {
935            return Err(AnalysisError::Domain {
936                what: "wind-speed factor",
937                value: speed_scale,
938            });
939        }
940        if !turn_rad.is_finite() {
941            return Err(AnalysisError::Domain {
942                what: "wind turn (rad)",
943                value: turn_rad,
944            });
945        }
946        Ok(Self {
947            base,
948            speed_scale,
949            turn_rad,
950        })
951    }
952}
953
954impl Wind for DispersedWind {
955    fn wind(&self, height_msl_m: f64) -> Result<WindSample, AtmosError> {
956        let mut sample = self.base.wind(height_msl_m)?;
957        let v = sample.velocity_enu_m_s;
958        let (sin, cos) = self.turn_rad.sin_cos();
959        // A bearing θ clockwise from north is (sin θ, cos θ) in east and north; turning it to
960        // θ + δ gives these.
961        sample.velocity_enu_m_s =
962            DVec3::new(v.x * cos + v.y * sin, v.y * cos - v.x * sin, v.z) * self.speed_scale;
963        Ok(sample)
964    }
965}
966
967#[cfg(test)]
968pub(crate) mod tests {
969    use hpr_atmos::ConstantWind;
970    use hpr_core::frames::LaunchAngles;
971    use hpr_core::geodesy::Geodetic;
972    use hpr_sim::{DeviceDrag, Trigger};
973
974    use super::*;
975
976    pub(crate) const SEED: u64 = 20_261_001;
977    /// `SeededRng::for_stream(42, &[7]).next_u64()`.
978    const GOLDEN_STREAM: u64 = 2_627_254_379_500_910_771;
979    /// Sample 0's drag and impulse factors with `every_dispersion()` and `SEED`, as macOS
980    /// computes them: a normal deviate takes a logarithm, which another platform's library may
981    /// round differently in the last bit, so these are held to 1e-14.
982    const GOLDEN_DRAG: f64 = 1.012_497_722_244_850_1;
983    const GOLDEN_IMPULSE: f64 = 1.020_485_907_939_679_7;
984
985    fn valetudo() -> Rocket {
986        serde_json::from_str(include_str!(
987            "../../../validation/designs/rocketpy-valetudo.json"
988        ))
989        .unwrap()
990    }
991
992    /// Valetudo in a 5 m/s wind from the west, off a 5.2 m rail at 85° heading north, with a
993    /// drogue at apogee.
994    pub(crate) fn nominal() -> FlightInputs {
995        let site = Geodetic::from_degrees(32.99, -106.97, 1400.0).unwrap();
996        let environment = Environment::standard(site)
997            .unwrap()
998            .with_wind(ConstantWind::new(5.0, 1.5 * PI).unwrap());
999        let mut rail = Rail::vertical(5.2);
1000        rail.elevation_rad = 85_f64.to_radians();
1001        let mut inputs = FlightInputs::new(valetudo(), "example", environment, rail);
1002        inputs.recovery = vec![
1003            Device::new(
1004                "drogue",
1005                DeviceDrag::DragArea { cd_s_m2: 0.3 },
1006                Trigger::Apogee,
1007            )
1008            .with_lag_s(1.0),
1009        ];
1010        inputs
1011    }
1012
1013    pub(crate) fn every_dispersion() -> Dispersion {
1014        Dispersion {
1015            dry_mass_sd_fraction: 0.02,
1016            cg_sd_m: 0.01,
1017            drag_sd_fraction: 0.05,
1018            impulse_sd_fraction: 0.03,
1019            burn_time_sd_fraction: 0.03,
1020            ejection_delay_sd_s: 0.5,
1021            wind_speed_sd_fraction: 0.2,
1022            wind_heading_sd_rad: 10_f64.to_radians(),
1023            rail_elevation_sd_rad: 1_f64.to_radians(),
1024            rail_azimuth_sd_rad: 2_f64.to_radians(),
1025            deployment_lag_sd_s: 0.3,
1026        }
1027    }
1028
1029    /// Loft lesson L52: Loft scaled the thrust but not the propellant mass, so its dispersed
1030    /// motors burned with a different specific impulse. Here both scale, and the effective
1031    /// exhaust velocity `I/m_p` is the nominal motor's.
1032    #[test]
1033    fn impulse_dispersion_preserves_specific_impulse() {
1034        let nominal = nominal();
1035        let motor = &nominal.rocket.configurations[0].motors[0].motor.clone();
1036        let impulse = motor.curve().total_impulse_ns();
1037        let exhaust = impulse / motor.propellant_initial_mass_kg();
1038        let burn = motor.curve().burn_time_s();
1039        for (k, s) in [(1.1, 1.0), (0.9, 1.2), (1.05, 0.8)] {
1040            let dispersed = dispersed_motor(motor, k, s).unwrap();
1041            let new_impulse = dispersed.curve().total_impulse_ns();
1042            let new_exhaust = new_impulse / dispersed.propellant_initial_mass_kg();
1043            assert!((new_impulse / impulse - k).abs() < 1e-14, "{k} {s}");
1044            assert!((new_exhaust / exhaust - 1.0).abs() < 1e-14, "{k} {s}");
1045            assert!(
1046                (dispersed.curve().burn_time_s() / burn - s).abs() < 1e-12,
1047                "{k} {s}"
1048            );
1049            assert_eq!(dispersed.dry(), motor.dry());
1050        }
1051        // And in a run: every sample's motor keeps it.
1052        let run = MonteCarlo::new(nominal, every_dispersion()).unwrap();
1053        for index in 0..20 {
1054            let draw = run.draw(SEED, index);
1055            let inputs = run.inputs(&draw).unwrap();
1056            let flown = &inputs.rocket.configurations[0].motors[0].motor;
1057            let flown_exhaust =
1058                flown.curve().total_impulse_ns() / flown.propellant_initial_mass_kg();
1059            assert!((flown_exhaust / exhaust - 1.0).abs() < 1e-14);
1060            // Each factor where it belongs: swapped, `I/m_p` would still hold.
1061            let flown_impulse = flown.curve().total_impulse_ns() / impulse;
1062            let flown_burn = flown.curve().burn_time_s() / burn;
1063            assert!((flown_impulse - draw.impulse_scale[0]).abs() < 1e-14);
1064            assert!((flown_burn - draw.burn_time_scale[0]).abs() < 1e-12);
1065            assert_ne!(draw.impulse_scale[0], draw.burn_time_scale[0]);
1066        }
1067        // A motor known only by its envelope holds its propellant as a column; its mass scales.
1068        let column =
1069            SolidMotor::from_envelope(motor.curve().clone(), 0.075, 0.6, 2.0, 3.5).unwrap();
1070        assert!(matches!(column.propellant(), Propellant::Column(_)));
1071        let heavier = dispersed_motor(&column, 1.2, 0.9).unwrap();
1072        assert!((heavier.propellant_initial_mass_kg() / 2.0 - 1.2).abs() < 1e-15);
1073        assert_eq!(heavier.dry(), column.dry());
1074        let ratio = |m: &SolidMotor| m.curve().total_impulse_ns() / m.propellant_initial_mass_kg();
1075        assert!((ratio(&heavier) / ratio(&column) - 1.0).abs() < 1e-14);
1076        // A factor at or below zero is refused.
1077        for bad in [0.0, -0.1, f64::NAN] {
1078            assert!(matches!(
1079                dispersed_motor(motor, bad, 1.0),
1080                Err(AnalysisError::Domain {
1081                    what: "impulse factor",
1082                    ..
1083                })
1084            ));
1085            assert!(matches!(
1086                dispersed_motor(motor, 1.0, bad),
1087                Err(AnalysisError::Domain {
1088                    what: "burn-time factor",
1089                    ..
1090                })
1091            ));
1092        }
1093    }
1094
1095    /// Loft lesson L53: Loft drew every sample from one stream, so adding a draw reshuffled every
1096    /// later sample and parallel runs weren't reproducible. Here sample `k` is the same whatever
1097    /// the run's length or thread count, and turning another dispersion on leaves its draws.
1098    #[test]
1099    fn sample_k_independent_of_n_and_thread_count() {
1100        let run = MonteCarlo::new(nominal(), every_dispersion()).unwrap();
1101        let short = run.run(SEED, 3);
1102        let long = run.run(SEED, 6);
1103        assert_eq!(short.samples[..], long.samples[..3]);
1104        assert_eq!(run.sample(SEED, 4), long.samples[4]);
1105        assert!(short.failed().next().is_none());
1106        // Another seed, other samples.
1107        assert_ne!(run.draw(SEED + 1, 0), run.draw(SEED, 0));
1108        // Each input has its own stream: with the drag dispersion off, the rest draw the same.
1109        let without_drag = MonteCarlo::new(
1110            nominal(),
1111            Dispersion {
1112                drag_sd_fraction: 0.0,
1113                ..every_dispersion()
1114            },
1115        )
1116        .unwrap();
1117        let (all, some) = (run.draw(SEED, 2), without_drag.draw(SEED, 2));
1118        assert_eq!(some.drag_scale, 1.0);
1119        assert_eq!(
1120            Draw {
1121                drag_scale: 1.0,
1122                ..all.clone()
1123            },
1124            some
1125        );
1126        // The run's first samples are a shorter run's, but one shared stream would give that
1127        // too: what a shared stream can't give is sample 4 flown on its own, above, or the same
1128        // draws with the drag dispersion off.
1129        #[cfg(feature = "parallel")]
1130        for threads in [1, 2, 5] {
1131            let pool = rayon::ThreadPoolBuilder::new()
1132                .num_threads(threads)
1133                .build()
1134                .unwrap();
1135            assert_eq!(
1136                pool.install(|| run.run_parallel(SEED, 6)),
1137                long,
1138                "{threads}"
1139            );
1140        }
1141    }
1142
1143    /// Loft lesson L54: Loft dropped impossible samples silently and computed its probabilities
1144    /// over the survivors. Here a sample whose draw the flight refuses is kept as failed, with
1145    /// its reason, and the apogee's distribution counts it as tried with no value.
1146    #[test]
1147    fn failed_samples_are_counted_and_reported() {
1148        // A 60% mass spread draws a negative mass now and then.
1149        let run = MonteCarlo::new(
1150            nominal(),
1151            Dispersion {
1152                dry_mass_sd_fraction: 0.6,
1153                ..Dispersion::default()
1154            },
1155        )
1156        .unwrap()
1157        .run(SEED, 16);
1158        let failed: Vec<&Sample> = run.failed().collect();
1159        assert!(!failed.is_empty() && failed.len() < 16, "{}", failed.len());
1160        for sample in &failed {
1161            assert!(sample.draw.dry_mass_scale[0] < 0.0, "{sample:?}");
1162            let Outcome::Failed { at, reason } = &sample.outcome else {
1163                unreachable!("filtered on failure")
1164            };
1165            assert_eq!(*at, FailedAt::Flight);
1166            assert!(reason.contains("mass"), "{reason}");
1167        }
1168        let apogee = run.apogee().unwrap();
1169        assert_eq!(apogee.attempted(), 16);
1170        let failed_count = failed.len();
1171        assert_eq!(apogee.missing(), failed.len());
1172        // A share over every sample tried, bounded by the failures both ways.
1173        let share = apogee.share_at_least(0.0).unwrap().unwrap();
1174        assert_eq!(share.low, (16 - failed_count) as f64 / 16.0);
1175        assert_eq!(share.high, 1.0);
1176        // The landings likewise: every flown sample's point, in order, the failures missing.
1177        let landing = run.landing().unwrap();
1178        assert_eq!((landing.attempted(), landing.missing()), (16, failed_count));
1179        let mut points: Vec<[f64; 2]> = run
1180            .samples
1181            .iter()
1182            .filter_map(Sample::summary)
1183            .map(|summary| {
1184                let landing = summary.landing.as_ref().unwrap();
1185                [landing.east_m, landing.north_m]
1186            })
1187            .collect();
1188        points.sort_by(|p, q| p[0].total_cmp(&q[0]).then(p[1].total_cmp(&q[1])));
1189        assert_eq!(landing.points(), points.as_slice());
1190        // A draw that leaves a motor with no impulse fails before it flies.
1191        let run = MonteCarlo::new(
1192            nominal(),
1193            Dispersion {
1194                impulse_sd_fraction: 1.0,
1195                ..Dispersion::default()
1196            },
1197        )
1198        .unwrap()
1199        .run(SEED, 16);
1200        let failed: Vec<&Sample> = run.failed().collect();
1201        assert!(
1202            !failed.is_empty(),
1203            "no impulse factor at or below zero in 16"
1204        );
1205        for sample in failed {
1206            assert!(sample.draw.impulse_scale[0] <= 0.0, "{sample:?}");
1207            assert!(
1208                matches!(&sample.outcome, Outcome::Failed { at: FailedAt::Inputs, reason }
1209                    if reason.contains("impulse factor")),
1210                "{sample:?}"
1211            );
1212        }
1213    }
1214
1215    /// Loft lesson L55: Loft drew wind and rail bearings uniformly at random, throwing the
1216    /// forecast heading away. Here the wind turns about the nominal direction, and the rail about
1217    /// its nominal heading, by normal deviates of the standard deviation asked for.
1218    #[test]
1219    fn wind_heading_dispersed_about_nominal() {
1220        let sd = 10_f64.to_radians();
1221        let run = MonteCarlo::new(
1222            nominal(),
1223            Dispersion {
1224                wind_heading_sd_rad: sd,
1225                rail_azimuth_sd_rad: sd,
1226                ..Dispersion::default()
1227            },
1228        )
1229        .unwrap();
1230        let n = 2000;
1231        let mut from = Vec::with_capacity(n);
1232        let mut headings = Vec::with_capacity(n);
1233        for index in 0..n as u64 {
1234            let inputs = run.inputs(&run.draw(SEED, index)).unwrap();
1235            let wind = inputs
1236                .environment
1237                .wind
1238                .wind(1500.0)
1239                .unwrap()
1240                .velocity_enu_m_s;
1241            // The direction it blows from, clockwise from north, about the nominal west (270°).
1242            let bearing = (-wind.x).atan2(-wind.y);
1243            from.push(bearing.rem_euclid(2.0 * PI) - 1.5 * PI);
1244            assert!((wind.truncate().length() - 5.0).abs() < 1e-12);
1245            headings.push(inputs.rail.azimuth_rad);
1246        }
1247        for turns in [from, headings] {
1248            let d = Distribution::new(turns, n).unwrap();
1249            let (mean, spread) = (d.mean().unwrap(), d.standard_deviation().unwrap());
1250            // Within four standard errors of 0 and of the deviation asked for.
1251            assert!(mean.abs() < 4.0 * sd / (n as f64).sqrt(), "{mean}");
1252            assert!(
1253                (spread / sd - 1.0).abs() < 4.0 / (2.0 * (n as f64 - 1.0)).sqrt(),
1254                "{spread}"
1255            );
1256        }
1257    }
1258
1259    /// Loft lesson L96: the same seed gives the same samples, and no dispersion gives the
1260    /// nominal flight in every sample, so a spread of exactly zero.
1261    #[test]
1262    fn zero_dispersion_reproduces_nominal_flight() {
1263        let nominal_flight = nominal().fly().unwrap();
1264        let run = MonteCarlo::new(nominal(), Dispersion::default())
1265            .unwrap()
1266            .run(SEED, 3);
1267        for sample in &run.samples {
1268            assert_eq!(sample.summary(), Some(&nominal_flight));
1269        }
1270        let apogee = run.apogee().unwrap();
1271        let nominal_apogee = nominal_flight.apogee.unwrap().height_above_ground_m;
1272        assert_eq!(apogee.mean(), Some(nominal_apogee));
1273        assert_eq!(apogee.standard_deviation(), Some(0.0));
1274        // The same seed, the same run, to the bit; it serializes and reads back whole.
1275        let dispersed = MonteCarlo::new(nominal(), every_dispersion()).unwrap();
1276        let first = dispersed.run(SEED, 2);
1277        assert_eq!(first, dispersed.run(SEED, 2));
1278        let json = serde_json::to_string(&first).unwrap();
1279        assert_eq!(serde_json::from_str::<Run>(&json).unwrap(), first);
1280    }
1281
1282    /// Valetudo's motor at four times its impulse in a quarter of its time takes it past Mach
1283    /// 1.2, where its flights need the supersonic table: the samples build the nominal's and
1284    /// share it, and each flies as the same inputs flown alone, on a table of their own, bit for
1285    /// bit.
1286    #[test]
1287    fn samples_share_the_nominal_table_and_fly_as_alone() {
1288        let mut inputs = nominal();
1289        let mounted = &mut inputs.rocket.configurations[0].motors[0];
1290        mounted.motor = dispersed_motor(&mounted.motor, 4.0, 0.25).unwrap();
1291        let monte_carlo = MonteCarlo::new(inputs, every_dispersion()).unwrap();
1292        let tables = &monte_carlo.shared.as_ref().unwrap().tables;
1293        assert!(!tables.supersonic_table_built());
1294        assert!(monte_carlo.sample(SEED, 0).summary().is_some());
1295        // The sample built the nominal's table.
1296        assert!(tables.supersonic_table_built());
1297        let table = tables.supersonic_body().unwrap();
1298        for index in 0..2 {
1299            let draw = monte_carlo.draw(SEED, index);
1300            let alone = monte_carlo.inputs(&draw).unwrap();
1301            assert!(alone.simulation().unwrap().share_supersonic_table(tables));
1302            let flight = alone.fly().unwrap();
1303            // The table carries the flow at the flight's peak, so it shapes the flight.
1304            let max_mach = flight.max_mach.as_ref().unwrap().value;
1305            assert!(table.weight(max_mach) > 0.0, "Mach {max_mach}");
1306            assert_eq!(monte_carlo.sample(SEED, index).summary(), Some(&flight));
1307            assert_eq!(monte_carlo.fly(&draw).unwrap(), flight);
1308        }
1309        // A fresh run on two and five threads, whose flights build the shared table on whichever
1310        // thread first needs it, is the run flown on one.
1311        #[cfg(feature = "parallel")]
1312        {
1313            let run = monte_carlo.run(SEED, 3);
1314            for threads in [2, 5] {
1315                let fresh =
1316                    MonteCarlo::new(monte_carlo.nominal().clone(), every_dispersion()).unwrap();
1317                let pool = rayon::ThreadPoolBuilder::new()
1318                    .num_threads(threads)
1319                    .build()
1320                    .unwrap();
1321                assert_eq!(
1322                    pool.install(|| fresh.run_parallel(SEED, 3)),
1323                    run,
1324                    "{threads}"
1325                );
1326            }
1327        }
1328    }
1329
1330    #[test]
1331    fn each_dispersion_moves_its_input() {
1332        let run = MonteCarlo::new(nominal(), every_dispersion()).unwrap();
1333        let draw = run.draw(SEED, 1);
1334        let inputs = run.inputs(&draw).unwrap();
1335        let base = run.nominal();
1336        // Mass and center: the stage's override, from the layout's nominal values.
1337        let layout = base.rocket.layout().unwrap();
1338        let flown = inputs.rocket.layout().unwrap();
1339        let (before, after) = (&layout.stages[0], &flown.stages[0]);
1340        let mass = after.mass.mass_kg / before.mass.mass_kg;
1341        assert!((mass - draw.dry_mass_scale[0]).abs() < 1e-14, "{mass}");
1342        let shift = before.mass.cg_m.z - after.mass.cg_m.z;
1343        assert!((shift - draw.cg_shift_m[0]).abs() < 1e-14, "{shift}");
1344        let inertia = after.mass.inertia_kg_m2.x_axis.x / before.mass.inertia_kg_m2.x_axis.x;
1345        assert!(
1346            (inertia - draw.dry_mass_scale[0]).abs() < 1e-14,
1347            "{inertia}"
1348        );
1349        // Drag.
1350        assert_eq!(inputs.drag_scale, draw.drag_scale);
1351        // Delays: the motor's (Valetudo's has none) and the drogue's lag.
1352        assert_eq!(
1353            inputs.rocket.configurations[0].motors[0].delay,
1354            base.rocket.configurations[0].motors[0].delay
1355        );
1356        assert_eq!(
1357            inputs.recovery[0].lag_s,
1358            (1.0 + draw.deployment_lag_offset_s[0]).max(0.0)
1359        );
1360        // Wind: scaled.
1361        let speed = |i: &FlightInputs| {
1362            i.environment
1363                .wind
1364                .wind(1500.0)
1365                .unwrap()
1366                .velocity_enu_m_s
1367                .length()
1368        };
1369        assert!((speed(&inputs) / speed(base) - draw.wind_speed_scale).abs() < 1e-14);
1370        // Rail.
1371        assert_eq!(
1372            inputs.rail.elevation_rad,
1373            base.rail.elevation_rad + draw.rail_elevation_offset_rad
1374        );
1375        // The flight flies, and its apogee isn't the nominal one.
1376        let flight = run.sample(SEED, 1);
1377        let apogee = |s: &FlightSummary| s.apogee.as_ref().unwrap().height_above_ground_m;
1378        let nominal_apogee = apogee(&base.fly().unwrap());
1379        assert_ne!(apogee(flight.summary().unwrap()), nominal_apogee);
1380    }
1381
1382    #[test]
1383    fn an_ejection_delay_moves_and_stops_at_zero() {
1384        let mut base = nominal();
1385        base.rocket.configurations[0].motors[0].delay = Some(Delay::Seconds(0.3));
1386        let run = MonteCarlo::new(
1387            base,
1388            Dispersion {
1389                ejection_delay_sd_s: 1.0,
1390                deployment_lag_sd_s: 1.0,
1391                ..Dispersion::default()
1392            },
1393        )
1394        .unwrap();
1395        let mut cut = [false; 2];
1396        let mut moved = [false; 2];
1397        for index in 0..40 {
1398            let draw = run.draw(SEED, index);
1399            let inputs = run.inputs(&draw).unwrap();
1400            let delay = draw.ejection_delay_offset_s[0] + 0.3;
1401            let Some(Delay::Seconds(flown)) = inputs.rocket.configurations[0].motors[0].delay
1402            else {
1403                unreachable!("a delay in seconds stays one")
1404            };
1405            assert_eq!(flown, delay.max(0.0));
1406            let lag = draw.deployment_lag_offset_s[0] + 1.0;
1407            assert_eq!(inputs.recovery[0].lag_s, lag.max(0.0));
1408            for (i, value) in [delay, lag].into_iter().enumerate() {
1409                cut[i] |= value < 0.0;
1410                moved[i] |= value > 0.0;
1411            }
1412        }
1413        assert_eq!((cut, moved), ([true; 2], [true; 2]));
1414    }
1415
1416    #[test]
1417    fn a_rail_past_vertical_leans_the_other_way() {
1418        let rail = Rail {
1419            azimuth_rad: 0.4,
1420            roll_rad: 0.1,
1421            ..Rail::vertical(2.0)
1422        };
1423        let leaned = tilted(rail, 0.03, 0.0);
1424        assert!((leaned.elevation_rad - (FRAC_PI_2 - 0.03)).abs() < 1e-15);
1425        // The same attitude as the angles taken past vertical.
1426        let past = LaunchAngles {
1427            azimuth_rad: 0.4,
1428            elevation_rad: FRAC_PI_2 + 0.03,
1429            roll_rad: 0.1,
1430        };
1431        let flown = LaunchAngles {
1432            azimuth_rad: leaned.azimuth_rad,
1433            elevation_rad: leaned.elevation_rad,
1434            roll_rad: leaned.roll_rad,
1435        };
1436        let (a, b) = (past.to_quaternion(), flown.to_quaternion());
1437        assert!(a.dot(b).abs() > 1.0 - 1e-15, "{a:?} {b:?}");
1438        assert!(leaned.validate().is_ok());
1439        // Below vertical, nothing turns.
1440        let below = tilted(rail, -0.03, 0.2);
1441        assert_eq!(below.roll_rad, 0.1);
1442        assert_eq!(below.azimuth_rad, 0.4 + 0.2);
1443    }
1444
1445    #[test]
1446    fn a_dispersed_wind_turns_clockwise() {
1447        // From the north at 4 m/s, turned a quarter turn clockwise: from the east, blowing west.
1448        let north = Arc::new(ConstantWind::new(4.0, 0.0).unwrap());
1449        let turned = DispersedWind::new(north, 1.5, FRAC_PI_2).unwrap();
1450        let v = turned.wind(100.0).unwrap().velocity_enu_m_s;
1451        assert!((v - DVec3::new(-6.0, 0.0, 0.0)).length() < 1e-14, "{v:?}");
1452        for (scale, turn) in [(-0.1, 0.0), (f64::NAN, 0.0), (1.0, f64::INFINITY)] {
1453            assert!(matches!(
1454                DispersedWind::new(Arc::new(ConstantWind::calm()), scale, turn),
1455                Err(AnalysisError::Domain { .. })
1456            ));
1457        }
1458    }
1459
1460    /// The drag scale reaches the flight: `FlightInputs` flies it as
1461    /// `Simulation::with_drag_scale` does, and a sample's factor multiplies a nominal scale.
1462    #[test]
1463    fn the_drag_scale_is_flown() {
1464        let mut scaled = nominal();
1465        scaled.drag_scale = 1.1;
1466        let by_hand = {
1467            let simulation = nominal()
1468                .simulation()
1469                .unwrap()
1470                .with_drag_scale(1.1)
1471                .unwrap();
1472            let mut metrics = FlightMetrics::new();
1473            let result = simulation.run(&mut metrics).unwrap();
1474            metrics.summary(&result, &scaled.environment).unwrap()
1475        };
1476        assert_eq!(scaled.fly().unwrap(), by_hand);
1477        assert_ne!(nominal().fly().unwrap(), by_hand);
1478        let run = MonteCarlo::new(
1479            scaled,
1480            Dispersion {
1481                drag_sd_fraction: 0.05,
1482                ..Dispersion::default()
1483            },
1484        )
1485        .unwrap();
1486        let draw = run.draw(SEED, 0);
1487        assert_eq!(run.inputs(&draw).unwrap().drag_scale, 1.1 * draw.drag_scale);
1488    }
1489
1490    /// A draw is checked against the rocket before it flies, and every entry that isn't at its
1491    /// nominal value is flown, whatever the dispersion: a draw written by hand flies as it says.
1492    #[test]
1493    fn a_draw_is_checked_and_flown_as_written() {
1494        let run = MonteCarlo::new(nominal(), Dispersion::default()).unwrap();
1495        let good = run.draw(SEED, 0);
1496        // Every list is checked against the rocket: one stage, one motor, one recovery device.
1497        type Field = fn(&mut Draw) -> &mut Vec<f64>;
1498        let fields: [Field; 6] = [
1499            |d| &mut d.dry_mass_scale,
1500            |d| &mut d.cg_shift_m,
1501            |d| &mut d.impulse_scale,
1502            |d| &mut d.burn_time_scale,
1503            |d| &mut d.ejection_delay_offset_s,
1504            |d| &mut d.deployment_lag_offset_s,
1505        ];
1506        for field in fields {
1507            for length in [0, 2] {
1508                let mut draw = good.clone();
1509                field(&mut draw).resize(length, 0.0);
1510                assert!(
1511                    matches!(
1512                        run.inputs(&draw),
1513                        Err(AnalysisError::Length { length: l, expected: 1, .. }) if l == length
1514                    ),
1515                    "{draw:?}"
1516                );
1517            }
1518        }
1519        // An entry that isn't a number is refused before it reaches a model that might not.
1520        let mut draw = good.clone();
1521        draw.rail_elevation_offset_rad = f64::NAN;
1522        assert!(matches!(
1523            run.inputs(&draw),
1524            Err(AnalysisError::Domain { what: "entry of a draw", value }) if value.is_nan()
1525        ));
1526        // No dispersion asked for, but the draw says 20% more drag and a calm wind (a factor
1527        // below zero is cut to calm).
1528        let mut draw = good;
1529        draw.drag_scale = 1.2;
1530        draw.wind_speed_scale = -0.3;
1531        let inputs = run.inputs(&draw).unwrap();
1532        assert_eq!(inputs.drag_scale, 1.2);
1533        let wind = inputs
1534            .environment
1535            .wind
1536            .wind(1500.0)
1537            .unwrap()
1538            .velocity_enu_m_s;
1539        assert_eq!(wind.length(), 0.0);
1540    }
1541
1542    /// The numbers a seed gives are part of every seeded result: these pin the stream key's fold
1543    /// and the order of a draw's inputs, so a change to either shows here first.
1544    #[test]
1545    fn a_seed_s_draws_are_pinned() {
1546        assert_eq!(SeededRng::for_stream(42, &[7]).next_u64(), GOLDEN_STREAM);
1547        let draw = MonteCarlo::new(nominal(), every_dispersion())
1548            .unwrap()
1549            .draw(SEED, 0);
1550        for (drawn, golden) in [
1551            (draw.drag_scale, GOLDEN_DRAG),
1552            (draw.impulse_scale[0], GOLDEN_IMPULSE),
1553        ] {
1554            assert!((drawn - golden).abs() < 1e-14, "{drawn:?}");
1555        }
1556    }
1557
1558    #[test]
1559    fn bad_setups_are_refused() {
1560        let error = MonteCarlo::new(
1561            nominal(),
1562            Dispersion {
1563                cg_sd_m: -0.1,
1564                ..Dispersion::default()
1565            },
1566        )
1567        .unwrap_err();
1568        assert!(matches!(
1569            error,
1570            AnalysisError::Domain { what: "center-of-mass standard deviation (m)", value }
1571                if value == -0.1
1572        ));
1573        let mut elsewhere = nominal();
1574        elsewhere.configuration_id = "missing".to_owned();
1575        assert!(matches!(
1576            MonteCarlo::new(elsewhere, Dispersion::default()),
1577            Err(AnalysisError::NoConfiguration(id)) if id == "missing"
1578        ));
1579    }
1580}