1use std::f64::consts::TAU;
19
20use hpr_core::{DMat3, DVec3};
21use hpr_design::{Assembly, MassProperties, PlacedComponent, Rocket};
22use serde::{Deserialize, Serialize};
23
24use crate::dynamics::MassState;
25use crate::error::SimError;
26use crate::pieces::node;
27use crate::recovery::Trigger;
28
29pub const MIN_SHIFT_DURATION_S: f64 = 0.01;
37
38pub const SHIFT_STOPS: usize = 16;
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73#[non_exhaustive]
74pub struct MassShift {
75 pub trigger: Trigger,
77 pub component: String,
79 pub travel_m: f64,
81 pub duration_s: f64,
83}
84
85impl MassShift {
86 #[must_use]
89 pub fn new(
90 trigger: Trigger,
91 component: impl Into<String>,
92 travel_m: f64,
93 duration_s: f64,
94 ) -> Self {
95 Self {
96 trigger,
97 component: component.into(),
98 travel_m,
99 duration_s,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub(crate) enum NotAPart {
108 Missing,
110 BodyComponent,
112 Outside,
114 NotOnePart,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub(crate) enum NotCarried {
122 HoldsMotor,
124 StageOverride,
126 CoveredOverride,
128}
129
130pub(crate) fn locate_part(assembly: &Assembly, id: &str) -> Result<usize, NotAPart> {
133 let (index, placed) = assembly.layout.find(id).ok_or(NotAPart::Missing)?;
134 if placed.parent.is_none() || placed.part.is_body() {
136 Err(NotAPart::BodyComponent)
137 } else if placed.body_radius_m.is_some() {
138 Err(NotAPart::Outside)
139 } else if placed.copies.len() != 1 {
140 Err(NotAPart::NotOnePart)
141 } else {
142 Ok(index)
143 }
144}
145
146pub(crate) fn inside(components: &[PlacedComponent], index: usize, ancestor: usize) -> bool {
148 let mut at = components[index].parent;
149 while let Some(parent) = at {
150 if parent == ancestor {
151 return true;
152 }
153 at = components[parent].parent;
154 }
155 false
156}
157
158pub(crate) fn check_carried(
161 rocket: &Rocket,
162 assembly: &Assembly,
163 index: usize,
164) -> Result<(), NotCarried> {
165 let components = &assembly.layout.components;
166 let holds_motor = assembly.motors.iter().any(|motor| {
167 components
168 .iter()
169 .position(|component| component.id == motor.mount)
170 .is_some_and(|mount| mount == index || inside(components, mount, index))
171 });
172 if holds_motor {
173 return Err(NotCarried::HoldsMotor);
174 }
175 let stage = &assembly.layout.stages[components[index].stage];
176 if rocket
177 .stages
178 .iter()
179 .find(|written| written.id == stage.id)
180 .is_some_and(|written| !written.overrides.is_empty())
181 {
182 return Err(NotCarried::StageOverride);
183 }
184 let mut at = components[index].parent;
185 while let Some(parent) = at {
186 if node(rocket, &components[parent].id)
187 .is_some_and(|holder| holder.overrides_include_children && !holder.overrides.is_empty())
188 {
189 return Err(NotCarried::CoveredOverride);
190 }
191 at = components[parent].parent;
192 }
193 Ok(())
194}
195
196fn cycloid(tau: f64) -> (f64, f64, f64) {
199 if tau <= 0.0 {
200 (0.0, 0.0, 0.0)
201 } else if tau >= 1.0 {
202 (1.0, 0.0, 0.0)
203 } else {
204 let (sin, cos) = (TAU * tau).sin_cos();
205 (tau - sin / TAU, 1.0 - cos, TAU * sin)
206 }
207}
208
209#[derive(Debug, Clone)]
211struct ShiftTerms {
212 part: usize,
214 travel_m: f64,
215 duration_s: f64,
216 start_s: f64,
218}
219
220impl ShiftTerms {
221 fn offset(&self, t: f64) -> (f64, f64, f64) {
224 if !self.start_s.is_finite() {
225 return (0.0, 0.0, 0.0);
226 }
227 let (s, ds, dds) = cycloid((t - self.start_s) / self.duration_s);
228 let along = -self.travel_m;
229 (
230 along * s,
231 along * ds / self.duration_s,
232 along * dds / (self.duration_s * self.duration_s),
233 )
234 }
235}
236
237#[derive(Debug, Clone, Default)]
239pub(crate) struct Shifts {
240 parts: Vec<MassProperties>,
242 terms: Vec<ShiftTerms>,
243}
244
245impl Shifts {
246 pub(crate) fn new(
255 rocket: &Rocket,
256 assembly: &Assembly,
257 shifts: &[MassShift],
258 start_s: &[Option<f64>],
259 ) -> Result<Self, SimError> {
260 let components = &assembly.layout.components;
261 let refuse = |what: &'static str, component: &str| SimError::Shift {
262 what,
263 component: component.to_owned(),
264 };
265 let mut moved: Vec<usize> = Vec::new();
266 let mut terms = Vec::with_capacity(shifts.len());
267 for (shift, start_s) in shifts.iter().zip(start_s) {
268 let id = shift.component.as_str();
269 if !shift.travel_m.is_finite() {
270 return Err(SimError::Domain {
271 what: "travel of a mass shift, m",
272 value: shift.travel_m,
273 });
274 }
275 if !(shift.duration_s.is_finite() && shift.duration_s >= MIN_SHIFT_DURATION_S) {
276 return Err(SimError::Domain {
277 what: "duration of a mass shift, s (0.01 s or more)",
278 value: shift.duration_s,
279 });
280 }
281 let index = locate_part(assembly, id).map_err(|refusal| {
282 refuse(
283 match refusal {
284 NotAPart::Missing => {
285 "a mass shift names a component the design doesn't have"
286 }
287 NotAPart::BodyComponent => {
288 "a mass shift moves a part carried inside the airframe, and this is a \
289 body component"
290 }
291 NotAPart::Outside => {
292 "a mass shift moves a part carried inside the airframe, and this part \
293 is outside it"
294 }
295 NotAPart::NotOnePart => {
296 "a mass shift of a part that isn't exactly one part (one of several \
297 copies in a cluster of tubes, or none)"
298 }
299 },
300 id,
301 )
302 })?;
303 if !moved.contains(&index) {
304 moved.push(index);
305 }
306 terms.push(ShiftTerms {
307 part: moved.iter().position(|&part| part == index).unwrap_or(0),
308 travel_m: shift.travel_m,
309 duration_s: shift.duration_s,
310 start_s: start_s.unwrap_or(f64::INFINITY),
311 });
312 }
313
314 for &index in &moved {
315 let id = components[index].id.as_str();
316 if moved
317 .iter()
318 .any(|&other| other != index && inside(components, index, other))
319 {
320 return Err(refuse(
321 "a mass shift of a part inside another that moves",
322 id,
323 ));
324 }
325 check_carried(rocket, assembly, index).map_err(|refusal| {
326 refuse(
327 match refusal {
328 NotCarried::HoldsMotor => {
329 "a mass shift of a part that holds a motor (the motor would stay where \
330 it is)"
331 }
332 NotCarried::StageOverride => {
333 "a mass shift in a stage whose mass is overridden (the override \
334 doesn't say how much of it is the part's)"
335 }
336 NotCarried::CoveredOverride => {
337 "a mass shift inside a component whose overridden mass covers what it \
338 holds (the override doesn't say how much of it is the part's)"
339 }
340 },
341 id,
342 )
343 })?;
344 let (mut forward_m, mut aft_m) = (0.0, 0.0);
346 for (shift, term) in shifts.iter().zip(&terms) {
347 if moved[term.part] == index {
348 if shift.travel_m < 0.0 {
349 forward_m -= shift.travel_m;
350 } else {
351 aft_m += shift.travel_m;
352 }
353 }
354 }
355 let part = &components[index];
358 if let Some(holder) = part.parent.map(|parent| &components[parent])
359 && (part.fore_station_m - forward_m
360 < holder.fore_station_m.min(part.fore_station_m)
361 || part.fore_station_m + part.length_m + aft_m
362 > (holder.fore_station_m + holder.length_m)
363 .max(part.fore_station_m + part.length_m))
364 {
365 return Err(refuse(
366 "a mass shift that can take the part out of the component that holds it",
367 id,
368 ));
369 }
370 }
371
372 Ok(Self {
373 parts: moved
374 .iter()
375 .map(|&index| components[index].with_children)
376 .collect(),
377 terms,
378 })
379 }
380
381 pub(crate) fn is_empty(&self) -> bool {
383 self.terms.is_empty()
384 }
385
386 pub(crate) fn start(&mut self, index: usize, t_s: f64) {
388 if let Some(term) = self.terms.get_mut(index) {
389 term.start_s = t_s;
390 }
391 }
392
393 pub(crate) fn hold(&mut self, index: usize) {
395 self.start(index, f64::INFINITY);
396 }
397
398 pub(crate) fn start_s(&self, index: usize) -> Option<f64> {
400 self.terms
401 .get(index)
402 .map(|term| term.start_s)
403 .filter(|t| t.is_finite())
404 }
405
406 pub(crate) fn knots_s(&self) -> Vec<f64> {
409 (0..self.terms.len())
410 .flat_map(|index| self.stops_s(index))
411 .collect()
412 }
413
414 pub(crate) fn stops_s(&self, index: usize) -> Vec<f64> {
417 match (self.start_s(index), self.terms.get(index)) {
418 (Some(start_s), Some(term)) => (0..=SHIFT_STOPS)
419 .map(|k| start_s + term.duration_s * (k as f64 / SHIFT_STOPS as f64))
420 .collect(),
421 _ => Vec::new(),
422 }
423 }
424
425 fn offsets(&self, t: f64) -> Vec<(f64, f64, f64)> {
427 let mut offsets = vec![(0.0, 0.0, 0.0); self.parts.len()];
428 for term in &self.terms {
429 let (s, v, a) = term.offset(t);
430 let sum = &mut offsets[term.part];
431 *sum = (sum.0 + s, sum.1 + v, sum.2 + a);
432 }
433 offsets
434 }
435
436 pub(crate) fn apply(&self, whole: MassProperties, t: f64) -> MassProperties {
439 self.moved(whole, &self.offsets(t))
440 }
441
442 fn moved(&self, whole: MassProperties, offsets: &[(f64, f64, f64)]) -> MassProperties {
444 self.parts
445 .iter()
446 .zip(offsets)
447 .filter(|(_, (offset, _, _))| *offset != 0.0)
448 .fold(whole, |whole, (part, (offset, _, _))| {
449 whole.with_part_moved(part, DVec3::new(0.0, 0.0, *offset))
450 })
451 }
452
453 pub(crate) fn shift_state(&self, state: &mut MassState, mass_second_kg_s2: f64, t: f64) {
471 let offsets = self.offsets(t);
472 let whole = MassProperties {
473 mass_kg: state.mass_kg,
474 cg_m: state.cg_m,
475 inertia_kg_m2: state.inertia_cg,
476 };
477 let moved = self.moved(whole, &offsets);
478 state.cg_m = moved.cg_m;
479 state.inertia_cg = moved.inertia_kg_m2;
480 state.inertia_o = moved.inertia_about(DVec3::ZERO);
481 let big_m = state.mass_kg;
482 let (dm, ddm) = (state.mass_rate_kg_s, mass_second_kg_s2);
483 for (part, (offset, speed, acceleration)) in self.parts.iter().zip(offsets) {
484 if speed == 0.0 && acceleration == 0.0 && (offset == 0.0 || dm == 0.0) {
485 continue;
486 }
487 let m = part.mass_kg;
488 let (d, v, a) = (DVec3::Z * offset, DVec3::Z * speed, DVec3::Z * acceleration);
489 let m2 = big_m * big_m;
490 state.cg_rate_m_s += v * (m / big_m) - d * (m * dm / m2);
491 state.cg_accel_m_s2 += a * (m / big_m) - v * (2.0 * m * dm / m2)
492 + d * (m * (2.0 * dm * dm / (m2 * big_m) - ddm / m2));
493 let c = part.cg_m + d;
494 state.inertia_o_rate +=
495 (DMat3::from_diagonal(DVec3::splat(2.0 * c.dot(v))) - outer(v, c) - outer(c, v))
496 * m;
497 state.relative_momentum += c.cross(v) * m;
498 state.relative_momentum_rate += c.cross(a) * m;
499 }
500 }
501}
502
503fn outer(u: DVec3, v: DVec3) -> DMat3 {
505 DMat3::from_cols(u * v.x, u * v.y, u * v.z)
506}
507
508#[cfg(test)]
509mod tests {
510 use hpr_design::{Part, Position};
511
512 use super::*;
513 use crate::flight::{EventKind, FlightResult, FlightSettings, Simulation, Termination};
514 use crate::integrator::{Adaptive, Method};
515 use crate::metrics::FlightMetrics;
516 use crate::pieces::Ejection;
517 use crate::rail::Rail;
518 use crate::recorder::Sample;
519 use crate::state::State;
520 use crate::testing::{
521 Ends, UniformAir, analytic_environment, design, with_ballast, with_sleeve,
522 };
523
524 const G: f64 = 9.806_65;
525 const TRAVEL_M: f64 = 0.3;
527 const START_S: f64 = 5.0;
528 const DURATION_S: f64 = 1.0;
529
530 fn simulation(rocket: &Rocket, settings: FlightSettings) -> Simulation {
531 Simulation::new(
532 rocket,
533 "i175",
534 analytic_environment(UniformAir::sea_level(), G),
535 Rail::vertical(3.0),
536 settings,
537 )
538 .unwrap()
539 }
540
541 fn shift(trigger: Trigger) -> MassShift {
542 MassShift::new(trigger, "ballast", TRAVEL_M, DURATION_S)
543 }
544
545 fn ballast(sim: &Simulation) -> MassProperties {
547 sim.assembly()
548 .layout
549 .components
550 .iter()
551 .find(|component| component.id == "ballast")
552 .unwrap()
553 .with_children
554 }
555
556 fn close(a: f64, b: f64, tolerance: f64, what: &str) {
557 assert!(
558 (a - b).abs() <= tolerance,
559 "{what}: {a} vs {b} ({:e})",
560 a - b
561 );
562 }
563
564 #[test]
565 fn mass_properties_before_during_and_after_a_shift_match_the_hand_calculation() {
566 let sim = simulation(&with_ballast(0.0), FlightSettings::default())
567 .with_shifts(vec![shift(Trigger::Time { time_s: START_S })])
568 .unwrap();
569 let result = sim.run(&mut ()).unwrap();
570 assert_eq!(result.termination, Termination::GroundHit);
571 let started = result.event(EventKind::Shift(0)).unwrap().sample;
572 assert_eq!(started.time_s, START_S);
573
574 let part = ballast(&sim);
580 let still = sim.assembly().mass_properties(START_S);
581 let (m, big_m) = (part.mass_kg, still.mass_kg);
582 let mu = m * (big_m - m) / big_m;
583 let l = (part.cg_m - still.cg_m) * (big_m / (big_m - m));
584 for (t_s, fraction) in [(4.9, 0.0), (5.5, 0.5), (6.5, 1.0)] {
586 let delta = TRAVEL_M * fraction;
587 let got = sim.mass_properties(&result, t_s);
588 let what = format!("at {t_s} s");
589 assert_eq!(got.mass_kg, big_m, "{what}");
590 close(got.cg_m.x, still.cg_m.x, 1e-18, &what);
591 close(got.cg_m.y, still.cg_m.y, 1e-18, &what);
592 close(got.cg_m.z, still.cg_m.z - m * delta / big_m, 1e-15, &what);
593 let (i, i0) = (got.inertia_kg_m2, still.inertia_kg_m2);
594 let (x, y, z0, z1) = (l.x, l.y, l.z, l.z - delta);
595 let change = |i: f64, i0: f64, before: f64, after: f64, which: &str| {
597 close(
598 i - i0,
599 mu * (after - before),
600 1e-15,
601 &format!("{what}, {which}"),
602 );
603 };
604 change(
605 i.x_axis.x,
606 i0.x_axis.x,
607 y * y + z0 * z0,
608 y * y + z1 * z1,
609 "I_xx",
610 );
611 change(
612 i.y_axis.y,
613 i0.y_axis.y,
614 x * x + z0 * z0,
615 x * x + z1 * z1,
616 "I_yy",
617 );
618 change(i.z_axis.z, i0.z_axis.z, 0.0, 0.0, "I_zz");
619 change(i.x_axis.y, i0.x_axis.y, 0.0, 0.0, "I_xy");
620 change(i.x_axis.z, i0.x_axis.z, -x * z0, -x * z1, "I_xz");
621 change(i.y_axis.z, i0.y_axis.z, -y * z0, -y * z1, "I_yz");
622 }
623 close(big_m, 0.818_80, 1e-5, "mass");
625 close(m * TRAVEL_M / big_m, 0.073_28, 1e-5, "center's travel");
626 }
627
628 #[test]
629 fn a_moving_mass_shifts_the_static_margin_by_the_hand_calculation() {
630 let sim = simulation(&with_ballast(0.0), FlightSettings::default())
634 .with_shifts(vec![shift(Trigger::Time { time_s: START_S })])
635 .unwrap();
636 let mut metrics = FlightMetrics::new();
637 let result = sim.run(&mut metrics).unwrap();
638 let apogee_s = result.event(EventKind::Apogee).unwrap().sample.time_s;
639 assert!(apogee_s > START_S + DURATION_S + 1.0, "{apogee_s}");
640 let part = ballast(&sim);
641 let still = sim.assembly().mass_properties(START_S);
642 let coast: Vec<_> = metrics
643 .stability()
644 .iter()
645 .filter(|sample| sample.time_s > 3.0)
646 .collect();
647 let before = coast.first().unwrap();
648 let d = before.reference_diameter_m;
649 let margin_before = before.static_margin.margin_cal.unwrap();
650 let (mut during, mut after) = (0, 0);
651 for sample in &coast {
652 let (s, _, _) = cycloid((sample.time_s - START_S) / DURATION_S);
653 let expected = margin_before - part.mass_kg * TRAVEL_M * s / (still.mass_kg * d);
654 let got = sample.static_margin.margin_cal.unwrap();
655 close(
656 got,
657 expected,
658 1e-12,
659 &format!("margin at {} s", sample.time_s),
660 );
661 close(
662 sample.cg_station_m,
663 -still.cg_m.z + part.mass_kg * TRAVEL_M * s / still.mass_kg,
664 1e-15,
665 "center of mass",
666 );
667 if s > 0.0 && s < 1.0 {
668 during += 1;
669 } else if s == 1.0 {
670 after += 1;
671 }
672 }
673 assert!(during >= 3 && after >= 3, "{during} during, {after} after");
674 let margin_after = coast.last().unwrap().static_margin.margin_cal.unwrap();
676 close(margin_before, 4.2973, 1e-4, "before");
677 close(margin_before - margin_after, 1.3016, 1e-4, "change");
678 }
679
680 #[test]
681 fn a_shift_s_rates_are_the_derivatives_of_its_mass_properties() {
682 for (start_s, at_s) in [(0.5, 1.2), (4.0, 4.3)] {
686 let sim = simulation(&with_ballast(0.01), FlightSettings::default());
687 let mut vehicle = crate::dynamics::Vehicle::lit(
688 sim.assembly().clone(),
689 sim.aero().clone(),
690 sim.assembly().ignition_times_s(|_| None),
691 )
692 .unwrap();
693 vehicle.shifts = Shifts::new(
694 &with_ballast(0.01),
695 sim.assembly(),
696 &[shift(Trigger::Time { time_s: start_s })],
697 &[Some(start_s)],
698 )
699 .unwrap();
700 let window = (start_s, start_s + DURATION_S);
701 let state = vehicle.mass_state(at_s, window);
702 let h = 1e-6;
703 let at = |t: f64| {
704 let whole = vehicle
705 .assembly
706 .mass_properties_lit(t, vehicle.ignition_s());
707 vehicle.shifts.apply(whole, t)
708 };
709 let (minus, mid, plus) = (at(at_s - h), at(at_s), at(at_s + h));
710 let what = format!("at {at_s} s");
711 assert_eq!(state.cg_m, mid.cg_m, "{what}");
712 let rate = (plus.cg_m - minus.cg_m) / (2.0 * h);
713 let wide = 1e-4;
714 let accel =
715 (at(at_s + wide).cg_m - 2.0 * mid.cg_m + at(at_s - wide).cg_m) / (wide * wide);
716 let inertia_rate =
717 (plus.inertia_about(DVec3::ZERO) - minus.inertia_about(DVec3::ZERO)) * (0.5 / h);
718 assert!((state.cg_rate_m_s - rate).length() < 1e-9, "{what}: r′");
719 assert!(
722 (state.cg_accel_m_s2 - accel).length() < 1e-7 * accel.length(),
723 "{what}: r″ {:?} vs {accel:?}",
724 state.cg_accel_m_s2
725 );
726 let error = (state.inertia_o_rate - inertia_rate)
727 .to_cols_array()
728 .iter()
729 .fold(0.0_f64, |m, v| m.max(v.abs()));
730 assert!(error < 1e-9, "{what}: I′ {error:e}");
731 let (_, ds, _) = cycloid((at_s - start_s) / DURATION_S);
733 let speed = -TRAVEL_M * ds / DURATION_S;
734 let part = ballast(&sim);
735 let expected =
736 DVec3::new(part.cg_m.y * speed, -part.cg_m.x * speed, 0.0) * part.mass_kg;
737 assert!(
738 (state.relative_momentum - expected).length() < 1e-18,
739 "{what}: h"
740 );
741 assert!(expected.length() > 1e-4, "{what}: h {expected:?}");
742 }
743 }
744
745 #[test]
746 fn a_part_moving_off_the_axis_keeps_both_momenta_in_free_space() {
747 let t0 = 10.0;
752 let settings = FlightSettings {
753 method: Method::DormandPrince54(Adaptive {
754 relative_tolerance: 1e-12,
755 absolute_tolerance: 1e-12,
756 ..Adaptive::default()
757 }),
758 max_time_s: t0 + 2.0,
759 ..FlightSettings::default()
760 };
761 let sim = Simulation::new(
762 &with_ballast(0.01),
763 "i175",
764 analytic_environment(UniformAir::vacuum(), 0.0),
765 Rail::vertical(3.0),
766 settings,
767 )
768 .unwrap()
769 .with_shifts(vec![MassShift::new(
770 Trigger::Time { time_s: t0 + 0.5 },
771 "ballast",
772 TRAVEL_M,
773 DURATION_S,
774 )])
775 .unwrap();
776 let attitude = Rail::vertical(3.0).attitude();
777 let cg_m = sim.assembly().mass_properties(t0).cg_m;
778 let state = State {
779 position_enu_m: DVec3::new(0.0, 0.0, 1000.0) - attitude.mul_vec3(cg_m),
780 velocity_enu_m_s: DVec3::new(3.0, -2.0, 10.0),
781 attitude,
782 body_rate_rad_s: DVec3::new(0.5, 0.2, 3.0),
783 };
784 let mut ends = Ends::default();
785 let result: FlightResult = sim.run_free(t0, state, &mut ends).unwrap();
786 assert_eq!(result.termination, Termination::TimeCap);
787 assert!(result.event(EventKind::Shift(0)).is_some());
788
789 let part = ballast(&sim);
790 let momentum = |sample: &Sample| {
791 let t = sample.time_s;
792 let (s, ds, _) = cycloid((t - t0 - 0.5) / DURATION_S);
793 let rho = part.cg_m - DVec3::Z * (TRAVEL_M * s);
794 let rho_rate = -DVec3::Z * (TRAVEL_M * ds / DURATION_S);
795 let whole = sim.mass_properties(&result, t);
796 let relative = (rho - whole.cg_m).cross(rho_rate) * part.mass_kg;
797 let body = whole.inertia_kg_m2 * sample.state.body_rate_rad_s + relative;
798 (sample.state.unit_attitude().mul_vec3(body), relative)
799 };
800 let (h0, _) = momentum(&ends.0[0]);
801 let v0 = ends.0[0].cg_velocity_enu_m_s;
802 let (mut angular_error, mut linear_error, mut relative_peak) = (0.0_f64, 0.0_f64, 0.0_f64);
803 for sample in &ends.0 {
804 let (h, relative) = momentum(sample);
805 angular_error = angular_error.max((h - h0).length() / h0.length());
806 linear_error = linear_error.max((sample.cg_velocity_enu_m_s - v0).length());
807 relative_peak = relative_peak.max(relative.length() / h0.length());
808 }
809 assert!(ends.0.len() > 20, "{} steps", ends.0.len());
810 assert!(angular_error < 1e-10, "angular momentum: {angular_error:e}");
814 assert!(
815 linear_error < 1e-10,
816 "center's velocity: {linear_error:e} m/s"
817 );
818 assert!(
819 relative_peak > 1e-2,
820 "relative angular momentum: {relative_peak:e}"
821 );
822 }
823
824 #[test]
825 fn a_shift_starts_at_apogee_or_at_its_height_on_the_way_down() {
826 for (trigger, what) in [
827 (Trigger::Apogee, "apogee"),
828 (
829 Trigger::Altitude {
830 height_above_ground_m: 200.0,
831 },
832 "height",
833 ),
834 ] {
835 let sim = simulation(&with_ballast(0.0), FlightSettings::default())
836 .with_shifts(vec![shift(trigger)])
837 .unwrap();
838 let result = sim.run(&mut ()).unwrap();
839 let started = result.event(EventKind::Shift(0)).unwrap().sample;
840 let apogee = result.event(EventKind::Apogee).unwrap().sample;
841 match trigger {
842 Trigger::Apogee => close(started.time_s, apogee.time_s, 0.0, what),
843 _ => {
844 assert!(started.vertical_speed_m_s < 0.0, "{started:?}");
845 close(started.height_above_ground_m, 200.0, 1e-6, what);
846 }
847 }
848 let part = ballast(&sim);
850 let still = sim.assembly().mass_properties(started.time_s);
851 let before = sim.mass_properties(&result, started.time_s - 1e-3);
852 let after = sim.mass_properties(&result, started.time_s + DURATION_S);
853 assert_eq!(
854 before,
855 sim.assembly().mass_properties(started.time_s - 1e-3)
856 );
857 close(
858 after.cg_m.z,
859 still.cg_m.z - part.mass_kg * TRAVEL_M / still.mass_kg,
860 1e-15,
861 what,
862 );
863 }
864 }
865
866 fn refusal(shifts: Vec<MassShift>) -> SimError {
868 simulation(&with_ballast(0.0), FlightSettings::default())
869 .with_shifts(shifts)
870 .unwrap_err()
871 }
872
873 #[test]
874 fn shifts_that_cannot_be_made_are_refused() {
875 let time = Trigger::Time { time_s: START_S };
876 let refused = |component: &str, travel_m: f64, starts: &str| {
877 let error = refusal(vec![MassShift::new(time, component, travel_m, 1.0)]);
878 let SimError::Shift {
879 what,
880 component: id,
881 } = &error
882 else {
883 panic!("{error:?}");
884 };
885 assert!(what.starts_with(starts), "{component}: {what}");
886 assert_eq!(id, component);
887 };
888 refused("no-such-part", 0.1, "a mass shift names a component");
889 refused(
890 "sustainer-airframe",
891 0.1,
892 "a mass shift moves a part carried inside the airframe, and this is a body",
893 );
894 refused(
895 "sustainer-rail-buttons",
896 0.1,
897 "a mass shift moves a part carried inside the airframe, and this part is outside",
898 );
899 refused(
900 "sustainer-motor-mount",
901 0.1,
902 "a mass shift of a part that holds a motor",
903 );
904 refused("ballast", -0.15, "a mass shift that can take the part out");
906 refused("ballast", 0.8, "a mass shift that can take the part out");
907 let forward = MassShift::new(time, "ballast", -0.04, 1.0);
909 simulation(&with_ballast(0.0), FlightSettings::default())
910 .with_shifts(vec![forward.clone(), forward.clone()])
911 .unwrap();
912 assert!(matches!(
913 refusal(vec![forward.clone(), forward.clone(), forward]),
914 SimError::Shift { what, .. } if what.starts_with("a mass shift that can take")
915 ));
916
917 for (travel_m, duration_s, starts) in [
918 (f64::NAN, 1.0, "travel of a mass shift"),
919 (0.1, 0.009, "duration of a mass shift"),
920 (0.1, f64::INFINITY, "duration of a mass shift"),
921 ] {
922 let error = refusal(vec![MassShift::new(time, "ballast", travel_m, duration_s)]);
923 assert!(
924 matches!(&error, SimError::Domain { what, .. } if what.starts_with(starts)),
925 "{error:?}"
926 );
927 }
928 let error = refusal(vec![shift(Trigger::Altitude {
929 height_above_ground_m: -1.0,
930 })]);
931 assert!(
932 matches!(&error, SimError::Domain { what, .. } if what.starts_with("height above the launch site at which a part")),
933 "{error:?}"
934 );
935
936 let ejection = Ejection::aft_of(Trigger::Apogee, "nose");
938 let error = simulation(&with_ballast(0.0), FlightSettings::default())
939 .with_shifts(vec![shift(time)])
940 .unwrap()
941 .with_ejections(vec![ejection.clone()])
942 .unwrap_err();
943 assert!(
944 matches!(error, SimError::Unsupported { what } if what.starts_with("a mass shift in a flight with"))
945 );
946 let error = simulation(&with_ballast(0.0), FlightSettings::default())
947 .with_ejections(vec![ejection])
948 .unwrap()
949 .with_shifts(vec![shift(time)])
950 .unwrap_err();
951 assert!(
952 matches!(error, SimError::Unsupported { what } if what.starts_with("a mass shift in a flight with"))
953 );
954 }
955
956 fn lenient(rocket: &Rocket) -> Simulation {
958 simulation(
959 rocket,
960 FlightSettings {
961 accept_design_errors: true,
962 ..FlightSettings::default()
963 },
964 )
965 }
966
967 fn shift_refusal(sim: Simulation, shifts: Vec<MassShift>) -> (&'static str, String) {
969 match sim.with_shifts(shifts) {
970 Err(SimError::Shift { what, component }) => (what, component),
971 other => panic!("{other:?}"),
972 }
973 }
974
975 #[test]
979 fn a_pod_s_parts_are_located_as_the_airframe_s_are() {
980 let podded = |count: u32| {
981 let mut rocket = with_sleeve();
982 let airframe = &mut rocket.stages[0].components[1];
983 let mut held = airframe
984 .children
985 .iter()
986 .find(|child| child.id == "ballast")
987 .unwrap()
988 .clone();
989 held.id = "pod-ballast".to_owned();
990 held.position = Some(Position::Top { aft_offset_m: 0.0 });
991 let mut pod_tube = airframe.clone();
992 pod_tube.id = "pod-tube".to_owned();
993 pod_tube.auto.clear();
994 pod_tube.motor_mount = None;
995 pod_tube.children = vec![held];
996 let mut pods = pod_tube.clone();
997 pods.id = "pods".to_owned();
998 pods.part = Part::PodSet(hpr_design::PodSet {
999 count,
1000 radial_offset_m: 0.2,
1001 angle_rad: 0.0,
1002 });
1003 pods.position = Some(Position::Top { aft_offset_m: 0.0 });
1004 pods.children = vec![pod_tube];
1005 airframe.children.push(pods);
1006 let id = rocket.configurations[0].id.clone();
1007 rocket.assemble(&id).unwrap()
1008 };
1009 let one = podded(1);
1010 assert_eq!(locate_part(&one, "pod-tube"), Err(NotAPart::BodyComponent));
1011 assert_eq!(locate_part(&one, "pods"), Err(NotAPart::Outside));
1012 assert!(locate_part(&one, "pod-ballast").is_ok());
1013 assert_eq!(
1014 locate_part(&podded(2), "pod-ballast"),
1015 Err(NotAPart::NotOnePart)
1016 );
1017 }
1018
1019 #[test]
1020 fn refusals_that_need_a_design_of_their_own() {
1021 let time = Trigger::Time { time_s: START_S };
1022 let move_by = |id: &str, travel_m: f64| MassShift::new(time, id, travel_m, 1.0);
1023
1024 lenient(&with_sleeve())
1026 .with_shifts(vec![move_by("sleeve", 0.01)])
1027 .unwrap();
1028 let (what, id) = shift_refusal(
1029 lenient(&with_sleeve()),
1030 vec![move_by("sleeve", 0.01), move_by("held", 0.01)],
1031 );
1032 assert!(
1033 what.starts_with("a mass shift of a part inside another"),
1034 "{what}"
1035 );
1036 assert_eq!(id, "held");
1037
1038 let mut clustered = with_sleeve();
1040 let sleeve = clustered.stages[0].components[1]
1041 .children
1042 .iter_mut()
1043 .find(|child| child.id == "sleeve")
1044 .unwrap();
1045 let Part::InnerTube(tube) = &mut sleeve.part else {
1046 panic!("the motor mount is an inner tube");
1047 };
1048 tube.cluster_m = vec![[0.004, 0.0], [-0.004, 0.0]];
1049 let (what, id) = shift_refusal(lenient(&clustered), vec![move_by("held", 0.01)]);
1050 assert!(
1051 what.starts_with("a mass shift of a part that isn't exactly one"),
1052 "{what}"
1053 );
1054 assert_eq!(id, "held");
1055
1056 let mut overridden = with_ballast(0.0);
1058 overridden.stages[0].overrides.mass_kg = Some(1.0);
1059 let (what, id) = shift_refusal(lenient(&overridden), vec![move_by("ballast", 0.1)]);
1060 assert!(
1061 what.starts_with("a mass shift in a stage whose mass is overridden"),
1062 "{what}"
1063 );
1064 assert_eq!(id, "ballast");
1065
1066 for covers_children in [false, true] {
1068 let mut overridden = with_ballast(0.0);
1069 let airframe = &mut overridden.stages[0].components[1];
1070 airframe.overrides.mass_kg = Some(0.5);
1071 airframe.overrides_include_children = covers_children;
1072 let result = lenient(&overridden).with_shifts(vec![move_by("ballast", 0.1)]);
1073 if covers_children {
1074 assert!(
1075 matches!(&result, Err(SimError::Shift { what, component })
1076 if what.starts_with("a mass shift inside a component whose overridden")
1077 && component == "ballast"),
1078 "{result:?}"
1079 );
1080 } else {
1081 result.unwrap();
1082 }
1083 }
1084
1085 let mut past = with_ballast(0.0);
1087 let ballast = past.stages[0].components[1]
1088 .children
1089 .iter_mut()
1090 .find(|child| child.id == "ballast")
1091 .unwrap();
1092 ballast.position = Some(Position::Top { aft_offset_m: 0.87 });
1093 lenient(&past)
1094 .with_shifts(vec![move_by("ballast", -0.1)])
1095 .unwrap();
1096 let (what, _) = shift_refusal(lenient(&past), vec![move_by("ballast", 0.01)]);
1097 assert!(
1098 what.starts_with("a mass shift that can take the part out"),
1099 "{what}"
1100 );
1101 let ballast = past.stages[0].components[1]
1103 .children
1104 .iter_mut()
1105 .find(|child| child.id == "ballast")
1106 .unwrap();
1107 ballast.position = Some(Position::Top {
1108 aft_offset_m: -0.02,
1109 });
1110 lenient(&past)
1111 .with_shifts(vec![move_by("ballast", 0.1)])
1112 .unwrap();
1113 let (what, _) = shift_refusal(lenient(&past), vec![move_by("ballast", -0.01)]);
1114 assert!(
1115 what.starts_with("a mass shift that can take the part out"),
1116 "{what}"
1117 );
1118
1119 let mut overridden = with_ballast(0.0);
1121 overridden.stages[0].overrides.cg_aft_m = Some(0.5);
1122 let (what, _) = shift_refusal(lenient(&overridden), vec![move_by("ballast", 0.1)]);
1123 assert!(
1124 what.starts_with("a mass shift in a stage whose mass is overridden"),
1125 "{what}"
1126 );
1127 }
1128
1129 #[test]
1130 fn triggers_a_shift_cannot_have_are_refused_in_its_own_words() {
1131 let domain = |result: Result<Simulation, SimError>| match result {
1132 Err(SimError::Domain { what, .. }) => what,
1133 other => panic!("{other:?}"),
1134 };
1135 let sim = || lenient(&with_ballast(0.0));
1136 let what = domain(sim().with_shifts(vec![shift(Trigger::Time { time_s: -1.0 })]));
1137 assert!(
1138 what.starts_with("start time of a mass shift after launch"),
1139 "{what}"
1140 );
1141 let what = domain(sim().with_shifts(vec![shift(Trigger::Burnout {
1142 motor: 0,
1143 delay_s: -1.0,
1144 })]));
1145 assert!(
1146 what.starts_with("delay after a motor's burnout, s"),
1147 "{what}"
1148 );
1149 let what = domain(sim().with_shifts(vec![shift(Trigger::MotorDelay { motor: 3 })]));
1150 assert!(
1151 what.starts_with("index of the motor whose delay starts a mass shift"),
1152 "{what}"
1153 );
1154 let mut plugged = with_ballast(0.0);
1155 plugged.configurations[0].motors[0].delay = None;
1156 let what =
1157 domain(lenient(&plugged).with_shifts(vec![shift(Trigger::MotorDelay { motor: 0 })]));
1158 assert!(
1159 what.starts_with("the motor whose delay starts a mass shift has no ejection"),
1160 "{what}"
1161 );
1162 let mut failed = with_ballast(0.0);
1164 failed.configurations[0].motors[0].failed_tubes = vec![0];
1165 let what = domain(lenient(&failed).with_shifts(vec![shift(Trigger::Burnout {
1166 motor: 0,
1167 delay_s: 1.0,
1168 })]));
1169 assert!(
1170 what.starts_with("index of the motor a mass shift is timed from"),
1171 "{what}"
1172 );
1173 let result = sim()
1175 .with_shifts(vec![shift(Trigger::Time { time_s: START_S })])
1176 .unwrap()
1177 .with_separation(crate::recovery::Separation::new(Trigger::Apogee, 0));
1178 assert!(
1179 matches!(&result, Err(SimError::Unsupported { what }) if what.starts_with("a mass shift in a flight with")),
1180 "{result:?}"
1181 );
1182 let two_stage = Simulation::new(
1184 &design("synthetic-two-stage-75mm-54mm"),
1185 "j760-i175",
1186 analytic_environment(UniformAir::sea_level(), G),
1187 Rail::vertical(3.0),
1188 FlightSettings::default(),
1189 )
1190 .unwrap()
1191 .with_separation(crate::recovery::Separation::new(Trigger::Apogee, 0))
1192 .unwrap()
1193 .with_shifts(vec![shift(Trigger::Time { time_s: START_S })]);
1194 assert!(
1195 matches!(&two_stage, Err(SimError::Unsupported { what }) if what.starts_with("a mass shift in a flight with")),
1196 "{two_stage:?}"
1197 );
1198 for time_s in [0.0, 0.1] {
1200 let error = sim()
1201 .with_shifts(vec![shift(Trigger::Time { time_s })])
1202 .unwrap()
1203 .run(&mut ())
1204 .unwrap_err();
1205 assert!(
1206 matches!(&error, SimError::Domain { what, value }
1207 if what.starts_with("start time of a mass shift, s (it must start once")
1208 && *value == time_s),
1209 "{error:?}"
1210 );
1211 }
1212 }
1213
1214 #[test]
1215 fn a_canopy_that_opens_while_the_ballast_moves_keeps_the_center_s_velocity() {
1216 let drogue = crate::recovery::Device::new(
1221 "drogue",
1222 crate::recovery::DeviceDrag::canopy(crate::recovery::CanopyType::FlatCircular, 0.6),
1223 Trigger::Apogee,
1224 )
1225 .with_lag_s(0.5);
1226 let sim = Simulation::new(
1227 &with_ballast(0.0),
1228 "i175",
1229 analytic_environment(UniformAir::vacuum(), G),
1230 Rail::vertical(3.0),
1231 FlightSettings {
1232 method: Method::DormandPrince54(Adaptive {
1233 relative_tolerance: 1e-12,
1234 absolute_tolerance: 1e-12,
1235 ..Adaptive::default()
1236 }),
1237 ..FlightSettings::default()
1238 },
1239 )
1240 .unwrap()
1241 .with_recovery(vec![drogue])
1242 .unwrap()
1243 .with_shifts(vec![shift(Trigger::Apogee)])
1244 .unwrap();
1245 let mut ends = Ends::default();
1246 let result = sim.run(&mut ends).unwrap();
1247 let started = result.event(EventKind::Shift(0)).unwrap().sample.time_s;
1248 let opened = result.event(EventKind::Deployment(0)).unwrap().sample;
1249 close(
1250 opened.time_s - started,
1251 0.5,
1252 1e-9,
1253 "the drogue opens halfway",
1254 );
1255 let before = ends
1257 .0
1258 .iter()
1259 .rfind(|sample| sample.time_s == opened.time_s && sample.phase == crate::Phase::Free)
1260 .unwrap();
1261 let jump = (opened.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s).length();
1262 assert!(jump < 1e-12, "the center's velocity jumps by {jump:e} m/s");
1263 let relative = (before.state.velocity_enu_m_s - before.cg_velocity_enu_m_s).length();
1265 assert!(relative > 0.01, "{relative} m/s");
1266 let mut checked = 0;
1268 for sample in ends.0.iter().filter(|sample| {
1269 sample.phase == crate::Phase::Descent && sample.time_s <= started + 2.0 * DURATION_S
1270 }) {
1271 let fallen =
1272 opened.cg_velocity_enu_m_s - DVec3::Z * (G * (sample.time_s - opened.time_s));
1273 let error = (sample.cg_velocity_enu_m_s - fallen).length();
1274 assert!(error < 1e-9, "at {} s: {error:e} m/s", sample.time_s);
1275 checked += 1;
1276 }
1277 assert!(checked >= 5, "{checked} samples");
1278 }
1279
1280 #[test]
1281 fn a_shift_the_flight_starts_gets_its_stops_too() {
1282 let sim = simulation(
1284 &with_ballast(0.0),
1285 FlightSettings {
1286 method: Method::Rk4 { step_s: 0.05 },
1287 ..FlightSettings::default()
1288 },
1289 )
1290 .with_shifts(vec![MassShift::new(
1291 Trigger::Apogee,
1292 "ballast",
1293 TRAVEL_M,
1294 MIN_SHIFT_DURATION_S,
1295 )])
1296 .unwrap();
1297 let mut ends = Ends::default();
1298 let result = sim.run(&mut ends).unwrap();
1299 let started = result.event(EventKind::Shift(0)).unwrap().sample.time_s;
1300 let during = ends
1301 .0
1302 .iter()
1303 .filter(|sample| {
1304 sample.time_s > started && sample.time_s <= started + MIN_SHIFT_DURATION_S
1305 })
1306 .count();
1307 assert_eq!(during, SHIFT_STOPS);
1308 }
1309
1310 #[test]
1311 fn a_fixed_step_follows_a_short_shift_in_its_stops() {
1312 let t0 = 10.0;
1315 let settings = FlightSettings {
1316 method: Method::Rk4 { step_s: 0.01 },
1317 max_time_s: t0 + 1.0,
1318 ..FlightSettings::default()
1319 };
1320 let sim = Simulation::new(
1321 &with_ballast(0.01),
1322 "i175",
1323 analytic_environment(UniformAir::vacuum(), 0.0),
1324 Rail::vertical(3.0),
1325 settings,
1326 )
1327 .unwrap()
1328 .with_shifts(vec![MassShift::new(
1329 Trigger::Time { time_s: t0 + 0.5 },
1330 "ballast",
1331 TRAVEL_M,
1332 MIN_SHIFT_DURATION_S,
1333 )])
1334 .unwrap();
1335 let attitude = Rail::vertical(3.0).attitude();
1336 let cg_m = sim.assembly().mass_properties(t0).cg_m;
1337 let state = State {
1338 position_enu_m: DVec3::new(0.0, 0.0, 1000.0) - attitude.mul_vec3(cg_m),
1339 velocity_enu_m_s: DVec3::new(3.0, -2.0, 10.0),
1340 attitude,
1341 body_rate_rad_s: DVec3::new(0.5, 0.2, 3.0),
1342 };
1343 let mut ends = Ends::default();
1344 sim.run_free(t0, state, &mut ends).unwrap();
1345 let v0 = ends.0[0].cg_velocity_enu_m_s;
1346 let error = ends
1347 .0
1348 .iter()
1349 .map(|sample| (sample.cg_velocity_enu_m_s - v0).length())
1350 .fold(0.0_f64, f64::max);
1351 let during = ends
1352 .0
1353 .iter()
1354 .filter(|sample| sample.time_s > t0 + 0.5 && sample.time_s <= t0 + 0.51)
1355 .count();
1356 assert_eq!(during, SHIFT_STOPS);
1359 assert!(error < 3e-4, "center's velocity: {error:e} m/s");
1360 }
1361
1362 #[test]
1363 fn the_cycloid_rests_at_both_ends_and_is_fastest_halfway() {
1364 assert_eq!(cycloid(-0.5), (0.0, 0.0, 0.0));
1365 assert_eq!(cycloid(1.5), (1.0, 0.0, 0.0));
1366 let (s, ds, dds) = cycloid(0.5);
1367 assert!((s - 0.5).abs() < 1e-16);
1368 assert!((ds - 2.0).abs() < 1e-15);
1369 assert!(dds.abs() < 1e-14);
1370 let (s, ds, dds) = cycloid(0.25);
1372 assert!((s - (0.25 - 1.0 / TAU)).abs() < 1e-16);
1373 assert!((ds - 1.0).abs() < 1e-15);
1374 assert!((dds - TAU).abs() < 1e-14);
1375 let h = 1e-6;
1377 for tau in [0.1, 0.3, 0.7, 0.9] {
1378 let (s_minus, ds_minus, _) = cycloid(tau - h);
1379 let (s_plus, ds_plus, _) = cycloid(tau + h);
1380 let (_, ds, dds) = cycloid(tau);
1381 assert!(((s_plus - s_minus) / (2.0 * h) - ds).abs() < 1e-8, "{tau}");
1382 assert!(
1383 ((ds_plus - ds_minus) / (2.0 * h) - dds).abs() < 1e-7,
1384 "{tau}"
1385 );
1386 }
1387 }
1388}