1use crate::orbits::{exhaust_velocity, mass_ratio};
7use crate::units::KmPerSec;
8
9#[derive(Debug, Clone)]
11pub struct MassEvent {
12 pub time_h: f64,
14 pub kind: MassEventKind,
16 pub episode: u8,
18 pub label: String,
20}
21
22#[derive(Debug, Clone)]
24pub enum MassEventKind {
25 FuelBurn {
28 delta_v_km_s: f64,
29 isp_s: f64,
30 burn_duration_h: f64,
31 },
32 ContainerJettison { mass_kg: f64 },
34 DamageEvent { mass_kg: f64 },
36 Resupply { mass_kg: f64 },
38}
39
40#[derive(Debug, Clone, Copy)]
42pub struct MassSnapshot {
43 pub time_h: f64,
45 pub total_mass_kg: f64,
47 pub dry_mass_kg: f64,
49 pub propellant_kg: f64,
51 pub episode: u8,
53}
54
55#[derive(Debug, Clone)]
57pub struct MassTimeline {
58 pub name: String,
60 pub initial_mass_kg: f64,
62 pub initial_dry_mass_kg: f64,
64 pub snapshots: Vec<MassSnapshot>,
66}
67
68pub fn propellant_consumed(pre_burn_mass_kg: f64, delta_v_km_s: f64, isp_s: f64) -> f64 {
76 assert!(pre_burn_mass_kg > 0.0, "pre-burn mass must be positive");
77 assert!(delta_v_km_s >= 0.0, "delta-v must be non-negative");
78 assert!(isp_s > 0.0, "Isp must be positive");
79
80 let ve = exhaust_velocity(isp_s);
81 let mr = mass_ratio(KmPerSec(delta_v_km_s), ve);
82
83 if mr.is_infinite() {
84 pre_burn_mass_kg } else {
86 pre_burn_mass_kg * (1.0 - 1.0 / mr)
87 }
88}
89
90pub fn post_burn_mass(pre_burn_mass_kg: f64, delta_v_km_s: f64, isp_s: f64) -> f64 {
92 pre_burn_mass_kg - propellant_consumed(pre_burn_mass_kg, delta_v_km_s, isp_s)
93}
94
95pub fn compute_timeline(
105 name: &str,
106 initial_total_kg: f64,
107 initial_dry_kg: f64,
108 events: &[MassEvent],
109) -> MassTimeline {
110 assert!(
111 initial_total_kg >= initial_dry_kg,
112 "total mass must be >= dry mass"
113 );
114
115 let initial_propellant = initial_total_kg - initial_dry_kg;
116 let mut snapshots = Vec::with_capacity(events.len() * 2 + 1);
117
118 let first_episode = events.first().map_or(1, |e| e.episode);
120 snapshots.push(MassSnapshot {
121 time_h: 0.0,
122 total_mass_kg: initial_total_kg,
123 dry_mass_kg: initial_dry_kg,
124 propellant_kg: initial_propellant,
125 episode: first_episode,
126 });
127
128 let mut current_dry = initial_dry_kg;
129 let mut current_propellant = initial_propellant;
130
131 for event in events {
132 let current_total = current_dry + current_propellant;
133
134 if let Some(last) = snapshots.last() {
136 if (event.time_h - last.time_h).abs() > 1e-6 {
137 snapshots.push(MassSnapshot {
138 time_h: event.time_h,
139 total_mass_kg: current_total,
140 dry_mass_kg: current_dry,
141 propellant_kg: current_propellant,
142 episode: event.episode,
143 });
144 }
145 }
146
147 match &event.kind {
148 MassEventKind::FuelBurn {
149 delta_v_km_s,
150 isp_s,
151 burn_duration_h,
152 } => {
153 let consumed = propellant_consumed(current_total, *delta_v_km_s, *isp_s);
154 let consumed = consumed.min(current_propellant);
156 current_propellant -= consumed;
157
158 let post_time = event.time_h + burn_duration_h;
160 snapshots.push(MassSnapshot {
161 time_h: post_time,
162 total_mass_kg: current_dry + current_propellant,
163 dry_mass_kg: current_dry,
164 propellant_kg: current_propellant,
165 episode: event.episode,
166 });
167 }
168 MassEventKind::ContainerJettison { mass_kg } => {
169 current_dry -= mass_kg;
170 snapshots.push(MassSnapshot {
171 time_h: event.time_h,
172 total_mass_kg: current_dry + current_propellant,
173 dry_mass_kg: current_dry,
174 propellant_kg: current_propellant,
175 episode: event.episode,
176 });
177 }
178 MassEventKind::DamageEvent { mass_kg } => {
179 current_dry -= mass_kg;
180 snapshots.push(MassSnapshot {
181 time_h: event.time_h,
182 total_mass_kg: current_dry + current_propellant,
183 dry_mass_kg: current_dry,
184 propellant_kg: current_propellant,
185 episode: event.episode,
186 });
187 }
188 MassEventKind::Resupply { mass_kg } => {
189 current_propellant += mass_kg;
190 snapshots.push(MassSnapshot {
191 time_h: event.time_h,
192 total_mass_kg: current_dry + current_propellant,
193 dry_mass_kg: current_dry,
194 propellant_kg: current_propellant,
195 episode: event.episode,
196 });
197 }
198 }
199 }
200
201 MassTimeline {
202 name: name.to_string(),
203 initial_mass_kg: initial_total_kg,
204 initial_dry_mass_kg: initial_dry_kg,
205 snapshots,
206 }
207}
208
209pub fn final_mass(timeline: &MassTimeline) -> Option<&MassSnapshot> {
211 timeline.snapshots.last()
212}
213
214pub fn total_propellant_consumed(timeline: &MassTimeline) -> f64 {
216 let initial_prop = timeline.initial_mass_kg - timeline.initial_dry_mass_kg;
217 match timeline.snapshots.last() {
218 Some(last) => initial_prop - last.propellant_kg,
219 None => 0.0,
220 }
221}
222
223pub fn propellant_margin(timeline: &MassTimeline) -> f64 {
225 let initial_prop = timeline.initial_mass_kg - timeline.initial_dry_mass_kg;
226 if initial_prop <= 0.0 {
227 return 0.0;
228 }
229 match timeline.snapshots.last() {
230 Some(last) => last.propellant_kg / initial_prop,
231 None => 1.0,
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 const ISP_HIGH: f64 = 1_000_000.0; #[test]
242 fn propellant_consumed_zero_dv() {
243 let consumed = propellant_consumed(300_000.0, 0.0, ISP_HIGH);
244 assert!(consumed.abs() < 1e-6, "zero ΔV → zero propellant");
245 }
246
247 #[test]
248 fn propellant_consumed_moderate_dv() {
249 let consumed = propellant_consumed(300_000.0, 8497.0, ISP_HIGH);
254 assert!(
255 consumed > 170_000.0 && consumed < 180_000.0,
256 "EP01 propellant consumed = {consumed} kg, expected ~173,720"
257 );
258 }
259
260 #[test]
261 fn post_burn_mass_consistency() {
262 let pre = 300_000.0;
263 let dv = 5000.0;
264 let post = post_burn_mass(pre, dv, ISP_HIGH);
265 let consumed = propellant_consumed(pre, dv, ISP_HIGH);
266 assert!(
267 (post - (pre - consumed)).abs() < 1e-6,
268 "post_burn = pre - consumed"
269 );
270 assert!(post > 0.0 && post < pre);
271 }
272
273 #[test]
274 fn empty_timeline() {
275 let tl = compute_timeline("empty", 300_000.0, 100_000.0, &[]);
276 assert_eq!(tl.snapshots.len(), 1);
277 assert_eq!(tl.snapshots[0].total_mass_kg, 300_000.0);
278 assert_eq!(tl.snapshots[0].propellant_kg, 200_000.0);
279 }
280
281 #[test]
282 fn single_burn_timeline() {
283 let events = vec![MassEvent {
284 time_h: 0.0,
285 kind: MassEventKind::FuelBurn {
286 delta_v_km_s: 1000.0,
287 isp_s: ISP_HIGH,
288 burn_duration_h: 12.0,
289 },
290 episode: 1,
291 label: "test burn".into(),
292 }];
293
294 let tl = compute_timeline("test", 300_000.0, 100_000.0, &events);
295 assert!(tl.snapshots.len() >= 2);
297
298 let final_snap = final_mass(&tl).unwrap();
299 assert!(final_snap.total_mass_kg < 300_000.0);
300 assert!(final_snap.total_mass_kg > 100_000.0); assert_eq!(final_snap.dry_mass_kg, 100_000.0); assert!(final_snap.propellant_kg < 200_000.0); }
304
305 #[test]
306 fn container_jettison_reduces_dry_mass() {
307 let events = vec![MassEvent {
308 time_h: 10.0,
309 kind: MassEventKind::ContainerJettison { mass_kg: 42_300.0 },
310 episode: 1,
311 label: "cargo delivery".into(),
312 }];
313
314 let tl = compute_timeline("jettison", 300_000.0, 142_300.0, &events);
315 let final_snap = final_mass(&tl).unwrap();
316 assert!(
317 (final_snap.dry_mass_kg - 100_000.0).abs() < 1e-6,
318 "dry mass reduced by container: {}",
319 final_snap.dry_mass_kg
320 );
321 assert!(
322 (final_snap.propellant_kg - 157_700.0).abs() < 1e-6,
323 "propellant unchanged"
324 );
325 }
326
327 #[test]
328 fn multi_event_timeline() {
329 let events = vec![
330 MassEvent {
331 time_h: 0.0,
332 kind: MassEventKind::FuelBurn {
333 delta_v_km_s: 4000.0,
334 isp_s: ISP_HIGH,
335 burn_duration_h: 36.0,
336 },
337 episode: 1,
338 label: "accel phase".into(),
339 },
340 MassEvent {
341 time_h: 36.0,
342 kind: MassEventKind::FuelBurn {
343 delta_v_km_s: 4000.0,
344 isp_s: ISP_HIGH,
345 burn_duration_h: 36.0,
346 },
347 episode: 1,
348 label: "decel phase".into(),
349 },
350 MassEvent {
351 time_h: 72.0,
352 kind: MassEventKind::ContainerJettison { mass_kg: 42_300.0 },
353 episode: 1,
354 label: "cargo delivery".into(),
355 },
356 ];
357
358 let tl = compute_timeline("multi", 300_000.0, 142_300.0, &events);
359 let final_snap = final_mass(&tl).unwrap();
360
361 assert!(
363 (final_snap.dry_mass_kg - 100_000.0).abs() < 1e-6,
364 "final dry = {}",
365 final_snap.dry_mass_kg
366 );
367
368 let consumed = total_propellant_consumed(&tl);
370 assert!(consumed > 0.0, "propellant consumed = {consumed}");
371
372 assert!(
374 (final_snap.total_mass_kg - final_snap.dry_mass_kg - final_snap.propellant_kg).abs()
375 < 1e-6
376 );
377 }
378
379 #[test]
380 fn propellant_margin_full_and_empty() {
381 let tl = compute_timeline("full", 300_000.0, 100_000.0, &[]);
383 assert!((propellant_margin(&tl) - 1.0).abs() < 1e-10);
384
385 let events = vec![MassEvent {
387 time_h: 0.0,
388 kind: MassEventKind::FuelBurn {
389 delta_v_km_s: 50000.0, isp_s: ISP_HIGH,
391 burn_duration_h: 100.0,
392 },
393 episode: 5,
394 label: "massive burn".into(),
395 }];
396 let tl2 = compute_timeline("empty", 300_000.0, 100_000.0, &events);
397 let margin = propellant_margin(&tl2);
398 assert!(margin < 0.1, "margin should be near zero: {margin}");
399 }
400
401 #[test]
404 fn kestrel_ep01_brachistochrone_72h() {
405 let dry_mass = 80_000.0 + 42_300.0; let total = 299_000.0;
414
415 let events = vec![
417 MassEvent {
418 time_h: 0.0,
419 kind: MassEventKind::FuelBurn {
420 delta_v_km_s: 4248.5, isp_s: ISP_HIGH,
422 burn_duration_h: 36.0,
423 },
424 episode: 1,
425 label: "加速フェーズ".into(),
426 },
427 MassEvent {
428 time_h: 36.0,
429 kind: MassEventKind::FuelBurn {
430 delta_v_km_s: 4248.5,
431 isp_s: ISP_HIGH,
432 burn_duration_h: 36.0,
433 },
434 episode: 1,
435 label: "減速フェーズ".into(),
436 },
437 ];
438
439 let tl = compute_timeline("EP01 299t", total, dry_mass, &events);
440 let final_snap = final_mass(&tl).unwrap();
441
442 assert!(
444 final_snap.propellant_kg > 0.0,
445 "propellant remaining = {} kg",
446 final_snap.propellant_kg
447 );
448 assert!(
450 (final_snap.dry_mass_kg - dry_mass).abs() < 1e-6,
451 "dry mass should be unchanged"
452 );
453
454 let consumed = total_propellant_consumed(&tl);
457 let initial_prop = total - dry_mass;
458 assert!(
459 consumed / initial_prop > 0.5 && consumed / initial_prop < 1.0,
460 "consumed fraction = {:.3}",
461 consumed / initial_prop
462 );
463 }
464
465 #[test]
466 fn kestrel_ep05_nozzle_limit_budget() {
467 let dry_mass = 60_000.0;
472 let total = 300_000.0;
473
474 let events = vec![
475 MassEvent {
476 time_h: 0.0,
477 kind: MassEventKind::FuelBurn {
478 delta_v_km_s: 3800.0,
479 isp_s: ISP_HIGH,
480 burn_duration_h: 12.0,
481 },
482 episode: 5,
483 label: "天王星脱出+巡航加速".into(),
484 },
485 MassEvent {
486 time_h: 375.0,
487 kind: MassEventKind::FuelBurn {
488 delta_v_km_s: 456.0, isp_s: ISP_HIGH,
490 burn_duration_h: 8.0,
491 },
492 episode: 5,
493 label: "木星フライバイ".into(),
494 },
495 MassEvent {
496 time_h: 400.0,
497 kind: MassEventKind::FuelBurn {
498 delta_v_km_s: 7600.0,
499 isp_s: ISP_HIGH,
500 burn_duration_h: 35.0,
501 },
502 episode: 5,
503 label: "減速フェーズ".into(),
504 },
505 MassEvent {
506 time_h: 505.0,
507 kind: MassEventKind::FuelBurn {
508 delta_v_km_s: 7.67, isp_s: ISP_HIGH,
510 burn_duration_h: 0.2,
511 },
512 episode: 5,
513 label: "LEO投入".into(),
514 },
515 ];
516
517 let tl = compute_timeline("EP05 300t", total, dry_mass, &events);
518 let final_snap = final_mass(&tl).unwrap();
519
520 assert!(
522 final_snap.propellant_kg > 0.0,
523 "EP05 must not run out of propellant: {} kg remaining",
524 final_snap.propellant_kg
525 );
526
527 let total_burn_h = 12.0 + 8.0 + 35.0 + 0.2;
529 assert!(
530 total_burn_h < 55.0 + 38.0 / 60.0,
531 "burn time {total_burn_h}h exceeds nozzle life"
532 );
533
534 let margin = propellant_margin(&tl);
536 assert!(
537 margin < 0.5,
538 "EP05 should consume >50% of propellant, margin = {margin:.3}"
539 );
540 }
541
542 #[test]
543 fn damage_event_reduces_mass() {
544 let events = vec![MassEvent {
545 time_h: 100.0,
546 kind: MassEventKind::DamageEvent { mass_kg: 5_000.0 },
547 episode: 4,
548 label: "プラズモイド被害".into(),
549 }];
550
551 let tl = compute_timeline("damage", 300_000.0, 150_000.0, &events);
552 let final_snap = final_mass(&tl).unwrap();
553 assert!(
554 (final_snap.dry_mass_kg - 145_000.0).abs() < 1e-6,
555 "dry mass after damage = {}",
556 final_snap.dry_mass_kg
557 );
558 }
559
560 #[test]
561 fn resupply_adds_mass() {
562 let events = vec![MassEvent {
563 time_h: 500.0,
564 kind: MassEventKind::Resupply { mass_kg: 50_000.0 },
565 episode: 2,
566 label: "エンケラドス補給".into(),
567 }];
568
569 let tl = compute_timeline("resupply", 200_000.0, 150_000.0, &events);
570 let final_snap = final_mass(&tl).unwrap();
571 assert!(
572 (final_snap.propellant_kg - 100_000.0).abs() < 1e-6,
573 "propellant after resupply = {}",
574 final_snap.propellant_kg
575 );
576 assert!(
577 (final_snap.total_mass_kg - 250_000.0).abs() < 1e-6,
578 "total after resupply = {}",
579 final_snap.total_mass_kg
580 );
581 }
582
583 #[test]
584 fn propellant_clamp_prevents_negative() {
585 let events = vec![MassEvent {
587 time_h: 0.0,
588 kind: MassEventKind::FuelBurn {
589 delta_v_km_s: 100_000.0,
590 isp_s: ISP_HIGH,
591 burn_duration_h: 1.0,
592 },
593 episode: 1,
594 label: "impossible burn".into(),
595 }];
596
597 let tl = compute_timeline("clamp", 200_000.0, 150_000.0, &events);
598 let final_snap = final_mass(&tl).unwrap();
599 assert!(
600 final_snap.propellant_kg >= 0.0,
601 "propellant must not go negative: {}",
602 final_snap.propellant_kg
603 );
604 }
605
606 #[test]
609 fn zero_initial_propellant() {
610 let tl = compute_timeline("zero-prop", 100_000.0, 100_000.0, &[]);
612 assert_eq!(tl.snapshots[0].propellant_kg, 0.0);
613 assert!((propellant_margin(&tl) - 0.0).abs() < 1e-10);
614 }
615
616 #[test]
617 fn zero_initial_propellant_with_burn() {
618 let events = vec![MassEvent {
620 time_h: 0.0,
621 kind: MassEventKind::FuelBurn {
622 delta_v_km_s: 1000.0,
623 isp_s: ISP_HIGH,
624 burn_duration_h: 1.0,
625 },
626 episode: 1,
627 label: "dry burn".into(),
628 }];
629
630 let tl = compute_timeline("dry-burn", 100_000.0, 100_000.0, &events);
631 let final_snap = final_mass(&tl).unwrap();
632 assert_eq!(final_snap.propellant_kg, 0.0);
633 assert_eq!(final_snap.dry_mass_kg, 100_000.0);
634 }
635
636 #[test]
637 fn post_burn_mass_zero_dv() {
638 let post = post_burn_mass(300_000.0, 0.0, ISP_HIGH);
639 assert!((post - 300_000.0).abs() < 1e-6, "zero ΔV → unchanged mass");
640 }
641
642 #[test]
643 fn post_burn_mass_low_isp() {
644 let pre = 10_000.0;
646 let post = post_burn_mass(pre, 3.0, 300.0);
647 assert!(
651 post > 3_000.0 && post < 4_000.0,
652 "low-Isp post mass = {post}"
653 );
654 }
655
656 #[test]
657 fn concurrent_timestamp_events() {
658 let events = vec![
660 MassEvent {
661 time_h: 10.0,
662 kind: MassEventKind::ContainerJettison { mass_kg: 5_000.0 },
663 episode: 1,
664 label: "jettison A".into(),
665 },
666 MassEvent {
667 time_h: 10.0,
668 kind: MassEventKind::ContainerJettison { mass_kg: 3_000.0 },
669 episode: 1,
670 label: "jettison B".into(),
671 },
672 ];
673
674 let tl = compute_timeline("concurrent", 200_000.0, 100_000.0, &events);
675 let final_snap = final_mass(&tl).unwrap();
676 assert!(
678 (final_snap.dry_mass_kg - 92_000.0).abs() < 1e-6,
679 "concurrent jettisons: dry = {}",
680 final_snap.dry_mass_kg
681 );
682 assert!(
684 (final_snap.propellant_kg - 100_000.0).abs() < 1e-6,
685 "propellant unchanged"
686 );
687 }
688
689 #[test]
690 fn zero_duration_burn() {
691 let events = vec![MassEvent {
693 time_h: 50.0,
694 kind: MassEventKind::FuelBurn {
695 delta_v_km_s: 1000.0,
696 isp_s: ISP_HIGH,
697 burn_duration_h: 0.0,
698 },
699 episode: 1,
700 label: "instant burn".into(),
701 }];
702
703 let tl = compute_timeline("instant", 200_000.0, 100_000.0, &events);
704 let final_snap = final_mass(&tl).unwrap();
705 assert!(final_snap.propellant_kg < 100_000.0);
707 assert!((final_snap.time_h - 50.0).abs() < 1e-6);
709 }
710
711 #[test]
712 fn mass_ratio_overflow_path() {
713 let consumed = propellant_consumed(100_000.0, 1_000_000.0, ISP_HIGH);
717 assert!(
718 (consumed - 100_000.0).abs() < 1e-6,
719 "infinite mass ratio → all mass consumed: {consumed}"
720 );
721 }
722
723 #[test]
724 fn resupply_exceeds_initial_propellant() {
725 let events = vec![MassEvent {
727 time_h: 10.0,
728 kind: MassEventKind::Resupply { mass_kg: 200_000.0 },
729 episode: 2,
730 label: "large resupply".into(),
731 }];
732
733 let tl = compute_timeline("excess", 200_000.0, 150_000.0, &events);
734 let margin = propellant_margin(&tl);
735 assert!(margin > 1.0, "margin after excess resupply = {margin:.1}");
738 }
739
740 #[test]
741 fn episode_tracking_across_events() {
742 let events = vec![
743 MassEvent {
744 time_h: 0.0,
745 kind: MassEventKind::FuelBurn {
746 delta_v_km_s: 1000.0,
747 isp_s: ISP_HIGH,
748 burn_duration_h: 36.0,
749 },
750 episode: 1,
751 label: "EP01 burn".into(),
752 },
753 MassEvent {
754 time_h: 100.0,
755 kind: MassEventKind::FuelBurn {
756 delta_v_km_s: 500.0,
757 isp_s: ISP_HIGH,
758 burn_duration_h: 12.0,
759 },
760 episode: 3,
761 label: "EP03 burn".into(),
762 },
763 ];
764
765 let tl = compute_timeline("multi-ep", 300_000.0, 100_000.0, &events);
766 assert_eq!(tl.snapshots[0].episode, 1);
768 let final_snap = final_mass(&tl).unwrap();
770 assert_eq!(final_snap.episode, 3);
771 }
772
773 #[test]
774 fn consecutive_burns_compound_correctly() {
775 let events = vec![
778 MassEvent {
779 time_h: 0.0,
780 kind: MassEventKind::FuelBurn {
781 delta_v_km_s: 2000.0,
782 isp_s: ISP_HIGH,
783 burn_duration_h: 10.0,
784 },
785 episode: 1,
786 label: "burn 1".into(),
787 },
788 MassEvent {
789 time_h: 10.0,
790 kind: MassEventKind::FuelBurn {
791 delta_v_km_s: 2000.0,
792 isp_s: ISP_HIGH,
793 burn_duration_h: 10.0,
794 },
795 episode: 1,
796 label: "burn 2".into(),
797 },
798 ];
799
800 let tl = compute_timeline("compound", 300_000.0, 100_000.0, &events);
801
802 let consumed1 = propellant_consumed(300_000.0, 2000.0, ISP_HIGH);
804 let post1_total = 300_000.0 - consumed1;
805 let consumed2 = propellant_consumed(post1_total, 2000.0, ISP_HIGH);
807
808 assert!(
810 consumed2 < consumed1,
811 "second burn should consume less: {consumed2:.1} < {consumed1:.1}",
812 );
813
814 let total_consumed = total_propellant_consumed(&tl);
816 assert!(
817 (total_consumed - (consumed1 + consumed2)).abs() < 1.0,
818 "timeline consumed {total_consumed:.1} vs manual {:.1}",
819 consumed1 + consumed2
820 );
821 }
822
823 #[test]
824 fn resupply_then_burn_uses_increased_mass() {
825 let events = vec![
827 MassEvent {
828 time_h: 10.0,
829 kind: MassEventKind::Resupply { mass_kg: 100_000.0 },
830 episode: 2,
831 label: "Enceladus refuel".into(),
832 },
833 MassEvent {
834 time_h: 20.0,
835 kind: MassEventKind::FuelBurn {
836 delta_v_km_s: 2000.0,
837 isp_s: ISP_HIGH,
838 burn_duration_h: 10.0,
839 },
840 episode: 3,
841 label: "post-refuel burn".into(),
842 },
843 ];
844
845 let tl = compute_timeline("resupply-burn", 200_000.0, 150_000.0, &events);
846 let expected_consumed = propellant_consumed(300_000.0, 2000.0, ISP_HIGH);
849 let final_snap = final_mass(&tl).unwrap();
850 let remaining_prop = final_snap.propellant_kg;
851 assert!(
852 (remaining_prop - (150_000.0 - expected_consumed)).abs() < 1.0,
853 "post-refuel burn: prop={remaining_prop:.1}, expected={:.1}",
854 150_000.0 - expected_consumed
855 );
856 }
857
858 #[test]
859 fn total_mass_invariant_at_every_snapshot() {
860 let events = vec![
862 MassEvent {
863 time_h: 0.0,
864 kind: MassEventKind::FuelBurn {
865 delta_v_km_s: 3000.0,
866 isp_s: ISP_HIGH,
867 burn_duration_h: 20.0,
868 },
869 episode: 1,
870 label: "burn".into(),
871 },
872 MassEvent {
873 time_h: 30.0,
874 kind: MassEventKind::ContainerJettison { mass_kg: 10_000.0 },
875 episode: 1,
876 label: "jettison".into(),
877 },
878 MassEvent {
879 time_h: 50.0,
880 kind: MassEventKind::Resupply { mass_kg: 5_000.0 },
881 episode: 2,
882 label: "resupply".into(),
883 },
884 MassEvent {
885 time_h: 60.0,
886 kind: MassEventKind::DamageEvent { mass_kg: 2_000.0 },
887 episode: 4,
888 label: "damage".into(),
889 },
890 ];
891
892 let tl = compute_timeline("invariant", 300_000.0, 120_000.0, &events);
893 for (i, snap) in tl.snapshots.iter().enumerate() {
894 let diff = (snap.total_mass_kg - snap.dry_mass_kg - snap.propellant_kg).abs();
895 assert!(
896 diff < 1e-6,
897 "snapshot {i}: total={:.1} != dry={:.1} + prop={:.1} (diff={diff:.10})",
898 snap.total_mass_kg,
899 snap.dry_mass_kg,
900 snap.propellant_kg
901 );
902 }
903 }
904
905 #[test]
906 fn propellant_dv_at_exhaust_velocity() {
907 let ve_km_s = ISP_HIGH * crate::constants::G0_M_S2 / 1000.0;
910 let consumed = propellant_consumed(100_000.0, ve_km_s, ISP_HIGH);
911 let expected = 100_000.0 * (1.0 - 1.0 / core::f64::consts::E);
912 assert!(
913 (consumed - expected).abs() < 1.0,
914 "ΔV = vₑ: consumed = {consumed:.1}, expected = {expected:.1}"
915 );
916 }
917
918 #[test]
919 fn resupply_event_increases_total_mass() {
920 let events = vec![
921 MassEvent {
922 time_h: 10.0,
923 kind: MassEventKind::FuelBurn {
924 delta_v_km_s: 1.0,
925 isp_s: ISP_HIGH,
926 burn_duration_h: 1.0,
927 },
928 episode: 1,
929 label: "Burn".to_string(),
930 },
931 MassEvent {
932 time_h: 20.0,
933 kind: MassEventKind::Resupply { mass_kg: 5000.0 },
934 episode: 2,
935 label: "Resupply".to_string(),
936 },
937 ];
938 let tl = compute_timeline("resupply-test", 100_000.0, 80_000.0, &events);
939
940 let after_burn = tl.snapshots.iter().find(|s| s.time_h > 10.5).unwrap();
942 assert!(after_burn.total_mass_kg < 100_000.0, "burn reduces mass");
943
944 let after_resupply = tl.snapshots.last().unwrap();
946 assert!(
947 after_resupply.total_mass_kg > after_burn.total_mass_kg,
948 "resupply increases mass: {:.1} > {:.1}",
949 after_resupply.total_mass_kg,
950 after_burn.total_mass_kg
951 );
952 assert!(
954 (after_resupply.propellant_kg - (after_burn.propellant_kg + 5000.0)).abs() < 1e-6,
955 "resupply adds exact mass to propellant"
956 );
957 }
958
959 #[test]
960 fn propellant_margin_after_mixed_events() {
961 let events = vec![
962 MassEvent {
963 time_h: 0.0,
964 kind: MassEventKind::FuelBurn {
965 delta_v_km_s: 1.0,
966 isp_s: ISP_HIGH,
967 burn_duration_h: 1.0,
968 },
969 episode: 1,
970 label: "Burn 1".to_string(),
971 },
972 MassEvent {
973 time_h: 10.0,
974 kind: MassEventKind::ContainerJettison { mass_kg: 1000.0 },
975 episode: 1,
976 label: "Jettison".to_string(),
977 },
978 MassEvent {
979 time_h: 20.0,
980 kind: MassEventKind::FuelBurn {
981 delta_v_km_s: 2.0,
982 isp_s: ISP_HIGH,
983 burn_duration_h: 2.0,
984 },
985 episode: 2,
986 label: "Burn 2".to_string(),
987 },
988 ];
989 let tl = compute_timeline("mixed", 100_000.0, 80_000.0, &events);
990
991 let margin = propellant_margin(&tl);
992 assert!(margin > 0.0 && margin < 1.0, "margin={margin}");
994
995 let consumed = total_propellant_consumed(&tl);
996 let initial_prop = 100_000.0 - 80_000.0;
998 let remaining = tl.snapshots.last().unwrap().propellant_kg;
999 assert!(
1000 (consumed - (initial_prop - remaining)).abs() < 1e-6,
1001 "consumed={consumed:.1}, expected={:.1}",
1002 initial_prop - remaining
1003 );
1004 }
1005}