solar_line_core/
mass_timeline.rs

1/// Mass timeline analysis for Kestrel's journey.
2///
3/// Models mass changes over time: propellant consumption during burns,
4/// container jettison, and damage-related mass loss. Uses Tsiolkovsky
5/// equation for propellant consumption during each burn segment.
6use crate::orbits::{exhaust_velocity, mass_ratio};
7use crate::units::KmPerSec;
8
9/// A discrete mass-changing event in the ship's timeline.
10#[derive(Debug, Clone)]
11pub struct MassEvent {
12    /// Mission elapsed time at event start (hours from EP01 departure).
13    pub time_h: f64,
14    /// Type of mass change.
15    pub kind: MassEventKind,
16    /// Episode number (1-5).
17    pub episode: u8,
18    /// Human-readable label (Japanese).
19    pub label: String,
20}
21
22/// Classification of mass-changing events.
23#[derive(Debug, Clone)]
24pub enum MassEventKind {
25    /// Propellant consumption during a burn.
26    /// Contains: ΔV (km/s), exhaust velocity via Isp (s), burn duration (hours).
27    FuelBurn {
28        delta_v_km_s: f64,
29        isp_s: f64,
30        burn_duration_h: f64,
31    },
32    /// Container or cargo jettison (mass removed from ship).
33    ContainerJettison { mass_kg: f64 },
34    /// Damage-related mass loss (debris, ablation, etc.).
35    DamageEvent { mass_kg: f64 },
36    /// Resupply or loading (mass added).
37    Resupply { mass_kg: f64 },
38}
39
40/// A snapshot of ship mass at a point in time.
41#[derive(Debug, Clone, Copy)]
42pub struct MassSnapshot {
43    /// Mission elapsed time (hours).
44    pub time_h: f64,
45    /// Total ship mass (kg).
46    pub total_mass_kg: f64,
47    /// Dry mass component (structure + payload, kg).
48    pub dry_mass_kg: f64,
49    /// Remaining propellant (kg).
50    pub propellant_kg: f64,
51    /// Episode number.
52    pub episode: u8,
53}
54
55/// A complete mass timeline scenario.
56#[derive(Debug, Clone)]
57pub struct MassTimeline {
58    /// Scenario name.
59    pub name: String,
60    /// Initial total mass (kg).
61    pub initial_mass_kg: f64,
62    /// Initial dry mass (kg).
63    pub initial_dry_mass_kg: f64,
64    /// Ordered snapshots (before and after each event).
65    pub snapshots: Vec<MassSnapshot>,
66}
67
68/// Compute propellant consumed during a burn using Tsiolkovsky equation.
69///
70/// Given the pre-burn total mass, ΔV, and exhaust velocity (from Isp),
71/// returns the propellant mass consumed (kg).
72///
73/// m_propellant = m_before × (1 - 1/mass_ratio)
74///              = m_before × (1 - exp(-ΔV/vₑ))
75pub 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 // all mass consumed
85    } else {
86        pre_burn_mass_kg * (1.0 - 1.0 / mr)
87    }
88}
89
90/// Post-burn mass after consuming propellant.
91pub 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
95/// Generate a mass timeline from an initial state and ordered events.
96///
97/// For each event:
98/// - FuelBurn: applies Tsiolkovsky equation, reduces propellant
99/// - ContainerJettison: reduces dry mass
100/// - DamageEvent: reduces dry mass (structural loss)
101/// - Resupply: adds to propellant (or dry mass depending on context)
102///
103/// Returns a MassTimeline with snapshots before and after each event.
104pub 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    // Initial snapshot
119    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        // Pre-event snapshot (if time differs from last snapshot)
135        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                // Clamp to available propellant
155                let consumed = consumed.min(current_propellant);
156                current_propellant -= consumed;
157
158                // Post-burn snapshot at end of burn
159                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
209/// Compute final mass after all events.
210pub fn final_mass(timeline: &MassTimeline) -> Option<&MassSnapshot> {
211    timeline.snapshots.last()
212}
213
214/// Compute total propellant consumed across all burns.
215pub 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
223/// Compute propellant margin (remaining / initial).
224pub 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; // Isp = 10⁶ s (D-He³ fusion)
240
241    #[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        // 300t ship, ΔV = 8497 km/s, Isp = 10⁶ s
250        // vₑ = 9806.65 km/s
251        // mass_ratio = exp(8497/9806.65) ≈ 2.376
252        // propellant = 300,000 * (1 - 1/2.376) ≈ 173,720 kg
253        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        // Should have: initial + post-burn = 2 snapshots
296        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); // above dry mass
301        assert_eq!(final_snap.dry_mass_kg, 100_000.0); // dry unchanged
302        assert!(final_snap.propellant_kg < 200_000.0); // propellant reduced
303    }
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        // Dry mass should be 142300 - 42300 = 100000
362        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        // Should have consumed propellant from both burns
369        let consumed = total_propellant_consumed(&tl);
370        assert!(consumed > 0.0, "propellant consumed = {consumed}");
371
372        // Total mass should be dry + remaining propellant
373        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        // Full propellant (no events)
382        let tl = compute_timeline("full", 300_000.0, 100_000.0, &[]);
383        assert!((propellant_margin(&tl) - 1.0).abs() < 1e-10);
384
385        // Nearly empty
386        let events = vec![MassEvent {
387            time_h: 0.0,
388            kind: MassEventKind::FuelBurn {
389                delta_v_km_s: 50000.0, // very high ΔV
390                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    // ── Kestrel-specific scenario tests ──
402
403    #[test]
404    fn kestrel_ep01_brachistochrone_72h() {
405        // EP01: Mars→Ganymede, 72h, ΔV = 8497 km/s at 9.8 MN
406        // Ship mass ≤299t for this to work
407        // mass_ratio = exp(8497/9806.65) ≈ 2.376
408        // final_mass = 299,000 / 2.376 ≈ 125,800 kg (dry mass bound)
409        // Two sequential half-burns consume slightly more than one full burn,
410        // so dry mass should be < 125,000 kg for margin.
411        // Scenario A: 80t structure + 42.3t cargo ≈ 122.3t dry
412        let dry_mass = 80_000.0 + 42_300.0; // 122.3t
413        let total = 299_000.0;
414
415        // Brachistochrone: accel 36h + decel 36h
416        let events = vec![
417            MassEvent {
418                time_h: 0.0,
419                kind: MassEventKind::FuelBurn {
420                    delta_v_km_s: 4248.5, // half of 8497
421                    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        // Should have positive propellant remaining
443        assert!(
444            final_snap.propellant_kg > 0.0,
445            "propellant remaining = {} kg",
446            final_snap.propellant_kg
447        );
448        // Dry mass unchanged
449        assert!(
450            (final_snap.dry_mass_kg - dry_mass).abs() < 1e-6,
451            "dry mass should be unchanged"
452        );
453
454        // mass_ratio for total ΔV = 8497 km/s:
455        // consumed ≈ 299,000 * 0.579 ≈ 173,000 kg
456        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        // EP05: 4 burns, 55h12m total burn, ΔV = 15207 km/s at 300t
468        // mass_ratio = exp(15207/9806.65) ≈ 4.72
469        // propellant_fraction ≈ 0.788 → need 236,400 kg propellant from 300t
470        // dry mass ≈ 63,600 kg — use 60t (ship already damaged/lightened)
471        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, // Oberth +3%
489                    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, // LEO insertion
509                    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        // Must still have positive propellant
521        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        // Total burn time = 12 + 8 + 35 + 0.2 = 55.2h < 55h38m nozzle life
528        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        // Final propellant margin should be reasonable but tight
535        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        // Huge ΔV should consume all propellant but not go negative
586        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    // ── Edge case tests (Task 420) ──
607
608    #[test]
609    fn zero_initial_propellant() {
610        // total == dry → zero propellant
611        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        // A burn with zero propellant should clamp to zero consumption
619        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        // Chemical rocket: Isp = 300 s
645        let pre = 10_000.0;
646        let post = post_burn_mass(pre, 3.0, 300.0);
647        // vₑ = 300 * 9.80665 / 1000 = 2.942 km/s
648        // mass_ratio = exp(3.0 / 2.942) ≈ 2.775
649        // post = 10000 / 2.775 ≈ 3604 kg
650        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        // Two events at the same time — should not duplicate pre-event snapshot
659        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        // Both jettisons should apply: dry = 100000 - 5000 - 3000 = 92000
677        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        // Propellant unchanged
683        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        // burn_duration_h = 0 → post-burn snapshot at same time as pre-event
692        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        // Burn should still consume propellant
706        assert!(final_snap.propellant_kg < 100_000.0);
707        // Post-burn time = 50.0 + 0.0 = 50.0
708        assert!((final_snap.time_h - 50.0).abs() < 1e-6);
709    }
710
711    #[test]
712    fn mass_ratio_overflow_path() {
713        // ΔV >> vₑ should trigger the infinite mass ratio branch
714        // vₑ = ISP_HIGH * g₀ / 1000 ≈ 9806.65 km/s
715        // ΔV = 1,000,000 km/s → exp(1000000/9806.65) = overflow
716        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        // Resupply more than initial → margin > 1.0
726        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        // Initial propellant = 50,000; after resupply = 250,000
736        // margin = 250,000 / 50,000 = 5.0
737        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        // Initial snapshot should use first event's episode
767        assert_eq!(tl.snapshots[0].episode, 1);
768        // Final snapshot should use last event's episode
769        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        // Two consecutive identical burns should consume different amounts of propellant
776        // because the second burn starts from a lighter ship
777        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        // Manual calculation: first burn consumes propellant from 300k total
803        let consumed1 = propellant_consumed(300_000.0, 2000.0, ISP_HIGH);
804        let post1_total = 300_000.0 - consumed1;
805        // Second burn starts from lighter ship
806        let consumed2 = propellant_consumed(post1_total, 2000.0, ISP_HIGH);
807
808        // Second burn should consume LESS propellant (lighter ship)
809        assert!(
810            consumed2 < consumed1,
811            "second burn should consume less: {consumed2:.1} < {consumed1:.1}",
812        );
813
814        // Total consumed should match timeline
815        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        // Resupply adds propellant, then a burn should consume from the increased total
826        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        // After resupply: total = 300,000, dry = 150,000, prop = 150,000
847        // Burn from 300,000 total mass
848        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        // At every snapshot, total_mass = dry + propellant
861        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        // ΔV = vₑ → mass_ratio = e ≈ 2.718
908        // propellant = m * (1 - 1/e) ≈ m * 0.6321
909        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        // After burn, mass should decrease
941        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        // After resupply, mass should increase relative to post-burn
945        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        // Resupply adds exactly 5000 kg to propellant
953        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        // Margin should be between 0 and 1 (some propellant consumed but not all)
993        assert!(margin > 0.0 && margin < 1.0, "margin={margin}");
994
995        let consumed = total_propellant_consumed(&tl);
996        // Total consumed should equal initial propellant minus remaining
997        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}