1use 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#[derive(Debug, Clone)]
111#[non_exhaustive]
112pub enum DragOverride {
113 Table(DragTable),
115 Model(Arc<dyn DragModel>),
117}
118
119#[derive(Debug, Clone)]
122pub struct FlightInputs {
123 pub rocket: Rocket,
125 pub configuration_id: String,
127 pub environment: Environment,
129 pub rail: Rail,
131 pub settings: FlightSettings,
133 pub recovery: Vec<Device>,
135 pub drag: Option<DragOverride>,
137 pub drag_scale: f64,
139 pub separations: Vec<Separation>,
142}
143
144impl FlightInputs {
145 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 pub fn simulation(&self) -> Result<Simulation, SimError> {
173 self.simulation_on(self.rocket.lay_out()?)
174 }
175
176 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 pub fn fly(&self) -> Result<FlightSummary, SimError> {
214 self.fly_on(self.simulation()?)
215 }
216
217 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 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#[derive(Debug, Clone)]
241struct Shared {
242 laid_out: LaidOut,
243 tables: AeroModel,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
249#[serde(default, deny_unknown_fields)]
250pub struct Dispersion {
251 pub dry_mass_sd_fraction: f64,
253 pub cg_sd_m: f64,
255 pub drag_sd_fraction: f64,
257 pub impulse_sd_fraction: f64,
259 pub burn_time_sd_fraction: f64,
261 pub ejection_delay_sd_s: f64,
263 pub wind_speed_sd_fraction: f64,
265 pub wind_heading_sd_rad: f64,
267 pub rail_elevation_sd_rad: f64,
269 pub rail_azimuth_sd_rad: f64,
271 pub deployment_lag_sd_s: f64,
273}
274
275impl Dispersion {
276 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 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
345pub struct Draw {
346 pub dry_mass_scale: Vec<f64>,
348 pub cg_shift_m: Vec<f64>,
350 pub drag_scale: f64,
352 pub impulse_scale: Vec<f64>,
354 pub burn_time_scale: Vec<f64>,
356 pub ejection_delay_offset_s: Vec<f64>,
358 pub wind_speed_scale: f64,
360 pub wind_turn_rad: f64,
362 pub rail_elevation_offset_rad: f64,
364 pub rail_azimuth_offset_rad: f64,
366 pub deployment_lag_offset_s: Vec<f64>,
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372#[serde(tag = "outcome", rename_all = "snake_case")]
373pub enum Outcome {
374 Flown {
377 summary: Box<FlightSummary>,
379 },
380 Failed {
382 at: FailedAt,
384 reason: String,
386 },
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(rename_all = "snake_case")]
392pub enum FailedAt {
393 Inputs,
396 Flight,
399}
400
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403pub struct Sample {
404 pub index: u64,
406 pub draw: Draw,
408 pub outcome: Outcome,
410}
411
412impl Sample {
413 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424pub struct Run {
425 pub seed: u64,
427 pub samples: Vec<Sample>,
429}
430
431impl Run {
432 pub fn failed(&self) -> impl Iterator<Item = &Sample> {
434 self.samples
435 .iter()
436 .filter(|sample| matches!(sample.outcome, Outcome::Failed { .. }))
437 }
438
439 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 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 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#[derive(Debug, Clone, Copy)]
493struct StageMass {
494 mass_kg: f64,
495 cg_aft_m: f64,
497}
498
499#[derive(Debug, Clone)]
501pub struct MonteCarlo {
502 nominal: FlightInputs,
503 dispersion: Dispersion,
504 stages: Vec<StageMass>,
505 configuration: usize,
506 shared: Option<Shared>,
509}
510
511impl MonteCarlo {
512 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 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 pub fn nominal(&self) -> &FlightInputs {
559 &self.nominal
560 }
561
562 pub fn dispersion(&self) -> &Dispersion {
564 &self.dispersion
565 }
566
567 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 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 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 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 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 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 pub fn fly(&self, draw: &Draw) -> Result<FlightSummary, AnalysisError> {
785 Ok(self.inputs(draw)?.fly_sharing(self.shared.as_ref())?)
786 }
787
788 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 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 #[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
838pub 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
896fn 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#[derive(Debug, Clone)]
916pub struct DispersedWind {
917 base: Arc<dyn Wind>,
918 speed_scale: f64,
919 turn_rad: f64,
920}
921
922impl DispersedWind {
923 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 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 const GOLDEN_STREAM: u64 = 2_627_254_379_500_910_771;
979 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 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 #[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 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 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 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 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 #[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 assert_ne!(run.draw(SEED + 1, 0), run.draw(SEED, 0));
1108 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 #[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 #[test]
1147 fn failed_samples_are_counted_and_reported() {
1148 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 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 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 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 #[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 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 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 #[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 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 #[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 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 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 #[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 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 assert_eq!(inputs.drag_scale, draw.drag_scale);
1351 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 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 assert_eq!(
1372 inputs.rail.elevation_rad,
1373 base.rail.elevation_rad + draw.rail_elevation_offset_rad
1374 );
1375 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 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 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 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 #[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 #[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 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 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 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 #[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}