solar_line_core/
comms.rs

1/// Communications analysis for SOLAR LINE.
2///
3/// Computes light delays, link budgets, and communication feasibility
4/// between spacecraft and planets at various distances.
5///
6/// Key analysis questions:
7/// - Is real-time dialogue plausible at depicted distances?
8/// - What data rates are achievable?
9/// - When would communication blackouts occur?
10use crate::ephemeris::{self, Planet, PlanetPosition};
11use crate::units::Km;
12
13/// Speed of light in km/s — re-exported from constants module for convenience.
14pub use crate::constants::C_KM_S;
15
16/// One-way light time (seconds) for a given distance in km.
17///
18/// ```text
19/// t = d / c
20/// ```
21pub fn light_time_seconds(distance: Km) -> f64 {
22    distance.value() / C_KM_S
23}
24
25/// One-way light time in minutes.
26pub fn light_time_minutes(distance: Km) -> f64 {
27    light_time_seconds(distance) / 60.0
28}
29
30/// Round-trip light time (seconds) — relevant for query-response communication.
31pub fn round_trip_light_time(distance: Km) -> f64 {
32    2.0 * light_time_seconds(distance)
33}
34
35/// Distance between two heliocentric positions (km).
36///
37/// Uses 2D ecliptic plane positions (sufficient for this analysis since
38/// planetary inclinations are small).
39pub fn distance_between_positions(pos1: &PlanetPosition, pos2: &PlanetPosition) -> Km {
40    let dx = pos1.x - pos2.x;
41    let dy = pos1.y - pos2.y;
42    let dz = pos1.z - pos2.z;
43    Km((dx * dx + dy * dy + dz * dz).sqrt())
44}
45
46/// Light delay between two planets at a given Julian Date.
47///
48/// Returns one-way light time in seconds.
49pub fn planet_light_delay(planet1: Planet, planet2: Planet, jd: f64) -> f64 {
50    let pos1 = ephemeris::planet_position(planet1, jd);
51    let pos2 = ephemeris::planet_position(planet2, jd);
52    let dist = distance_between_positions(&pos1, &pos2);
53    light_time_seconds(dist)
54}
55
56/// Light delay between a spacecraft position (x, y in km, heliocentric) and a planet.
57///
58/// Returns one-way light time in seconds.
59pub fn ship_planet_light_delay(ship_x: f64, ship_y: f64, planet: Planet, jd: f64) -> f64 {
60    let planet_pos = ephemeris::planet_position(planet, jd);
61    let dx = ship_x - planet_pos.x;
62    let dy = ship_y - planet_pos.y;
63    let dist = Km((dx * dx + dy * dy).sqrt());
64    light_time_seconds(dist)
65}
66
67/// Communication feasibility classification.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum CommFeasibility {
70    /// Real-time conversation possible (delay < 3 seconds one-way)
71    RealTime,
72    /// Near-real-time with noticeable lag (3s–30s one-way)
73    NearRealTime,
74    /// Delayed communication — must use store-and-forward (30s–30min)
75    Delayed,
76    /// Deep space communication — very long delays (> 30 min)
77    DeepSpace,
78}
79
80impl CommFeasibility {
81    /// Classify communication feasibility based on one-way light delay (seconds).
82    pub fn classify(one_way_delay_s: f64) -> Self {
83        if one_way_delay_s < 3.0 {
84            CommFeasibility::RealTime
85        } else if one_way_delay_s < 30.0 {
86            CommFeasibility::NearRealTime
87        } else if one_way_delay_s < 1800.0 {
88            CommFeasibility::Delayed
89        } else {
90            CommFeasibility::DeepSpace
91        }
92    }
93
94    /// Human-readable Japanese label.
95    pub fn label_ja(&self) -> &'static str {
96        match self {
97            CommFeasibility::RealTime => "リアルタイム通信可能",
98            CommFeasibility::NearRealTime => "準リアルタイム(顕著な遅延あり)",
99            CommFeasibility::Delayed => "遅延通信(蓄積転送)",
100            CommFeasibility::DeepSpace => "深宇宙通信(大幅な遅延)",
101        }
102    }
103}
104
105/// Free-space path loss in dB.
106///
107/// FSPL = 20·log₁₀(d) + 20·log₁₀(f) + 20·log₁₀(4π/c)
108///
109/// where d is distance in meters, f is frequency in Hz.
110/// This follows the standard telecommunications formula.
111pub fn free_space_path_loss_db(distance_km: f64, freq_hz: f64) -> f64 {
112    let d_m = distance_km * 1000.0;
113    let c_m_s = C_KM_S * 1000.0;
114    20.0 * d_m.log10()
115        + 20.0 * freq_hz.log10()
116        + 20.0 * (4.0 * std::f64::consts::PI / c_m_s).log10()
117}
118
119/// Minimum and maximum distances between two planets (km).
120///
121/// Uses mean orbital radii (circular orbit approximation).
122/// Min distance = |r2 - r1| (conjunction), Max = r1 + r2 (opposition).
123pub fn planet_distance_range(planet1: Planet, planet2: Planet) -> (Km, Km) {
124    let r1 = planet1.semi_major_axis().value();
125    let r2 = planet2.semi_major_axis().value();
126    let min = (r2 - r1).abs();
127    let max = r1 + r2;
128    (Km(min), Km(max))
129}
130
131/// Light delay range between two planets (min, max) in seconds.
132pub fn planet_light_delay_range(planet1: Planet, planet2: Planet) -> (f64, f64) {
133    let (min_dist, max_dist) = planet_distance_range(planet1, planet2);
134    (light_time_seconds(min_dist), light_time_seconds(max_dist))
135}
136
137/// Timeline entry for communication delay along a route.
138#[derive(Debug, Clone)]
139pub struct CommTimelineEntry {
140    /// Julian Date
141    pub jd: f64,
142    /// Elapsed time from route start (seconds)
143    pub elapsed_s: f64,
144    /// Ship heliocentric x (km)
145    pub ship_x: f64,
146    /// Ship heliocentric y (km)
147    pub ship_y: f64,
148    /// One-way light delay to Earth (seconds)
149    pub delay_to_earth_s: f64,
150    /// Communication feasibility classification
151    pub feasibility: CommFeasibility,
152}
153
154/// Compute communication timeline along a linear route between two planets.
155///
156/// Assumes the ship travels in a straight line from departure planet position
157/// to arrival planet position (simplified; actual trajectories curve).
158/// This is sufficient for estimating the light delay profile.
159///
160/// Returns entries at regular time intervals.
161pub fn comm_timeline_linear(
162    departure: Planet,
163    arrival: Planet,
164    departure_jd: f64,
165    travel_time_s: f64,
166    n_steps: usize,
167) -> Vec<CommTimelineEntry> {
168    let dep_pos = ephemeris::planet_position(departure, departure_jd);
169    let arr_jd = departure_jd + travel_time_s / 86400.0;
170    let arr_pos = ephemeris::planet_position(arrival, arr_jd);
171
172    let mut entries = Vec::with_capacity(n_steps + 1);
173    for i in 0..=n_steps {
174        let frac = i as f64 / n_steps as f64;
175        let elapsed = frac * travel_time_s;
176        let jd = departure_jd + elapsed / 86400.0;
177
178        // Linear interpolation of ship position
179        let ship_x = dep_pos.x + frac * (arr_pos.x - dep_pos.x);
180        let ship_y = dep_pos.y + frac * (arr_pos.y - dep_pos.y);
181
182        // Delay to Earth (Earth position at this instant)
183        let earth_pos = ephemeris::planet_position(Planet::Earth, jd);
184        let dx = ship_x - earth_pos.x;
185        let dy = ship_y - earth_pos.y;
186        let dist = (dx * dx + dy * dy).sqrt();
187        let delay = dist / C_KM_S;
188
189        entries.push(CommTimelineEntry {
190            jd,
191            elapsed_s: elapsed,
192            ship_x,
193            ship_y,
194            delay_to_earth_s: delay,
195            feasibility: CommFeasibility::classify(delay),
196        });
197    }
198
199    entries
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::ephemeris::J2000_JD;
206
207    const AU_KM: f64 = 149_597_870.7;
208
209    #[test]
210    fn test_light_time_1au() {
211        // Light time for 1 AU ≈ 499.0 seconds ≈ 8.317 minutes
212        let delay = light_time_seconds(Km(AU_KM));
213        assert!(
214            (delay - 499.0).abs() < 0.5,
215            "1 AU light time = {:.1}s, expected ~499s",
216            delay
217        );
218    }
219
220    #[test]
221    fn test_light_time_minutes_1au() {
222        let minutes = light_time_minutes(Km(AU_KM));
223        assert!(
224            (minutes - 8.317).abs() < 0.01,
225            "1 AU light time = {:.3} min, expected ~8.317 min",
226            minutes
227        );
228    }
229
230    #[test]
231    fn test_round_trip_1au() {
232        let rt = round_trip_light_time(Km(AU_KM));
233        assert!(
234            (rt - 998.0).abs() < 1.0,
235            "1 AU round trip = {:.1}s, expected ~998s",
236            rt
237        );
238    }
239
240    #[test]
241    fn test_light_time_zero_distance() {
242        let delay = light_time_seconds(Km(0.0));
243        assert_eq!(delay, 0.0);
244    }
245
246    #[test]
247    fn test_planet_distance_range_earth_mars() {
248        let (min, max) = planet_distance_range(Planet::Earth, Planet::Mars);
249        // Earth-Mars: min ~0.524 AU (~78.3M km), max ~2.524 AU (~377.5M km)
250        let min_au = min.value() / AU_KM;
251        let max_au = max.value() / AU_KM;
252        assert!(
253            (min_au - 0.524).abs() < 0.02,
254            "Earth-Mars min = {:.3} AU, expected ~0.524",
255            min_au
256        );
257        assert!(
258            (max_au - 2.524).abs() < 0.02,
259            "Earth-Mars max = {:.3} AU, expected ~2.524",
260            max_au
261        );
262    }
263
264    #[test]
265    fn test_planet_distance_range_earth_jupiter() {
266        let (min, max) = planet_distance_range(Planet::Earth, Planet::Jupiter);
267        let min_au = min.value() / AU_KM;
268        let max_au = max.value() / AU_KM;
269        // Min ~4.2 AU, Max ~6.2 AU
270        assert!(
271            (min_au - 4.2).abs() < 0.1,
272            "Earth-Jupiter min = {:.1} AU",
273            min_au
274        );
275        assert!(
276            (max_au - 6.2).abs() < 0.1,
277            "Earth-Jupiter max = {:.1} AU",
278            max_au
279        );
280    }
281
282    #[test]
283    fn test_planet_distance_range_earth_saturn() {
284        let (min, max) = planet_distance_range(Planet::Earth, Planet::Saturn);
285        let min_au = min.value() / AU_KM;
286        let max_au = max.value() / AU_KM;
287        // Min ~8.5 AU, Max ~10.5 AU
288        assert!(
289            min_au > 8.0 && min_au < 9.0,
290            "Earth-Saturn min = {:.1} AU",
291            min_au
292        );
293        assert!(
294            max_au > 10.0 && max_au < 11.0,
295            "Earth-Saturn max = {:.1} AU",
296            max_au
297        );
298    }
299
300    #[test]
301    fn test_planet_distance_range_earth_uranus() {
302        let (min, max) = planet_distance_range(Planet::Earth, Planet::Uranus);
303        let min_au = min.value() / AU_KM;
304        let max_au = max.value() / AU_KM;
305        // Min ~18.2 AU, Max ~20.2 AU
306        assert!(
307            min_au > 17.5 && min_au < 19.0,
308            "Earth-Uranus min = {:.1} AU",
309            min_au
310        );
311        assert!(
312            max_au > 19.5 && max_au < 21.0,
313            "Earth-Uranus max = {:.1} AU",
314            max_au
315        );
316    }
317
318    #[test]
319    fn test_planet_light_delay_range_earth_mars() {
320        let (min_s, max_s) = planet_light_delay_range(Planet::Earth, Planet::Mars);
321        let min_min = min_s / 60.0;
322        let max_min = max_s / 60.0;
323        // Earth-Mars: min ~4.3 min, max ~21 min
324        assert!(
325            min_min > 3.5 && min_min < 5.0,
326            "Earth-Mars min delay = {:.1} min",
327            min_min
328        );
329        assert!(
330            max_min > 19.0 && max_min < 22.0,
331            "Earth-Mars max delay = {:.1} min",
332            max_min
333        );
334    }
335
336    #[test]
337    fn test_planet_light_delay_at_epoch() {
338        // At J2000, compute Earth-Mars light delay (should be a valid, finite value)
339        let delay = planet_light_delay(Planet::Earth, Planet::Mars, J2000_JD);
340        assert!(delay > 0.0 && delay.is_finite(), "delay = {}", delay);
341        // Should be between min and max
342        let (min_s, max_s) = planet_light_delay_range(Planet::Earth, Planet::Mars);
343        assert!(
344            delay >= min_s * 0.9 && delay <= max_s * 1.1,
345            "delay {} outside expected range [{}, {}]",
346            delay,
347            min_s,
348            max_s
349        );
350    }
351
352    #[test]
353    fn test_ship_planet_light_delay() {
354        // Ship at Earth's position should have ~0 delay to Earth
355        let earth_pos = ephemeris::planet_position(Planet::Earth, J2000_JD);
356        let delay = ship_planet_light_delay(earth_pos.x, earth_pos.y, Planet::Earth, J2000_JD);
357        assert!(
358            delay < 1.0,
359            "ship at Earth position should have near-zero delay: {}",
360            delay
361        );
362    }
363
364    #[test]
365    fn test_feasibility_classification() {
366        assert_eq!(CommFeasibility::classify(0.5), CommFeasibility::RealTime);
367        assert_eq!(CommFeasibility::classify(2.9), CommFeasibility::RealTime);
368        assert_eq!(
369            CommFeasibility::classify(3.0),
370            CommFeasibility::NearRealTime
371        );
372        assert_eq!(
373            CommFeasibility::classify(15.0),
374            CommFeasibility::NearRealTime
375        );
376        assert_eq!(CommFeasibility::classify(30.0), CommFeasibility::Delayed);
377        assert_eq!(CommFeasibility::classify(600.0), CommFeasibility::Delayed);
378        assert_eq!(
379            CommFeasibility::classify(1800.0),
380            CommFeasibility::DeepSpace
381        );
382        assert_eq!(
383            CommFeasibility::classify(3600.0),
384            CommFeasibility::DeepSpace
385        );
386    }
387
388    #[test]
389    fn test_free_space_path_loss_x_band() {
390        // X-band (8.4 GHz) at 1 AU distance
391        // FSPL = 20·log₁₀(d_m) + 20·log₁₀(f_Hz) + 20·log₁₀(4π/c)
392        // ≈ 274.4 dB at 1 AU (standard free-space path loss)
393        let fspl = free_space_path_loss_db(AU_KM, 8.4e9);
394        assert!(
395            (fspl - 274.4).abs() < 1.0,
396            "X-band FSPL at 1 AU = {:.1} dB, expected ~274.4",
397            fspl
398        );
399    }
400
401    #[test]
402    fn test_free_space_path_loss_increases_with_distance() {
403        let fspl_1au = free_space_path_loss_db(AU_KM, 8.4e9);
404        let fspl_5au = free_space_path_loss_db(5.0 * AU_KM, 8.4e9);
405        // 5x distance = +14 dB (20·log₁₀(5) ≈ 14.0)
406        assert!(
407            (fspl_5au - fspl_1au - 14.0).abs() < 0.1,
408            "FSPL increase for 5x distance = {:.1} dB, expected ~14.0",
409            fspl_5au - fspl_1au
410        );
411    }
412
413    #[test]
414    fn test_distance_between_positions() {
415        let pos1 = PlanetPosition {
416            longitude: crate::units::Radians(0.0),
417            latitude: crate::units::Radians(0.0),
418            distance: Km(AU_KM),
419            x: AU_KM,
420            y: 0.0,
421            z: 0.0,
422            inclination: crate::units::Radians(0.0),
423        };
424        let pos2 = PlanetPosition {
425            longitude: crate::units::Radians(std::f64::consts::PI),
426            latitude: crate::units::Radians(0.0),
427            distance: Km(AU_KM),
428            x: -AU_KM,
429            y: 0.0,
430            z: 0.0,
431            inclination: crate::units::Radians(0.0),
432        };
433        let dist = distance_between_positions(&pos1, &pos2);
434        assert!(
435            (dist.value() - 2.0 * AU_KM).abs() < 1.0,
436            "opposite sides of 1 AU orbit = {:.0} km, expected {:.0}",
437            dist.value(),
438            2.0 * AU_KM
439        );
440    }
441
442    #[test]
443    fn test_comm_timeline_linear_ep01_mars_ganymede() {
444        // EP01: Mars → Ganymede (actually Mars → Jupiter vicinity), 72 hours
445        // Use Mars→Jupiter as approximation for this test
446        let jd = J2000_JD;
447        let travel_time = 72.0 * 3600.0; // 72 hours
448
449        let timeline = comm_timeline_linear(Planet::Mars, Planet::Jupiter, jd, travel_time, 10);
450
451        assert_eq!(timeline.len(), 11); // 10 steps + 1
452        assert!((timeline[0].elapsed_s - 0.0).abs() < 1e-10);
453        assert!((timeline[10].elapsed_s - travel_time).abs() < 1e-6);
454
455        // All entries should have valid delays
456        for entry in &timeline {
457            assert!(entry.delay_to_earth_s > 0.0);
458            assert!(entry.delay_to_earth_s.is_finite());
459        }
460    }
461
462    #[test]
463    fn test_comm_timeline_entries_have_monotonic_elapsed() {
464        let timeline = comm_timeline_linear(
465            Planet::Earth,
466            Planet::Mars,
467            J2000_JD,
468            259.0 * 86400.0, // ~259 day Hohmann
469            20,
470        );
471        for i in 1..timeline.len() {
472            assert!(
473                timeline[i].elapsed_s > timeline[i - 1].elapsed_s,
474                "elapsed time not monotonic at step {}",
475                i
476            );
477        }
478    }
479
480    // ── SOLAR LINE episode-specific tests ──────────────────────────────
481
482    #[test]
483    fn test_ep01_mars_fire_control_is_nearrealtime() {
484        // EP01 scene 03: Mars control communication while still near Mars
485        // Ship is near Mars, communicating with Mars control → should be real-time
486        // Ship 10,000 km from Mars
487        let delay = light_time_seconds(Km(10_000.0));
488        assert!(delay < 0.1, "Mars control at 10,000 km = {:.3}s", delay);
489        assert_eq!(CommFeasibility::classify(delay), CommFeasibility::RealTime);
490    }
491
492    #[test]
493    fn test_ep02_jupiter_saturn_delay_range() {
494        // EP02: Jupiter → Saturn, ballistic 455 days
495        // Jupiter-Saturn distance: 4.3–10.5 AU
496        let (min, max) = planet_distance_range(Planet::Jupiter, Planet::Saturn);
497        let min_au = min.value() / AU_KM;
498        let max_au = max.value() / AU_KM;
499        assert!(min_au > 4.0 && min_au < 5.0, "J-S min = {:.1} AU", min_au);
500        assert!(max_au > 14.0 && max_au < 16.0, "J-S max = {:.1} AU", max_au);
501
502        // Light delay at minimum: ~35 min
503        let min_delay_min = light_time_minutes(min);
504        assert!(
505            min_delay_min > 30.0,
506            "J-S min delay = {:.1} min",
507            min_delay_min
508        );
509    }
510
511    #[test]
512    fn test_ep04_uranus_earth_deep_space() {
513        // EP04: Near Uranus, communicating with Earth
514        // Uranus-Earth: 18–20 AU → deep space communication
515        let (min_s, _max_s) = planet_light_delay_range(Planet::Earth, Planet::Uranus);
516        let min_min = min_s / 60.0;
517        // Even minimum delay > 30 min → deep space
518        assert!(
519            CommFeasibility::classify(min_s) == CommFeasibility::DeepSpace,
520            "Uranus-Earth min delay = {:.0} min should be DeepSpace",
521            min_min
522        );
523    }
524
525    #[test]
526    fn test_speed_of_light_constant() {
527        // Verify c = 299,792.458 km/s (exact by definition)
528        assert!((C_KM_S - 299_792.458).abs() < 1e-10);
529    }
530
531    #[test]
532    fn test_label_ja_all_variants() {
533        assert_eq!(CommFeasibility::RealTime.label_ja(), "リアルタイム通信可能");
534        assert_eq!(
535            CommFeasibility::NearRealTime.label_ja(),
536            "準リアルタイム(顕著な遅延あり)"
537        );
538        assert_eq!(CommFeasibility::Delayed.label_ja(), "遅延通信(蓄積転送)");
539        assert_eq!(
540            CommFeasibility::DeepSpace.label_ja(),
541            "深宇宙通信(大幅な遅延)"
542        );
543    }
544
545    #[test]
546    fn test_label_ja_via_classify() {
547        // Verify label_ja works when called on classify results
548        let rt = CommFeasibility::classify(1.0);
549        assert!(!rt.label_ja().is_empty());
550        let ds = CommFeasibility::classify(10_000.0);
551        assert!(!ds.label_ja().is_empty());
552    }
553
554    #[test]
555    fn test_fspl_increases_with_distance() {
556        // Free-space path loss increases with distance (6 dB per doubling)
557        let freq = 1.55e14; // ~1550 nm FSOC wavelength in Hz
558        let loss_1au = free_space_path_loss_db(149_597_870.7, freq);
559        let loss_2au = free_space_path_loss_db(2.0 * 149_597_870.7, freq);
560        // Doubling distance adds 20·log₁₀(2) ≈ 6.02 dB
561        let diff = loss_2au - loss_1au;
562        assert!(
563            (diff - 6.02).abs() < 0.01,
564            "FSPL doubling should add ~6 dB: diff = {diff:.4} dB"
565        );
566        // Also verify absolute order: farther is more loss
567        assert!(
568            loss_2au > loss_1au,
569            "2 AU loss {loss_2au:.1} should exceed 1 AU loss {loss_1au:.1}"
570        );
571    }
572
573    #[test]
574    fn test_planet_distance_range_earth_mars_au_values() {
575        // Earth-Mars: min ≈ 0.52 AU, max ≈ 2.52 AU
576        let (min_dist, max_dist) = planet_distance_range(Planet::Earth, Planet::Mars);
577        let min_au = min_dist.value() / AU_KM;
578        let max_au = max_dist.value() / AU_KM;
579        assert!(
580            (min_au - 0.52).abs() < 0.1,
581            "Earth-Mars min distance: {min_au:.3} AU (expected ~0.52)"
582        );
583        assert!(
584            (max_au - 2.52).abs() < 0.1,
585            "Earth-Mars max distance: {max_au:.3} AU (expected ~2.52)"
586        );
587        // Max should be much larger than min
588        assert!(max_au > 4.0 * min_au, "max should be ~5× min");
589    }
590}