1use hpr_core::DVec3;
19use hpr_design::{Assembly, MassProperties, Rocket};
20use serde::{Deserialize, Serialize};
21
22use crate::error::SimError;
23use crate::events::Direction;
24use crate::flight::{EventKind, Simulation, Termination};
25use crate::integrator::{Advance, IntegrationError, Integrator, OdeSystem, Stats};
26use crate::recovery::{BodyEvent, BodySample, Trigger};
27use crate::shifts::{NotAPart, NotCarried, check_carried, inside, locate_part};
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56#[non_exhaustive]
57pub struct MassRelease {
58 pub trigger: Trigger,
60 pub component: String,
62 pub drag_area_m2: f64,
64}
65
66impl MassRelease {
67 #[must_use]
69 pub fn new(trigger: Trigger, component: impl Into<String>, drag_area_m2: f64) -> Self {
70 Self {
71 trigger,
72 component: component.into(),
73 drag_area_m2,
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[non_exhaustive]
81pub struct ReleasedFlight {
82 pub release: usize,
84 pub start_sample: BodySample,
87 pub termination: Termination,
89 pub events: Vec<BodyEvent>,
92 pub final_sample: BodySample,
94 pub stats: Stats,
96}
97
98impl ReleasedFlight {
99 #[must_use]
101 pub fn event(&self, kind: EventKind) -> Option<&BodyEvent> {
102 self.events.iter().find(|event| event.kind == kind)
103 }
104}
105
106#[derive(Debug, Clone, Default)]
108pub(crate) struct Releases {
109 parts: Vec<MassProperties>,
111}
112
113impl Releases {
114 pub(crate) fn new(
122 rocket: &Rocket,
123 assembly: &Assembly,
124 releases: &[MassRelease],
125 ) -> Result<Self, SimError> {
126 let components = &assembly.layout.components;
127 let refuse = |what: &'static str, component: &str| SimError::MassRelease {
128 what,
129 component: component.to_owned(),
130 };
131 let mut indices: Vec<usize> = Vec::with_capacity(releases.len());
132 for release in releases {
133 let id = release.component.as_str();
134 if !(release.drag_area_m2.is_finite() && release.drag_area_m2 > 0.0) {
135 return Err(SimError::Domain {
136 what: "drag area of a released part, m² (positive)",
137 value: release.drag_area_m2,
138 });
139 }
140 let index = locate_part(assembly, id).map_err(|refusal| {
141 refuse(
142 match refusal {
143 NotAPart::Missing => {
144 "a mass release names a component the design doesn't have"
145 }
146 NotAPart::BodyComponent => {
147 "a mass release lets go of a part carried inside the airframe, and \
148 this is a body component"
149 }
150 NotAPart::Outside => {
151 "a mass release lets go of a part carried inside the airframe, and \
152 this part is outside it"
153 }
154 NotAPart::NotOnePart => {
155 "a mass release of a part that isn't exactly one part (one of several \
156 copies in a cluster of tubes, or none)"
157 }
158 },
159 id,
160 )
161 })?;
162 if indices.contains(&index) {
163 return Err(refuse("a mass release of a part released already", id));
164 }
165 indices.push(index);
166 }
167 for &index in &indices {
168 let id = components[index].id.as_str();
169 if indices
170 .iter()
171 .any(|&other| other != index && inside(components, index, other))
172 {
173 return Err(refuse(
174 "a mass release of a part inside another that is released",
175 id,
176 ));
177 }
178 check_carried(rocket, assembly, index).map_err(|refusal| {
179 refuse(
180 match refusal {
181 NotCarried::HoldsMotor => {
182 "a mass release of a part that holds a motor (the rocket's motors \
183 stay with it)"
184 }
185 NotCarried::StageOverride => {
186 "a mass release in a stage whose mass is overridden (the override \
187 doesn't say how much of it is the part's)"
188 }
189 NotCarried::CoveredOverride => {
190 "a mass release inside a component whose overridden mass covers what \
191 it holds (the override doesn't say how much of it is the part's)"
192 }
193 },
194 id,
195 )
196 })?;
197 }
198 let parts: Vec<MassProperties> = indices
199 .iter()
200 .map(|&index| components[index].with_children)
201 .collect();
202 let mut rest_kg = assembly.layout.structure.mass_kg;
203 for (part, release) in parts.iter().zip(releases) {
204 if !(part.mass_kg.is_finite() && part.mass_kg > 0.0) {
206 return Err(refuse(
207 "a mass release of a part with no mass",
208 &release.component,
209 ));
210 }
211 rest_kg -= part.mass_kg;
212 if rest_kg <= 0.0 {
213 return Err(refuse(
214 "a mass release that leaves the airframe, its motors aside, with no mass",
215 &release.component,
216 ));
217 }
218 }
219 Ok(Self { parts })
220 }
221
222 pub(crate) fn part(&self, index: usize) -> &MassProperties {
224 &self.parts[index]
225 }
226}
227
228struct PartSystem<'a> {
234 simulation: &'a Simulation,
235 mass_kg: f64,
236 drag_area_m2: f64,
237 apogee: bool,
239 failure: Option<SimError>,
240}
241
242impl OdeSystem<6> for PartSystem<'_> {
243 type Error = SimError;
244
245 fn derivative(&mut self, _t_s: f64, y: &[f64; 6]) -> Result<[f64; 6], SimError> {
246 self.simulation
247 .point_mass_derivative(y, self.mass_kg, self.drag_area_m2)
248 }
249
250 fn event_count(&self) -> usize {
251 1 + usize::from(self.apogee)
252 }
253
254 fn event_direction(&self, _index: usize) -> Direction {
255 Direction::Falling
256 }
257
258 fn event_value(&mut self, index: usize, t_s: f64, y: &[f64; 6]) -> f64 {
259 match self
260 .simulation
261 .point_sample(t_s, y, self.mass_kg, self.drag_area_m2)
262 {
263 Ok(sample) if index == 0 => sample.height_above_ground_m,
265 Ok(sample) => sample.vertical_speed_m_s,
266 Err(error) => {
267 self.failure.get_or_insert(error);
268 f64::NAN
269 }
270 }
271 }
272}
273
274impl Simulation {
275 pub(crate) fn fly_released(
278 &self,
279 release: usize,
280 t0: f64,
281 (cg_enu_m, velocity_enu_m_s): (DVec3, DVec3),
282 mass_kg: f64,
283 ) -> Result<ReleasedFlight, SimError> {
284 let settings = self.settings();
285 let drag_area_m2 = self.releases()[release].drag_area_m2;
286 let start = [
287 cg_enu_m.x,
288 cg_enu_m.y,
289 cg_enu_m.z,
290 velocity_enu_m_s.x,
291 velocity_enu_m_s.y,
292 velocity_enu_m_s.z,
293 ];
294 let start_sample = self.point_sample(t0, &start, mass_kg, drag_area_m2)?;
295 if start_sample.height_above_ground_m <= 0.0 {
296 return Ok(ReleasedFlight {
300 release,
301 start_sample,
302 termination: Termination::GroundHit,
303 events: vec![BodyEvent {
304 kind: EventKind::GroundHit,
305 sample: start_sample,
306 after: None,
307 }],
308 final_sample: start_sample,
309 stats: Stats::default(),
310 });
311 }
312 let mut integrator =
313 Integrator::new(settings.method, t0, start)?.with_step_limit(settings.step_limit);
314 let cap = settings.max_time_s;
315 let mut events: Vec<BodyEvent> = Vec::new();
316 let termination = loop {
317 if integrator.time_s() >= cap {
318 break Termination::TimeCap;
319 }
320 let apogee = events.is_empty() && start_sample.vertical_speed_m_s > 0.0;
321 let mut system = PartSystem {
322 simulation: self,
323 mass_kg,
324 drag_area_m2,
325 apogee,
326 failure: None,
327 };
328 let outcome = integrator.advance(&mut system, cap);
329 if let Some(error) = system.failure.take() {
330 return Err(error);
331 }
332 let outcome = match outcome {
333 Ok(outcome) => outcome,
334 Err(IntegrationError::StepLimit { .. }) => break Termination::StepLimit,
335 Err(IntegrationError::Derivative { source, .. }) => return Err(source),
336 Err(error) => return Err(SimError::Integration(Box::new(error))),
337 };
338 if let Advance::Events = outcome {
339 let fired = integrator.fired_events().to_vec();
340 let (t, y) = (integrator.time_s(), *integrator.state());
341 let sample = self.point_sample(t, &y, mass_kg, drag_area_m2)?;
342 if apogee && fired.contains(&1) {
343 events.push(BodyEvent {
344 kind: EventKind::Apogee,
345 sample,
346 after: None,
347 });
348 }
349 if fired.contains(&0) {
350 events.push(BodyEvent {
351 kind: EventKind::GroundHit,
352 sample,
353 after: None,
354 });
355 break Termination::GroundHit;
356 }
357 }
358 };
359 let final_sample = self.point_sample(
360 integrator.time_s(),
361 integrator.state(),
362 mass_kg,
363 drag_area_m2,
364 )?;
365 Ok(ReleasedFlight {
366 release,
367 start_sample,
368 termination,
369 events,
370 final_sample,
371 stats: integrator.stats(),
372 })
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use hpr_core::DMat3;
379 use hpr_design::Part;
380
381 use super::*;
382 use crate::MassShift;
383 use crate::flight::{FlightSettings, Simulation};
384 use crate::integrator::{Adaptive, Method};
385 use crate::pieces::Ejection;
386 use crate::rail::Rail;
387 use crate::recorder::Sample;
388 use crate::recovery::{Device, DeviceDrag, Separation, terminal_speed_m_s};
389 use crate::state::State;
390 use crate::testing::{
391 BALLAST_KG, Ends, UniformAir, analytic_environment, design, with_ballast, with_sleeve,
392 };
393
394 const G: f64 = 9.806_65;
395 const RELEASE_S: f64 = 5.0;
397 const PART_DRAG_AREA_M2: f64 = 0.01;
399
400 fn simulation(
401 rocket: &Rocket,
402 air: UniformAir,
403 g: f64,
404 settings: FlightSettings,
405 ) -> Simulation {
406 Simulation::new(
407 rocket,
408 "i175",
409 analytic_environment(air, g),
410 Rail::vertical(3.0),
411 settings,
412 )
413 .unwrap()
414 }
415
416 fn release(trigger: Trigger) -> MassRelease {
417 MassRelease::new(trigger, "ballast", PART_DRAG_AREA_M2)
418 }
419
420 fn tight() -> Method {
421 Method::DormandPrince54(Adaptive {
422 relative_tolerance: 1e-12,
423 absolute_tolerance: 1e-12,
424 ..Adaptive::default()
425 })
426 }
427
428 fn ballast(sim: &Simulation) -> MassProperties {
430 sim.assembly()
431 .layout
432 .find("ballast")
433 .unwrap()
434 .1
435 .with_children
436 }
437
438 fn close(a: f64, b: f64, tolerance: f64, what: &str) {
439 assert!(
440 (a - b).abs() <= tolerance,
441 "{what}: {a} vs {b} ({:e})",
442 a - b
443 );
444 }
445
446 fn close_vec(a: DVec3, b: DVec3, tolerance: f64, what: &str) {
447 assert!(
448 (a - b).length() <= tolerance,
449 "{what}: {a:?} vs {b:?} ({:e})",
450 (a - b).length()
451 );
452 }
453
454 #[test]
455 fn mass_properties_after_a_release_match_the_hand_calculation() {
456 let sim = simulation(
457 &with_ballast(0.01),
458 UniformAir::sea_level(),
459 G,
460 FlightSettings::default(),
461 )
462 .with_releases(vec![release(Trigger::Time { time_s: RELEASE_S })])
463 .unwrap();
464 let mut ends = Ends::default();
465 let result = sim.run(&mut ends).unwrap();
466 let event = result.event(EventKind::MassRelease(0)).unwrap().sample;
467 assert_eq!(event.time_s, RELEASE_S);
468
469 let part = ballast(&sim);
474 let whole = sim.assembly().mass_properties(RELEASE_S);
475 let (m, big_m) = (part.mass_kg, whole.mass_kg);
476 assert_eq!(m, BALLAST_KG);
477 let rest_kg = big_m - m;
478 let rest_cg = (whole.cg_m * big_m - part.cg_m * m) / rest_kg;
479 let l = part.cg_m - rest_cg;
480 let mu = m * rest_kg / big_m;
481 let pair = DMat3::from_diagonal(DVec3::splat(l.length_squared()))
482 - DMat3::from_cols(l * l.x, l * l.y, l * l.z);
483 let rest_inertia = whole.inertia_kg_m2 - part.inertia_kg_m2 - pair * mu;
484
485 assert_eq!(
487 sim.mass_properties(&result, RELEASE_S - 1e-3),
488 sim.assembly().mass_properties(RELEASE_S - 1e-3)
489 );
490 for t_s in [RELEASE_S, RELEASE_S + 1.0] {
491 let got = sim.mass_properties(&result, t_s);
492 let what = format!("at {t_s} s");
493 close(got.mass_kg, rest_kg, 1e-15, &what);
494 close_vec(got.cg_m, rest_cg, 1e-15, &what);
495 let diff = (got.inertia_kg_m2 - rest_inertia)
496 .to_cols_array()
497 .iter()
498 .fold(0.0_f64, |most, v| most.max(v.abs()));
499 assert!(diff <= 1e-15, "{what}: inertia off by {diff:e}");
500 }
501
502 let mut without = with_ballast(0.01);
504 without.stages[0].components[1]
505 .children
506 .retain(|child| child.id != "ballast");
507 let built = lenient(&without)
508 .assembly()
509 .mass_properties(RELEASE_S + 1.0);
510 let got = sim.mass_properties(&result, RELEASE_S + 1.0);
511 close(got.mass_kg, built.mass_kg, 1e-15, "built without it");
512 close_vec(got.cg_m, built.cg_m, 1e-15, "built without it");
513 let diff = (got.inertia_kg_m2 - built.inertia_kg_m2)
514 .to_cols_array()
515 .iter()
516 .fold(0.0_f64, |most, v| most.max(v.abs()));
517 assert!(diff <= 1e-15, "built without it: inertia off by {diff:e}");
518
519 for sample in &ends.0 {
521 let expected = if sample.time_s <= RELEASE_S {
522 sim.assembly().mass_properties(sample.time_s).mass_kg
523 } else {
524 rest_kg
525 };
526 close(sample.mass_kg, expected, 1e-15, "a step's mass");
527 if sample.time_s > RELEASE_S {
528 close_vec(
529 sample.cg_enu_m,
530 sample.state.point_enu_m(rest_cg),
531 1e-12,
532 "a step's center",
533 );
534 }
535 }
536
537 assert_eq!(result.released.len(), 1);
539 let flown = &result.released[0];
540 assert_eq!(flown.release, 0);
541 let start = flown.start_sample;
542 assert_eq!(start.time_s, RELEASE_S);
543 assert_eq!(start.mass_kg, m);
544 assert_eq!(start.recovery_drag_area_m2, PART_DRAG_AREA_M2);
545 let state = event.state;
546 close_vec(
547 start.cg_enu_m,
548 state.point_enu_m(part.cg_m),
549 1e-12,
550 "the part's start",
551 );
552 close_vec(
553 start.cg_velocity_enu_m_s,
554 state.velocity_enu_m_s
555 + state
556 .unit_attitude()
557 .mul_vec3(state.body_rate_rad_s.cross(part.cg_m)),
558 1e-12,
559 "the part's velocity",
560 );
561 assert_eq!(flown.termination, Termination::GroundHit);
562 assert!(flown.final_sample.height_above_ground_m.abs() < 1e-6);
563 assert_eq!(result.termination, Termination::GroundHit);
564 }
565
566 #[test]
567 fn a_release_conserves_mass_and_momentum_in_free_space() {
568 let t0 = 10.0;
573 let release_s = t0 + 0.5;
574 let sim = simulation(
575 &with_ballast(0.01),
576 UniformAir::vacuum(),
577 0.0,
578 FlightSettings {
579 method: tight(),
580 max_time_s: t0 + 2.0,
581 ..FlightSettings::default()
582 },
583 )
584 .with_releases(vec![release(Trigger::Time { time_s: release_s })])
585 .unwrap();
586 let attitude = Rail::vertical(3.0).attitude();
587 let cg_m = sim.assembly().mass_properties(t0).cg_m;
588 let state = State {
589 position_enu_m: DVec3::new(0.0, 0.0, 1000.0) - attitude.mul_vec3(cg_m),
590 velocity_enu_m_s: DVec3::new(3.0, -2.0, 10.0),
591 attitude,
592 body_rate_rad_s: DVec3::new(0.5, 0.2, 3.0),
593 };
594 let mut ends = Ends::default();
595 let result = sim.run_free(t0, state, &mut ends).unwrap();
596 assert_eq!(result.termination, Termination::TimeCap);
597 let before = result.event(EventKind::MassRelease(0)).unwrap().sample;
598 assert_eq!(before.time_s, release_s);
599 let flown = &result.released[0];
600 assert_eq!(flown.termination, Termination::TimeCap);
601 let part = ballast(&sim);
602 let whole = sim.assembly().mass_properties(t0);
603 let rest = sim.mass_properties(&result, release_s);
604
605 close(
607 rest.mass_kg + flown.start_sample.mass_kg,
608 whole.mass_kg,
609 1e-15,
610 "mass",
611 );
612 let spin = |inertia: DMat3, sample: &Sample| {
618 sample
619 .state
620 .unit_attitude()
621 .mul_vec3(inertia * sample.state.body_rate_rad_s)
622 };
623 let (origin, center_velocity) = (before.cg_enu_m, before.cg_velocity_enu_m_s);
624 let momentum0 = center_velocity * whole.mass_kg;
625 let angular0 = spin(whole.inertia_kg_m2, &before);
626 let part_spin = spin(part.inertia_kg_m2, &before);
627 let v_part = flown.start_sample.cg_velocity_enu_m_s;
628 let (mut linear_error, mut angular_error, mut after) = (0.0_f64, 0.0_f64, 0);
629 for sample in ends.0.iter().filter(|sample| sample.time_s > release_s) {
630 after += 1;
631 let x_part = flown.start_sample.cg_enu_m + v_part * (sample.time_s - release_s);
632 let momentum = sample.cg_velocity_enu_m_s * rest.mass_kg + v_part * part.mass_kg;
633 let center = origin + center_velocity * (sample.time_s - release_s);
634 let angular = (sample.cg_enu_m - center)
635 .cross((sample.cg_velocity_enu_m_s - center_velocity) * rest.mass_kg)
636 + spin(rest.inertia_kg_m2, sample)
637 + (x_part - center).cross((v_part - center_velocity) * part.mass_kg)
638 + part_spin;
639 linear_error = linear_error.max((momentum - momentum0).length() / momentum0.length());
640 angular_error = angular_error.max((angular - angular0).length() / angular0.length());
641 }
642 assert!(after > 10, "{after} steps after the release");
643 assert!(linear_error < 1e-12, "momentum: {linear_error:e}");
645 assert!(angular_error < 1e-10, "angular momentum: {angular_error:e}");
646 close_vec(
648 flown.final_sample.cg_velocity_enu_m_s,
649 v_part,
650 1e-12,
651 "the part's final velocity",
652 );
653 let off = |v: DVec3| (v - v_part).length() * part.mass_kg / momentum0.length();
657 let (nose, center) = (
658 off(before.state.velocity_enu_m_s),
659 off(before.cg_velocity_enu_m_s),
660 );
661 assert!(nose > 1e-3 && center > 1e-3, "{nose:e}, {center:e}");
662 assert!(part_spin.length() / angular0.length() < 3e-3);
665
666 let later = sim.run_free(t0 + 1.0, state, &mut ()).unwrap();
668 assert!(later.event(EventKind::MassRelease(0)).is_none());
669 assert!(later.released.is_empty());
670 close(later.final_sample.mass_kg, rest.mass_kg, 1e-15, "later");
671 }
672
673 #[test]
674 fn a_payload_let_go_under_the_drogue_lands_slower_and_falls_at_its_own_speed() {
675 let drogue = Device::new(
681 "drogue",
682 DeviceDrag::DragArea { cd_s_m2: 0.3 },
683 Trigger::Apogee,
684 );
685 let sim = simulation(
686 &with_ballast(0.0),
687 UniformAir::sea_level(),
688 G,
689 FlightSettings {
690 method: tight(),
691 ..FlightSettings::default()
692 },
693 )
694 .with_recovery(vec![drogue])
695 .unwrap()
696 .with_releases(vec![MassRelease::new(
697 Trigger::Altitude {
698 height_above_ground_m: 150.0,
699 },
700 "ballast",
701 0.05,
702 )])
703 .unwrap();
704 let result = sim.run(&mut ()).unwrap();
705 let rho = UniformAir::sea_level().0.density_kg_m3;
706 let event = result.event(EventKind::MassRelease(0)).unwrap().sample;
707 close(event.height_above_ground_m, 150.0, 1e-6, "release height");
708 let whole = sim.assembly().mass_properties(event.time_s);
709 let rest = sim.mass_properties(&result, event.time_s);
710 close(
712 event.cg_velocity_enu_m_s.length(),
713 terminal_speed_m_s(whole.mass_kg, 0.3, rho, G),
714 1e-6,
715 "before",
716 );
717 assert_eq!(result.termination, Termination::GroundHit);
718 close(
719 result.final_sample.cg_velocity_enu_m_s.length(),
720 terminal_speed_m_s(rest.mass_kg, 0.3, rho, G),
721 1e-6,
722 "the rest",
723 );
724 let flown = &result.released[0];
725 assert_eq!(flown.termination, Termination::GroundHit);
726 assert_eq!(flown.events.len(), 1);
728 assert_eq!(flown.events[0].kind, EventKind::GroundHit);
729 close(
730 flown.final_sample.cg_velocity_enu_m_s.length(),
731 terminal_speed_m_s(BALLAST_KG, 0.05, rho, G),
732 1e-6,
733 "the part",
734 );
735 }
736
737 #[test]
738 fn a_release_comes_at_apogee_and_a_part_let_go_climbing_has_its_own() {
739 let sim = |trigger: Trigger| {
740 simulation(
741 &with_ballast(0.0),
742 UniformAir::sea_level(),
743 G,
744 FlightSettings::default(),
745 )
746 .with_releases(vec![release(trigger)])
747 .unwrap()
748 };
749 let result = sim(Trigger::Apogee).run(&mut ()).unwrap();
750 let apogee = result.event(EventKind::Apogee).unwrap().sample.time_s;
751 let released = result.event(EventKind::MassRelease(0)).unwrap().sample;
752 assert_eq!(released.time_s, apogee);
753 let result = sim(Trigger::Burnout {
755 motor: 0,
756 delay_s: 1.0,
757 })
758 .run(&mut ())
759 .unwrap();
760 let flown = &result.released[0];
761 assert!(flown.start_sample.vertical_speed_m_s > 0.0);
762 let kinds: Vec<EventKind> = flown.events.iter().map(|event| event.kind).collect();
763 assert_eq!(kinds, [EventKind::Apogee, EventKind::GroundHit]);
764 assert!(flown.events[0].sample.vertical_speed_m_s.abs() < 1e-6);
765 }
766
767 fn release_refusal(sim: Simulation, releases: Vec<MassRelease>) -> (&'static str, String) {
769 match sim.with_releases(releases) {
770 Err(SimError::MassRelease { what, component }) => (what, component),
771 other => panic!("{other:?}"),
772 }
773 }
774
775 fn lenient(rocket: &Rocket) -> Simulation {
777 simulation(
778 rocket,
779 UniformAir::sea_level(),
780 G,
781 FlightSettings {
782 accept_design_errors: true,
783 ..FlightSettings::default()
784 },
785 )
786 }
787
788 #[test]
789 fn releases_that_cannot_be_made_are_refused() {
790 let time = Trigger::Time { time_s: RELEASE_S };
791 let of = |id: &str| MassRelease::new(time, id, PART_DRAG_AREA_M2);
792 let refused = |releases: Vec<MassRelease>, rocket: &Rocket, starts: &str, id: &str| {
793 let (what, component) = release_refusal(lenient(rocket), releases);
794 assert!(what.starts_with(starts), "{id}: {what}");
795 assert_eq!(component, id);
796 };
797 let ballasted = with_ballast(0.0);
798 refused(
799 vec![of("no-such-part")],
800 &ballasted,
801 "a mass release names a component",
802 "no-such-part",
803 );
804 refused(
805 vec![of("sustainer-airframe")],
806 &ballasted,
807 "a mass release lets go of a part carried inside the airframe, and this is a body",
808 "sustainer-airframe",
809 );
810 refused(
811 vec![of("sustainer-rail-buttons")],
812 &ballasted,
813 "a mass release lets go of a part carried inside the airframe, and this part is \
814 outside",
815 "sustainer-rail-buttons",
816 );
817 refused(
818 vec![of("sustainer-motor-mount")],
819 &ballasted,
820 "a mass release of a part that holds a motor",
821 "sustainer-motor-mount",
822 );
823 refused(
824 vec![of("ballast"), of("ballast")],
825 &ballasted,
826 "a mass release of a part released already",
827 "ballast",
828 );
829 lenient(&with_sleeve())
831 .with_releases(vec![of("sleeve")])
832 .unwrap();
833 refused(
834 vec![of("sleeve"), of("held")],
835 &with_sleeve(),
836 "a mass release of a part inside another that is released",
837 "held",
838 );
839 let mut clustered = with_sleeve();
841 let sleeve = clustered.stages[0].components[1]
842 .children
843 .iter_mut()
844 .find(|child| child.id == "sleeve")
845 .unwrap();
846 let Part::InnerTube(tube) = &mut sleeve.part else {
847 panic!("the motor mount is an inner tube");
848 };
849 tube.cluster_m = vec![[0.004, 0.0], [-0.004, 0.0]];
850 refused(
851 vec![of("held")],
852 &clustered,
853 "a mass release of a part that isn't exactly one",
854 "held",
855 );
856 let mut overridden = with_ballast(0.0);
858 overridden.stages[0].overrides.mass_kg = Some(1.0);
859 refused(
860 vec![of("ballast")],
861 &overridden,
862 "a mass release in a stage whose mass is overridden",
863 "ballast",
864 );
865 for covers_children in [false, true] {
867 let mut overridden = with_ballast(0.0);
868 let airframe = &mut overridden.stages[0].components[1];
869 airframe.overrides.mass_kg = Some(0.5);
870 airframe.overrides_include_children = covers_children;
871 let result = lenient(&overridden).with_releases(vec![of("ballast")]);
872 if covers_children {
873 assert!(
874 matches!(&result, Err(SimError::MassRelease { what, component })
875 if what.starts_with("a mass release inside a component whose overridden")
876 && component == "ballast"),
877 "{result:?}"
878 );
879 } else {
880 result.unwrap();
881 }
882 }
883 for area in [0.0, -0.1, f64::NAN, f64::INFINITY] {
885 let result =
886 lenient(&ballasted).with_releases(vec![MassRelease::new(time, "ballast", area)]);
887 assert!(
888 matches!(&result, Err(SimError::Domain { what, .. })
889 if what.starts_with("drag area of a released part")),
890 "{area}: {result:?}"
891 );
892 }
893 }
894
895 #[test]
896 fn triggers_a_release_cannot_have_are_refused_in_its_own_words() {
897 let domain = |result: Result<Simulation, SimError>| match result {
898 Err(SimError::Domain { what, .. }) => what,
899 other => panic!("{other:?}"),
900 };
901 let sim = || lenient(&with_ballast(0.0));
902 for (trigger, starts) in [
903 (
904 Trigger::Altitude {
905 height_above_ground_m: -1.0,
906 },
907 "height above the launch site at which a part is released",
908 ),
909 (
910 Trigger::Time { time_s: -1.0 },
911 "time of a mass release after launch",
912 ),
913 (
914 Trigger::MotorDelay { motor: 3 },
915 "index of the motor whose delay releases a part",
916 ),
917 ] {
918 let what = domain(sim().with_releases(vec![release(trigger)]));
919 assert!(what.starts_with(starts), "{what}");
920 }
921 let mut plugged = with_ballast(0.0);
922 plugged.configurations[0].motors[0].delay = None;
923 let what = domain(
924 lenient(&plugged).with_releases(vec![release(Trigger::MotorDelay { motor: 0 })]),
925 );
926 assert!(
927 what.starts_with("the motor whose delay releases a part has no ejection"),
928 "{what}"
929 );
930 let mut failed = with_ballast(0.0);
931 failed.configurations[0].motors[0].failed_tubes = vec![0];
932 let what = domain(
933 lenient(&failed).with_releases(vec![release(Trigger::Burnout {
934 motor: 0,
935 delay_s: 1.0,
936 })]),
937 );
938 assert!(
939 what.starts_with("index of the motor a mass release is timed from"),
940 "{what}"
941 );
942 for time_s in [0.0, 0.1] {
944 let error = sim()
945 .with_releases(vec![release(Trigger::Time { time_s })])
946 .unwrap()
947 .run(&mut ())
948 .unwrap_err();
949 assert!(
950 matches!(&error, SimError::Domain { what, value }
951 if what.starts_with("time of a mass release, s (it must come once")
952 && *value == time_s),
953 "{error:?}"
954 );
955 }
956 }
957
958 #[test]
959 fn a_release_is_refused_with_partings_or_shifts_in_either_order() {
960 let time = Trigger::Time { time_s: RELEASE_S };
961 let unsupported = |result: Result<Simulation, SimError>| {
962 assert!(
963 matches!(&result, Err(SimError::Unsupported { what })
964 if what.starts_with("a mass release in a flight with")),
965 "{result:?}"
966 );
967 };
968 let sim = || lenient(&with_ballast(0.0));
969 let shift = MassShift::new(time, "ballast", 0.1, 1.0);
970 let ejection = Ejection::aft_of(Trigger::Apogee, "nose");
971 unsupported(
972 sim()
973 .with_releases(vec![release(time)])
974 .unwrap()
975 .with_shifts(vec![shift.clone()]),
976 );
977 unsupported(
978 sim()
979 .with_shifts(vec![shift])
980 .unwrap()
981 .with_releases(vec![release(time)]),
982 );
983 unsupported(
984 sim()
985 .with_releases(vec![release(time)])
986 .unwrap()
987 .with_ejections(vec![ejection.clone()]),
988 );
989 unsupported(
990 sim()
991 .with_ejections(vec![ejection])
992 .unwrap()
993 .with_releases(vec![release(time)]),
994 );
995 unsupported(
996 sim()
997 .with_releases(vec![release(time)])
998 .unwrap()
999 .with_separation(Separation::new(Trigger::Apogee, 0)),
1000 );
1001 let two_stage = Simulation::new(
1002 &design("synthetic-two-stage-75mm-54mm"),
1003 "j760-i175",
1004 analytic_environment(UniformAir::sea_level(), G),
1005 Rail::vertical(3.0),
1006 FlightSettings::default(),
1007 )
1008 .unwrap()
1009 .with_separation(Separation::new(Trigger::Apogee, 0))
1010 .unwrap();
1011 unsupported(two_stage.with_releases(vec![release(time)]));
1012 }
1013
1014 fn apogees(result: &crate::FlightResult) -> usize {
1016 result
1017 .events
1018 .iter()
1019 .filter(|event| event.kind == EventKind::Apogee)
1020 .count()
1021 }
1022
1023 #[test]
1024 fn a_release_at_apogee_on_a_tilted_rail_leaves_one_apogee() {
1025 let rail = Rail {
1029 elevation_rad: 85f64.to_radians(),
1030 ..Rail::vertical(3.0)
1031 };
1032 for drogue in [false, true] {
1033 let mut sim = Simulation::new(
1034 &with_ballast(0.0),
1035 "i175",
1036 crate::testing::analytic_wind_environment(
1037 UniformAir::sea_level(),
1038 G,
1039 hpr_atmos::ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap(),
1040 ),
1041 rail,
1042 FlightSettings::default(),
1043 )
1044 .unwrap();
1045 if drogue {
1046 sim = sim
1047 .with_recovery(vec![Device::new(
1048 "drogue",
1049 DeviceDrag::DragArea { cd_s_m2: 0.3 },
1050 Trigger::Apogee,
1051 )])
1052 .unwrap();
1053 }
1054 let result = sim
1055 .with_releases(vec![release(Trigger::Apogee)])
1056 .unwrap()
1057 .run(&mut ())
1058 .unwrap();
1059 assert_eq!(apogees(&result), 1, "drogue {drogue}: {:?}", result.events);
1060 let apogee = result.event(EventKind::Apogee).unwrap().sample.time_s;
1061 let left = result
1062 .event(EventKind::MassRelease(0))
1063 .unwrap()
1064 .sample
1065 .time_s;
1066 assert_eq!(left, apogee);
1067 assert_eq!(result.termination, Termination::GroundHit);
1068 }
1069 }
1070
1071 #[test]
1072 fn a_part_let_go_just_before_apogee_can_make_the_apogee_there() {
1073 let t0 = 10.0;
1080 let tilted = Rail {
1081 elevation_rad: 45f64.to_radians(),
1082 ..Rail::vertical(3.0)
1083 };
1084 let timed = release(Trigger::Time { time_s: t0 });
1085 let waiting = MassRelease::new(Trigger::Apogee, "sleeve", PART_DRAG_AREA_M2);
1086 for (rocket, releases, timed_index, bound) in [
1089 (with_ballast(0.0), vec![timed.clone()], 0, -0.05),
1090 (
1091 with_sleeve(),
1092 vec![waiting.clone(), timed.clone()],
1093 1,
1094 -0.04,
1095 ),
1096 (with_sleeve(), vec![timed, waiting], 0, -0.04),
1097 ] {
1098 let drogue = Device::new(
1099 "drogue",
1100 DeviceDrag::DragArea { cd_s_m2: 0.3 },
1101 Trigger::Apogee,
1102 );
1103 let count = releases.len();
1104 let sim = simulation(
1105 &rocket,
1106 UniformAir::vacuum(),
1107 G,
1108 FlightSettings {
1109 max_time_s: t0 + 2.0,
1110 accept_design_errors: true,
1111 ..FlightSettings::default()
1112 },
1113 )
1114 .with_recovery(vec![drogue])
1115 .unwrap()
1116 .with_releases(releases)
1117 .unwrap();
1118 let attitude = tilted.attitude();
1119 let whole = sim.assembly().mass_properties(t0);
1120 let rest = whole.without_part(&ballast(&sim));
1121 let step = rest.cg_m - whole.cg_m;
1122 let up = |omega: DVec3| attitude.mul_vec3(omega.cross(step)).z;
1123 let omega = if up(DVec3::X) < 0.0 {
1124 DVec3::X
1125 } else {
1126 -DVec3::X
1127 };
1128 let jump = up(omega);
1129 assert!(jump < bound, "{jump}");
1130 let cg_velocity = DVec3::new(2.0, 0.0, -0.5 * jump);
1131 let state = State {
1132 position_enu_m: DVec3::new(0.0, 0.0, 1000.0) - attitude.mul_vec3(whole.cg_m),
1133 velocity_enu_m_s: cg_velocity - attitude.mul_vec3(omega.cross(whole.cg_m)),
1134 attitude,
1135 body_rate_rad_s: omega,
1136 };
1137 let result = sim.run_free(t0, state, &mut ()).unwrap();
1138 let left = result
1139 .event(EventKind::MassRelease(timed_index))
1140 .unwrap()
1141 .sample;
1142 assert!(left.vertical_speed_m_s > 0.0, "{left:?}");
1143 assert_eq!(apogees(&result), 1, "{:?}", result.events);
1144 let apogee = result.event(EventKind::Apogee).unwrap().sample;
1145 assert_eq!(apogee.time_s, t0);
1146 assert!(apogee.vertical_speed_m_s < 0.0, "{apogee:?}");
1147 close(
1148 apogee.mass_kg,
1149 rest.mass_kg,
1150 1e-15,
1151 "the rest's mass at the apogee",
1152 );
1153 let fired = result.event(EventKind::Trigger(0)).unwrap().sample.time_s;
1154 assert_eq!(fired, t0);
1155 assert_eq!(result.released.len(), count);
1157 for flown in &result.released {
1158 assert_eq!(flown.start_sample.time_s, t0);
1159 }
1160 }
1161 }
1162
1163 #[test]
1164 fn parts_waiting_for_the_apogee_all_leave_at_it() {
1165 let rail = Rail {
1169 elevation_rad: 85f64.to_radians(),
1170 ..Rail::vertical(3.0)
1171 };
1172 let at_apogee = |id: &str| MassRelease::new(Trigger::Apogee, id, PART_DRAG_AREA_M2);
1173 for ids in [["ballast", "sleeve"], ["sleeve", "ballast"]] {
1174 let result = Simulation::new(
1175 &with_sleeve(),
1176 "i175",
1177 crate::testing::analytic_wind_environment(
1178 UniformAir::sea_level(),
1179 G,
1180 hpr_atmos::ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap(),
1181 ),
1182 rail,
1183 FlightSettings {
1184 accept_design_errors: true,
1185 ..FlightSettings::default()
1186 },
1187 )
1188 .unwrap()
1189 .with_releases(ids.iter().map(|id| at_apogee(id)).collect())
1190 .unwrap()
1191 .run(&mut ())
1192 .unwrap();
1193 assert_eq!(apogees(&result), 1, "{ids:?}");
1194 let apogee = result.event(EventKind::Apogee).unwrap().sample.time_s;
1195 assert_eq!(result.released.len(), 2, "{ids:?}");
1196 for flown in &result.released {
1197 assert_eq!(flown.start_sample.time_s, apogee, "{ids:?}");
1198 }
1199 }
1200 }
1201
1202 #[test]
1203 fn a_main_set_above_the_apogee_opens_there_whatever_leaves() {
1204 let rail = Rail {
1209 elevation_rad: 85f64.to_radians(),
1210 ..Rail::vertical(3.0)
1211 };
1212 let high = Trigger::Altitude {
1213 height_above_ground_m: 1600.0,
1214 };
1215 let part = |trigger: Trigger, id: &str| MassRelease::new(trigger, id, PART_DRAG_AREA_M2);
1216 let cases = [
1217 vec![],
1218 vec![part(Trigger::Apogee, "ballast")],
1219 vec![part(Trigger::Apogee, "sleeve")],
1220 vec![part(high, "sleeve")],
1221 vec![part(Trigger::Apogee, "ballast"), part(high, "sleeve")],
1222 vec![part(high, "sleeve"), part(Trigger::Apogee, "ballast")],
1223 ];
1224 for releases in cases {
1225 let count = releases.len();
1226 let result = Simulation::new(
1227 &with_sleeve(),
1228 "i175",
1229 crate::testing::analytic_wind_environment(
1230 UniformAir::sea_level(),
1231 G,
1232 hpr_atmos::ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap(),
1233 ),
1234 rail,
1235 FlightSettings {
1236 accept_design_errors: true,
1237 ..FlightSettings::default()
1238 },
1239 )
1240 .unwrap()
1241 .with_recovery(vec![Device::new(
1242 "main",
1243 DeviceDrag::DragArea { cd_s_m2: 0.5 },
1244 high,
1245 )])
1246 .unwrap()
1247 .with_releases(releases)
1248 .unwrap()
1249 .run(&mut ())
1250 .unwrap();
1251 let apogee = result.event(EventKind::Apogee).unwrap().sample;
1252 assert!(apogee.height_above_ground_m < 1600.0, "{apogee:?}");
1253 let opened = result.event(EventKind::Trigger(0)).map(|e| e.sample.time_s);
1254 assert_eq!(opened, Some(apogee.time_s), "{count} {:?}", result.events);
1255 assert_eq!(result.released.len(), count, "{:?}", result.events);
1256 for flown in &result.released {
1257 assert_eq!(flown.start_sample.time_s, apogee.time_s);
1258 }
1259 assert!(
1261 result.final_sample.cg_velocity_enu_m_s.length() < 10.0,
1262 "{:?}",
1263 result.final_sample
1264 );
1265 }
1266 }
1267
1268 #[test]
1269 fn a_release_that_puts_the_rest_on_the_ground_lands_it() {
1270 let sim = simulation(
1275 &with_ballast(0.0),
1276 UniformAir::sea_level(),
1277 G,
1278 FlightSettings::default(),
1279 )
1280 .with_recovery(vec![Device::new(
1281 "drogue",
1282 DeviceDrag::DragArea { cd_s_m2: 0.3 },
1283 Trigger::Apogee,
1284 )])
1285 .unwrap()
1286 .with_releases(vec![release(Trigger::Altitude {
1287 height_above_ground_m: 0.05,
1288 })])
1289 .unwrap();
1290 let result = sim.run(&mut ()).unwrap();
1291 assert_eq!(result.termination, Termination::GroundHit);
1292 let left = result.event(EventKind::MassRelease(0)).unwrap().sample;
1293 close(left.height_above_ground_m, 0.05, 1e-6, "the release height");
1294 let landed = result.event(EventKind::GroundHit).unwrap().sample;
1295 assert_eq!(landed.time_s, left.time_s);
1296 let whole = sim.assembly().mass_properties(left.time_s);
1298 let rest = whole.without_part(&ballast(&sim));
1299 let step_m = left
1300 .state
1301 .unit_attitude()
1302 .mul_vec3(rest.cg_m - whole.cg_m)
1303 .z;
1304 assert!(step_m < -0.09, "{step_m}");
1305 close(
1308 landed.height_above_ground_m,
1309 left.height_above_ground_m + step_m,
1310 1e-8,
1311 "the rest's height",
1312 );
1313 close(landed.mass_kg, rest.mass_kg, 1e-15, "the rest's mass");
1314 assert_eq!(result.final_sample, landed);
1315 assert_eq!(result.released[0].termination, Termination::GroundHit);
1316
1317 let t0 = 10.0;
1319 let sim = simulation(
1320 &with_ballast(0.0),
1321 UniformAir::vacuum(),
1322 G,
1323 FlightSettings::default(),
1324 )
1325 .with_releases(vec![release(Trigger::Time { time_s: t0 })])
1326 .unwrap();
1327 let attitude = Rail::vertical(3.0).attitude();
1328 let state = State {
1329 position_enu_m: DVec3::new(0.0, 0.0, 0.05) - attitude.mul_vec3(whole.cg_m),
1330 velocity_enu_m_s: DVec3::new(0.0, 0.0, 1.0),
1331 attitude,
1332 body_rate_rad_s: DVec3::ZERO,
1333 };
1334 let refused = sim.run_free(t0, state, &mut ());
1335 assert!(
1336 matches!(&refused, Err(SimError::Domain { what, value })
1337 if what.contains("while climbing") && *value < 0.0),
1338 "{refused:?}"
1339 );
1340 }
1341
1342 #[test]
1343 fn the_optimum_delay_holds_a_release_on_the_motor_s_charge() {
1344 let delays = |delay: Option<f64>| {
1347 let mut rocket = with_ballast(0.0);
1348 let mut releases = Vec::new();
1349 if let Some(delay_s) = delay {
1350 rocket.configurations[0].motors[0].delay = Some(hpr_motor::Delay::Seconds(delay_s));
1351 releases.push(release(Trigger::MotorDelay { motor: 0 }));
1352 }
1353 let sim = lenient(&rocket).with_releases(releases).unwrap();
1354 crate::metrics::optimum_delays(&sim).unwrap().unwrap()[0].delay_s
1355 };
1356 let alone = delays(None);
1357 assert_eq!(delays(Some(2.0)), alone);
1358 assert_eq!(delays(Some(6.0)), alone);
1359 let mut rocket = with_ballast(0.0);
1361 rocket.configurations[0].motors[0].delay = Some(hpr_motor::Delay::Seconds(2.0));
1362 let sim = lenient(&rocket)
1363 .with_shifts(vec![MassShift::new(
1364 Trigger::MotorDelay { motor: 0 },
1365 "ballast",
1366 0.3,
1367 1.0,
1368 )])
1369 .unwrap();
1370 assert_eq!(
1371 crate::metrics::optimum_delays(&sim).unwrap().unwrap()[0].delay_s,
1372 alone
1373 );
1374 }
1375
1376 #[test]
1377 fn two_releases_leave_the_design_without_both_parts() {
1378 let rocket = with_sleeve();
1381 let sim = lenient(&rocket)
1382 .with_releases(vec![
1383 MassRelease::new(Trigger::Time { time_s: 6.0 }, "ballast", 0.01),
1384 MassRelease::new(Trigger::Time { time_s: 4.0 }, "sleeve", 0.01),
1385 ])
1386 .unwrap();
1387 let result = sim.run(&mut ()).unwrap();
1388 let order: Vec<usize> = result.released.iter().map(|flown| flown.release).collect();
1389 assert_eq!(order, [1, 0]);
1390 let mut without = rocket.clone();
1391 without.stages[0].components[1]
1392 .children
1393 .retain(|child| child.id != "ballast" && child.id != "sleeve");
1394 let expected = lenient(&without).assembly().mass_properties(7.0);
1395 let got = sim.mass_properties(&result, 7.0);
1396 close(got.mass_kg, expected.mass_kg, 1e-15, "mass");
1397 close_vec(got.cg_m, expected.cg_m, 1e-15, "center");
1398 let diff = (got.inertia_kg_m2 - expected.inertia_kg_m2)
1399 .to_cols_array()
1400 .iter()
1401 .fold(0.0_f64, |most, v| most.max(v.abs()));
1402 assert!(diff <= 1e-15, "inertia off by {diff:e}");
1403 let between = sim.mass_properties(&result, 5.0).mass_kg;
1405 close(
1406 between,
1407 sim.assembly().mass_properties(5.0).mass_kg
1408 - sim
1409 .assembly()
1410 .layout
1411 .find("sleeve")
1412 .unwrap()
1413 .1
1414 .with_children
1415 .mass_kg,
1416 1e-15,
1417 "between",
1418 );
1419 }
1420
1421 #[test]
1422 fn a_part_let_go_at_the_ground_has_landed() {
1423 let ballistic = simulation(
1426 &with_ballast(0.0),
1427 UniformAir::sea_level(),
1428 G,
1429 FlightSettings::default(),
1430 );
1431 let landed_s = ballistic.run(&mut ()).unwrap().final_sample.time_s;
1432 let result = simulation(
1433 &with_ballast(0.0),
1434 UniformAir::sea_level(),
1435 G,
1436 FlightSettings::default(),
1437 )
1438 .with_releases(vec![release(Trigger::Time {
1439 time_s: landed_s - 0.002,
1440 })])
1441 .unwrap()
1442 .run(&mut ())
1443 .unwrap();
1444 let flown = &result.released[0];
1445 assert!(flown.start_sample.height_above_ground_m <= 0.0, "{flown:?}");
1446 assert_eq!(flown.termination, Termination::GroundHit);
1447 assert_eq!(flown.final_sample, flown.start_sample);
1448 assert!(flown.event(EventKind::GroundHit).is_some());
1449 assert_eq!(result.termination, Termination::GroundHit);
1450 }
1451
1452 #[test]
1453 fn a_release_and_its_flight_read_back_as_written() {
1454 let release = MassRelease::new(Trigger::Apogee, "ballast", 0.25);
1455 let text = serde_json::to_string(&release).unwrap();
1456 assert_eq!(serde_json::from_str::<MassRelease>(&text).unwrap(), release);
1457 let sim = simulation(
1458 &with_ballast(0.0),
1459 UniformAir::sea_level(),
1460 G,
1461 FlightSettings::default(),
1462 )
1463 .with_releases(vec![release])
1464 .unwrap();
1465 let result = sim.run(&mut ()).unwrap();
1466 let text = serde_json::to_string(&result).unwrap();
1467 assert!(text.contains("\"released\""));
1468 let back: crate::FlightResult = serde_json::from_str(&text).unwrap();
1469 assert_eq!(back, result);
1470 }
1471
1472 #[test]
1473 fn parts_with_no_mass_are_refused() {
1474 let mut empty = with_ballast(0.0);
1475 let airframe = &mut empty.stages[0].components[1];
1476 let ballast = airframe
1477 .children
1478 .iter_mut()
1479 .find(|child| child.id == "ballast")
1480 .unwrap();
1481 let Part::MassComponent(mass) = &mut ballast.part else {
1482 panic!("the ballast is a mass component");
1483 };
1484 mass.mass_kg = 0.0;
1485 let (what, id) = release_refusal(lenient(&empty), vec![release(Trigger::Apogee)]);
1486 assert!(
1487 what.starts_with("a mass release of a part with no mass"),
1488 "{what}"
1489 );
1490 assert_eq!(id, "ballast");
1491
1492 fn weightless(component: &mut hpr_design::Component) {
1495 if component.id != "ballast" {
1496 component.overrides.mass_kg = Some(0.0);
1497 }
1498 component.children.iter_mut().for_each(weightless);
1499 }
1500 let mut hollow = with_ballast(0.0);
1501 hollow.stages[0].components.iter_mut().for_each(weightless);
1502 let (what, id) = release_refusal(lenient(&hollow), vec![release(Trigger::Apogee)]);
1503 assert!(
1504 what.starts_with(
1505 "a mass release that leaves the airframe, its motors aside, with no mass"
1506 ),
1507 "{what}"
1508 );
1509 assert_eq!(id, "ballast");
1510 }
1511}