solar_line_core/
jupiter_radiation.rs

1//! Jupiter radiation belt dose model for SOLAR LINE EP02 analysis.
2//!
3//! Models radiation dose rate as a function of distance from Jupiter using a
4//! piecewise power-law model calibrated to Galileo-era measurements.
5//!
6//! The model estimates TID (Total Ionizing Dose) behind a reference shielding
7//! thickness, expressed in krad(Si) behind 100 mil Al equivalent.
8//!
9//! Physical basis:
10//!   - Jupiter's radiation belts are the most intense in the solar system
11//!   - Trapped electron population dominates dose in the 10-30 RJ region
12//!   - Dose rate falls off roughly as a power law with distance
13//!   - The magnetopause (~50-100 RJ on dayside) marks the outer boundary
14//!
15//! Calibration references:
16//!   - Europa (~9.4 RJ): ~5,400 krad/year behind 100 mil Al (Galileo DDD)
17//!   - Ganymede (~15 RJ): ~540 krad/year behind 100 mil Al
18//!   - Callisto (~26 RJ): ~1 krad/year behind 100 mil Al
19//!   - These values are order-of-magnitude from NASA Europa mission studies
20//!     and the Galileo Design Dose Document (DDD).
21//!
22//! Key insight for EP02: The ship AI's "radiation shield remaining life: 42 min"
23//! is best interpreted as `dose_budget_remaining / instantaneous_dose_rate`,
24//! evaluated at the current position (~15 RJ). As the ship moves outward,
25//! the dose rate drops dramatically, so the actual time to shield depletion
26//! increases — potentially allowing the entire escape to succeed within budget.
27
28/// Jupiter equatorial radius in km.
29pub const JUPITER_RADIUS_KM: f64 = 71_492.0;
30
31/// A region of the piecewise power-law dose model.
32///
33/// Within this region, dose_rate(r) = d0 * (r / r0)^(-alpha)
34/// where r is in Jupiter radii.
35#[derive(Debug, Clone, Copy)]
36pub struct RadiationRegion {
37    /// Inner boundary of this region (in RJ). Inclusive.
38    pub r_min_rj: f64,
39    /// Outer boundary of this region (in RJ). Exclusive (except for outermost).
40    pub r_max_rj: f64,
41    /// Reference dose rate at r0 (krad(Si)/hour behind 100 mil Al)
42    pub d0_krad_per_hour: f64,
43    /// Reference distance (in RJ)
44    pub r0_rj: f64,
45    /// Power-law exponent (positive = rate decreases outward)
46    pub alpha: f64,
47}
48
49/// Configuration for the Jupiter radiation dose model.
50#[derive(Debug, Clone)]
51pub struct JupiterRadiationConfig {
52    /// Piecewise regions from inner to outer.
53    /// Must be sorted by r_min_rj and contiguous.
54    pub regions: Vec<RadiationRegion>,
55    /// Magnetopause distance in RJ. Beyond this, trapped radiation is zero.
56    pub magnetopause_rj: f64,
57    /// Geometry/variability multipliers
58    pub latitude_factor: f64,
59    /// Plasma sheet crossing factor (1.0 = in sheet, typically 0.3-0.5 outside)
60    pub plasma_sheet_factor: f64,
61    /// Storm/quiet multiplier (1.0 = nominal, 2-5 for storm)
62    pub storm_factor: f64,
63}
64
65/// Results of a Jupiter radiation transit analysis.
66#[derive(Debug, Clone)]
67pub struct JupiterTransitResult {
68    /// Total accumulated dose during transit (krad behind 100 mil Al)
69    pub total_dose_krad: f64,
70    /// Dose rate at departure point (krad/hour)
71    pub departure_dose_rate_krad_h: f64,
72    /// Dose rate at arrival point (krad/hour)
73    pub arrival_dose_rate_krad_h: f64,
74    /// Shield life remaining at departure = budget/rate (hours)
75    pub shield_life_at_departure_h: f64,
76    /// Shield life remaining at arrival = (budget - accumulated)/rate (hours)
77    pub shield_life_at_arrival_h: f64,
78    /// Whether the shield survives the transit (total_dose < budget)
79    pub shield_survives: bool,
80    /// Fraction of dose accumulated in each region
81    pub region_dose_fractions: Vec<(f64, f64, f64)>, // (r_min, r_max, fraction)
82    /// Peak dose rate encountered (krad/hour)
83    pub peak_dose_rate_krad_h: f64,
84}
85
86impl JupiterRadiationConfig {
87    /// Create the default model calibrated to Galileo-era data.
88    ///
89    /// Dose rates in krad(Si)/hour behind 100 mil Al:
90    /// - Europa (~9.4 RJ): ~0.616 krad/h (= 5,400 krad/year)
91    /// - Ganymede (~15 RJ): ~0.0616 krad/h (= 540 krad/year)
92    /// - Callisto (~26 RJ): ~0.000114 krad/h (= 1 krad/year)
93    pub fn default_model() -> Self {
94        // Region 1: Inner belt (6-15 RJ)
95        // Calibrated: at 9.4 RJ ≈ 0.616 krad/h, at 15 RJ ≈ 0.0616 krad/h
96        // alpha = ln(0.616/0.0616) / ln(15/9.4) ≈ 2.303 / 0.468 ≈ 4.92
97        let inner = RadiationRegion {
98            r_min_rj: 6.0,
99            r_max_rj: 15.0,
100            d0_krad_per_hour: 0.616,
101            r0_rj: 9.4,
102            alpha: 4.92,
103        };
104
105        // Region 2: Middle magnetosphere (15-30 RJ)
106        // Calibrated: at 15 RJ ≈ 0.0616 krad/h, at 26 RJ ≈ 0.000114 krad/h
107        // alpha = ln(0.0616/0.000114) / ln(26/15) ≈ 6.29 / 0.549 ≈ 11.46
108        // This is very steep — Callisto orbit is much quieter than Ganymede.
109        // However, empirical data shows this steep falloff is real.
110        // Use a slightly moderated value to account for non-equatorial paths.
111        let middle = RadiationRegion {
112            r_min_rj: 15.0,
113            r_max_rj: 30.0,
114            d0_krad_per_hour: 0.0616,
115            r0_rj: 15.0,
116            alpha: 9.5,
117        };
118
119        // Region 3: Outer magnetosphere (30-63 RJ)
120        // Much lower dose rate, gradual falloff to magnetopause
121        // Extrapolate from middle region endpoint
122        let d_at_30 = middle.d0_krad_per_hour * (30.0 / middle.r0_rj).powf(-middle.alpha);
123        let outer = RadiationRegion {
124            r_min_rj: 30.0,
125            r_max_rj: 100.0,
126            d0_krad_per_hour: d_at_30,
127            r0_rj: 30.0,
128            alpha: 2.0,
129        };
130
131        JupiterRadiationConfig {
132            regions: vec![inner, middle, outer],
133            magnetopause_rj: 63.0, // Nominal subsolar magnetopause
134            latitude_factor: 1.0,
135            plasma_sheet_factor: 1.0,
136            storm_factor: 1.0,
137        }
138    }
139
140    /// Instantaneous dose rate at distance r (in RJ) in krad/hour.
141    ///
142    /// Returns 0 if r is beyond the magnetopause.
143    pub fn dose_rate_krad_h(&self, r_rj: f64) -> f64 {
144        if r_rj >= self.magnetopause_rj || r_rj < 0.0 {
145            return 0.0;
146        }
147
148        let geometry_mult = self.latitude_factor * self.plasma_sheet_factor * self.storm_factor;
149
150        for region in &self.regions {
151            if r_rj >= region.r_min_rj && r_rj < region.r_max_rj {
152                let rate = region.d0_krad_per_hour * (r_rj / region.r0_rj).powf(-region.alpha);
153                return rate * geometry_mult;
154            }
155        }
156
157        // Beyond all defined regions but inside magnetopause
158        0.0
159    }
160
161    /// Analytical dose integral for a single power-law region.
162    ///
163    /// For constant radial velocity v_r (km/s):
164    ///   Dose = ∫ D(r) dt = ∫ D(r) dr / v_r
165    ///        = (D0 * r0^alpha / v_r) * ∫[r1..r2] r^(-alpha) dr
166    ///
167    /// For alpha ≠ 1:
168    ///   ∫ r^(-alpha) dr = r^(1-alpha) / (1-alpha)
169    ///
170    /// Returns dose in krad. v_r in km/s, distances in RJ.
171    fn analytical_dose_segment(
172        region: &RadiationRegion,
173        r_start_rj: f64,
174        r_end_rj: f64,
175        v_radial_km_s: f64,
176    ) -> f64 {
177        if v_radial_km_s.abs() < 1e-10 {
178            return 0.0; // Avoid division by zero
179        }
180
181        let alpha = region.alpha;
182        let d0 = region.d0_krad_per_hour;
183        let r0 = region.r0_rj;
184
185        // Convert v_r from km/s to RJ/hour:
186        // 1 RJ = 71,492 km, 1 hour = 3600 s
187        // v_rj_per_h = v_km_s * 3600 / 71492
188        let v_rj_per_h = v_radial_km_s * 3600.0 / JUPITER_RADIUS_KM;
189
190        let (a, b) = if r_start_rj < r_end_rj {
191            (r_start_rj, r_end_rj) // outward
192        } else {
193            (r_end_rj, r_start_rj) // inward
194        };
195
196        let coeff = d0 * r0.powf(alpha) / v_rj_per_h.abs();
197
198        if (alpha - 1.0).abs() < 1e-10 {
199            // Special case: alpha ≈ 1
200            // ∫ r^(-1) dr = ln(r)
201            coeff * (b.ln() - a.ln())
202        } else {
203            // General case
204            let exp = 1.0 - alpha;
205            coeff * (b.powf(exp) - a.powf(exp)) / exp
206        }
207    }
208
209    /// Compute total accumulated dose along a radial transit.
210    ///
211    /// Assumes constant radial velocity (reasonable for hyperbolic escape
212    /// far from periapsis where gravity turn is small).
213    ///
214    /// # Arguments
215    /// * `r_start_rj` - Starting distance from Jupiter center (RJ)
216    /// * `r_end_rj` - Ending distance (RJ). Must be > r_start for outward.
217    /// * `v_radial_km_s` - Radial velocity component (km/s, positive = outward)
218    /// * `shield_budget_krad` - Total dose budget of the radiation shield (krad)
219    pub fn transit_analysis(
220        &self,
221        r_start_rj: f64,
222        r_end_rj: f64,
223        v_radial_km_s: f64,
224        shield_budget_krad: f64,
225    ) -> JupiterTransitResult {
226        let geometry_mult = self.latitude_factor * self.plasma_sheet_factor * self.storm_factor;
227
228        let mut total_dose = 0.0;
229        let mut region_doses: Vec<(f64, f64, f64)> = Vec::new();
230
231        // Clamp end to magnetopause
232        let effective_end = if r_end_rj > self.magnetopause_rj {
233            self.magnetopause_rj
234        } else {
235            r_end_rj
236        };
237
238        for region in &self.regions {
239            // Compute overlap between transit path and this region
240            let seg_start = r_start_rj.max(region.r_min_rj);
241            let seg_end = effective_end.min(region.r_max_rj);
242
243            if seg_start < seg_end {
244                let dose = Self::analytical_dose_segment(region, seg_start, seg_end, v_radial_km_s)
245                    * geometry_mult;
246                total_dose += dose;
247                region_doses.push((seg_start, seg_end, dose));
248            }
249        }
250
251        // Convert absolute doses to fractions
252        let region_fractions: Vec<(f64, f64, f64)> = region_doses
253            .iter()
254            .map(|(a, b, d)| {
255                (
256                    *a,
257                    *b,
258                    if total_dose > 0.0 {
259                        d / total_dose
260                    } else {
261                        0.0
262                    },
263                )
264            })
265            .collect();
266
267        let departure_rate = self.dose_rate_krad_h(r_start_rj);
268        let arrival_rate = self.dose_rate_krad_h(r_end_rj.min(self.magnetopause_rj));
269        let peak_rate = departure_rate.max(arrival_rate); // For outward transit, peak is at start
270
271        let shield_life_at_departure = if departure_rate > 0.0 {
272            shield_budget_krad / departure_rate
273        } else {
274            f64::INFINITY
275        };
276
277        let remaining_budget = shield_budget_krad - total_dose;
278        let shield_life_at_arrival = if arrival_rate > 0.0 && remaining_budget > 0.0 {
279            remaining_budget / arrival_rate
280        } else if remaining_budget <= 0.0 {
281            0.0
282        } else {
283            f64::INFINITY
284        };
285
286        JupiterTransitResult {
287            total_dose_krad: total_dose,
288            departure_dose_rate_krad_h: departure_rate,
289            arrival_dose_rate_krad_h: arrival_rate,
290            shield_life_at_departure_h: shield_life_at_departure,
291            shield_life_at_arrival_h: shield_life_at_arrival,
292            shield_survives: total_dose < shield_budget_krad,
293            region_dose_fractions: region_fractions,
294            peak_dose_rate_krad_h: peak_rate,
295        }
296    }
297}
298
299/// EP02 scenario: Kestrel's Jupiter escape from Ganymede orbit.
300///
301/// Parameters from the episode:
302/// - Starts at ~15 RJ (Ganymede orbit)
303/// - Reaches 50 RJ with Jupiter-frame velocity 10.3 km/s
304/// - Shield remaining life: 42 minutes at departure
305///
306/// Key finding: "42 minutes remaining at current rate" translates to a dose
307/// budget of D_rate × 42/60 hours. Whether the shield survives depends on
308/// how fast the ship moves through the belt. At 7 km/s radial (passive escape),
309/// the transit takes ~99 hours and the shield fails. At higher velocities
310/// (active brachistochrone escape), the shield can survive.
311///
312/// Returns results for multiple velocity/geometry scenarios.
313pub fn ep02_jupiter_escape_analysis() -> Vec<(&'static str, JupiterTransitResult)> {
314    let config = JupiterRadiationConfig::default_model();
315    let departure_rate = config.dose_rate_krad_h(15.0);
316    let shield_budget_42min = departure_rate * (42.0 / 60.0);
317
318    let mut results = Vec::new();
319
320    // Scenario 1: Passive escape at 7 km/s radial (hyperbolic coast)
321    // Transit time: ~99 hours. Shield FAILS.
322    let result_passive = config.transit_analysis(15.0, 50.0, 7.0, shield_budget_42min);
323    results.push(("弾道脱出 7 km/s", result_passive));
324
325    // Scenario 2: Active brachistochrone escape at ~60 km/s average radial
326    // If the ship uses brachistochrone (32.7 m/s² from EP01 analysis),
327    // the radial velocity increases rapidly. Effective average ~60 km/s.
328    // Transit time: ~12 hours. Shield survives.
329    let result_brach = config.transit_analysis(15.0, 50.0, 60.0, shield_budget_42min);
330    results.push(("加速脱出 60 km/s", result_brach));
331
332    // Scenario 3: Moderate acceleration at 20 km/s average
333    // Represents a damaged-ship scenario with reduced thrust
334    let result_moderate = config.transit_analysis(15.0, 50.0, 20.0, shield_budget_42min);
335    results.push(("損傷状態 20 km/s", result_moderate));
336
337    // Scenario 4: High-latitude escape at 7 km/s (reduced equatorial dose)
338    let mut lat_config = JupiterRadiationConfig::default_model();
339    lat_config.latitude_factor = 0.15; // Well above equatorial plane
340    let result_lat = lat_config.transit_analysis(15.0, 50.0, 7.0, shield_budget_42min);
341    results.push(("高緯度脱出 7 km/s", result_lat));
342
343    results
344}
345
346/// Find the minimum average radial velocity for shield survival.
347///
348/// Binary searches for the threshold velocity where the 42-minute
349/// shield budget is exactly depleted during transit from r_start to r_end.
350pub fn minimum_survival_velocity(
351    config: &JupiterRadiationConfig,
352    r_start_rj: f64,
353    r_end_rj: f64,
354    shield_budget_krad: f64,
355) -> f64 {
356    let mut v_low = 1.0_f64;
357    let mut v_high = 200.0_f64;
358    for _ in 0..60 {
359        let v_mid = (v_low + v_high) / 2.0;
360        let result = config.transit_analysis(r_start_rj, r_end_rj, v_mid, shield_budget_krad);
361        if result.shield_survives {
362            v_high = v_mid;
363        } else {
364            v_low = v_mid;
365        }
366    }
367    (v_low + v_high) / 2.0
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_default_model_creation() {
376        let config = JupiterRadiationConfig::default_model();
377        assert_eq!(config.regions.len(), 3);
378        assert_eq!(config.magnetopause_rj, 63.0);
379        assert_eq!(config.latitude_factor, 1.0);
380    }
381
382    #[test]
383    fn test_dose_rate_at_europa_order_of_magnitude() {
384        // Europa (~9.4 RJ): ~0.616 krad/h (calibration point)
385        let config = JupiterRadiationConfig::default_model();
386        let rate = config.dose_rate_krad_h(9.4);
387        // Should be close to the calibration value
388        assert!(
389            (rate - 0.616).abs() < 0.01,
390            "Europa rate = {rate:.4} krad/h (expected ~0.616)"
391        );
392    }
393
394    #[test]
395    fn test_dose_rate_at_ganymede_order_of_magnitude() {
396        // Ganymede (~15 RJ): ~0.0616 krad/h
397        let config = JupiterRadiationConfig::default_model();
398        let rate = config.dose_rate_krad_h(15.0);
399        // The inner region ends at 15 RJ; check boundary behavior
400        // At exactly 15 RJ, we're at the boundary — check the middle region
401        // Middle region has d0=0.0616 at r0=15.0, so rate should be 0.0616
402        assert!(
403            (rate - 0.0616).abs() < 0.005,
404            "Ganymede rate = {rate:.6} krad/h (expected ~0.0616)"
405        );
406    }
407
408    #[test]
409    fn test_dose_rate_decreases_outward() {
410        let config = JupiterRadiationConfig::default_model();
411        let rate_15 = config.dose_rate_krad_h(15.0);
412        let rate_20 = config.dose_rate_krad_h(20.0);
413        let rate_30 = config.dose_rate_krad_h(30.0);
414        let rate_50 = config.dose_rate_krad_h(50.0);
415        assert!(
416            rate_15 > rate_20,
417            "rate should decrease: 15 RJ ({rate_15}) > 20 RJ ({rate_20})"
418        );
419        assert!(
420            rate_20 > rate_30,
421            "rate should decrease: 20 RJ ({rate_20}) > 30 RJ ({rate_30})"
422        );
423        assert!(
424            rate_30 > rate_50,
425            "rate should decrease: 30 RJ ({rate_30}) > 50 RJ ({rate_50})"
426        );
427    }
428
429    #[test]
430    fn test_dose_rate_steep_falloff_ganymede_to_callisto() {
431        // From Ganymede (15 RJ) to Callisto (26 RJ), dose drops by ~500x
432        let config = JupiterRadiationConfig::default_model();
433        let rate_15 = config.dose_rate_krad_h(15.0);
434        let rate_26 = config.dose_rate_krad_h(26.0);
435        let ratio = rate_15 / rate_26;
436        // Empirically should be ~500x (5,400/1 in yearly terms)
437        assert!(
438            ratio > 100.0 && ratio < 2000.0,
439            "Ganymede/Callisto ratio = {ratio:.0}x (expected ~500x)"
440        );
441    }
442
443    #[test]
444    fn test_dose_rate_zero_beyond_magnetopause() {
445        let config = JupiterRadiationConfig::default_model();
446        assert_eq!(config.dose_rate_krad_h(100.0), 0.0);
447        assert_eq!(config.dose_rate_krad_h(63.0), 0.0);
448        assert_eq!(config.dose_rate_krad_h(63.1), 0.0);
449    }
450
451    #[test]
452    fn test_dose_rate_negative_distance() {
453        let config = JupiterRadiationConfig::default_model();
454        assert_eq!(config.dose_rate_krad_h(-1.0), 0.0);
455    }
456
457    #[test]
458    fn test_geometry_multipliers() {
459        let mut config = JupiterRadiationConfig::default_model();
460        let base_rate = config.dose_rate_krad_h(15.0);
461
462        config.storm_factor = 2.0;
463        let storm_rate = config.dose_rate_krad_h(15.0);
464        assert!(
465            (storm_rate - 2.0 * base_rate).abs() < 1e-10,
466            "Storm factor should double rate"
467        );
468
469        config.storm_factor = 1.0;
470        config.latitude_factor = 0.3;
471        let lat_rate = config.dose_rate_krad_h(15.0);
472        assert!(
473            (lat_rate - 0.3 * base_rate).abs() < 1e-10,
474            "Latitude factor should scale rate"
475        );
476    }
477
478    #[test]
479    fn test_analytical_dose_nonzero() {
480        let config = JupiterRadiationConfig::default_model();
481        let result = config.transit_analysis(15.0, 50.0, 7.0, 100.0);
482        assert!(
483            result.total_dose_krad > 0.0,
484            "Total dose should be positive for transit through radiation belt"
485        );
486    }
487
488    #[test]
489    fn test_dose_dominated_by_inner_region() {
490        // Most dose should accumulate in the 15-30 RJ region (first segment)
491        let config = JupiterRadiationConfig::default_model();
492        let result = config.transit_analysis(15.0, 50.0, 7.0, 100.0);
493
494        assert!(!result.region_dose_fractions.is_empty());
495        // First region (15-30 RJ) should dominate
496        let first_fraction = result.region_dose_fractions[0].2;
497        assert!(
498            first_fraction > 0.9,
499            "Inner region should dominate dose: {first_fraction:.3} (expected > 0.9)"
500        );
501    }
502
503    #[test]
504    fn test_shield_42min_at_constant_position_exceeds_transit_dose() {
505        // Key finding: "42 minutes" at 15 RJ corresponds to a shield budget
506        // of 0.043 krad. The transit from 15→50 RJ at 7 km/s accumulates
507        // ~0.31 krad — about 7x more than the budget.
508        //
509        // This means the "42 minutes remaining" interpretation requires one of:
510        // (a) Much higher radial velocity (rapid brachistochrone escape)
511        // (b) High-latitude path reducing equatorial dose
512        // (c) The shield has a dynamic regeneration/cooling mechanism
513        //
514        // The analysis explores which interpretation is most plausible.
515        let config = JupiterRadiationConfig::default_model();
516        let departure_rate = config.dose_rate_krad_h(15.0);
517        let shield_budget_42min = departure_rate * (42.0 / 60.0);
518
519        let result = config.transit_analysis(15.0, 50.0, 7.0, shield_budget_42min);
520
521        // At constant 7 km/s, the shield does NOT survive
522        assert!(
523            !result.shield_survives,
524            "At 7 km/s, shield should NOT survive: dose={:.4} > budget={:.4}",
525            result.total_dose_krad, shield_budget_42min,
526        );
527
528        // Verify departure shield life is 42 min = 0.7 h
529        assert!(
530            (result.shield_life_at_departure_h - 0.7).abs() < 0.01,
531            "Departure shield life = {:.3} h (expected 0.7 h = 42 min)",
532            result.shield_life_at_departure_h,
533        );
534    }
535
536    #[test]
537    fn test_shield_survives_with_higher_velocity() {
538        // At ~51 km/s average radial velocity, transit time drops enough
539        // for the shield to survive with a 42-minute budget.
540        // This is physically achievable with brachistochrone thrust (32.7 m/s²).
541        let config = JupiterRadiationConfig::default_model();
542        let departure_rate = config.dose_rate_krad_h(15.0);
543        let shield_budget_42min = departure_rate * (42.0 / 60.0);
544
545        // Find the threshold
546        let threshold = minimum_survival_velocity(&config, 15.0, 50.0, shield_budget_42min);
547
548        // At threshold + margin, shield survives
549        let result = config.transit_analysis(15.0, 50.0, threshold + 5.0, shield_budget_42min);
550        assert!(
551            result.shield_survives,
552            "Above threshold ({:.1} km/s), shield should survive: dose={:.6}",
553            threshold, result.total_dose_krad,
554        );
555
556        // Threshold should be ~50 km/s range
557        assert!(
558            threshold > 30.0 && threshold < 80.0,
559            "Threshold = {threshold:.1} km/s (expected 30-80)"
560        );
561    }
562
563    #[test]
564    fn test_shield_survives_high_latitude() {
565        // High-latitude escape (30% of equatorial dose) with moderate velocity
566        let mut config = JupiterRadiationConfig::default_model();
567        config.latitude_factor = 0.3;
568        let departure_rate = config.dose_rate_krad_h(15.0);
569        let shield_budget_42min = departure_rate * (42.0 / 60.0);
570
571        let result = config.transit_analysis(15.0, 50.0, 7.0, shield_budget_42min);
572        // High-latitude reduces accumulated dose by 0.3x, same as budget
573        // so this is still marginal
574        assert!(
575            !result.shield_survives || result.total_dose_krad < shield_budget_42min * 1.1,
576            "High-latitude should be marginal: dose={:.6}, budget={:.6}",
577            result.total_dose_krad,
578            shield_budget_42min,
579        );
580    }
581
582    #[test]
583    fn test_ep02_scenarios_run() {
584        let results = ep02_jupiter_escape_analysis();
585        assert_eq!(results.len(), 4);
586        for (label, result) in &results {
587            assert!(
588                result.total_dose_krad > 0.0,
589                "Scenario '{label}' should have positive dose"
590            );
591        }
592
593        // Scenario 1 (7 km/s passive): shield fails
594        assert!(
595            !results[0].1.shield_survives,
596            "Passive escape should NOT survive"
597        );
598        // Scenario 2 (50 km/s brachistochrone): shield survives
599        assert!(
600            results[1].1.shield_survives,
601            "Brachistochrone escape should survive"
602        );
603    }
604
605    #[test]
606    fn test_minimum_velocity_for_shield_survival() {
607        // Find the minimum radial velocity where the shield (42 min budget) survives
608        let config = JupiterRadiationConfig::default_model();
609        let departure_rate = config.dose_rate_krad_h(15.0);
610        let shield_budget = departure_rate * (42.0 / 60.0);
611
612        // Binary search for threshold velocity
613        let mut v_low = 5.0_f64;
614        let mut v_high = 100.0_f64;
615        for _ in 0..50 {
616            let v_mid = (v_low + v_high) / 2.0;
617            let result = config.transit_analysis(15.0, 50.0, v_mid, shield_budget);
618            if result.shield_survives {
619                v_high = v_mid;
620            } else {
621                v_low = v_mid;
622            }
623        }
624        let threshold_v = (v_low + v_high) / 2.0;
625
626        // Threshold should be in a physically meaningful range
627        assert!(
628            threshold_v > 10.0 && threshold_v < 80.0,
629            "Threshold velocity = {threshold_v:.1} km/s (should be 10-80 km/s)"
630        );
631    }
632
633    #[test]
634    fn test_transit_time_physical() {
635        // Verify transit time is physically reasonable
636        // 15 → 50 RJ at 7 km/s radial
637        let dist_km = (50.0 - 15.0) * JUPITER_RADIUS_KM;
638        let time_h = dist_km / 7.0 / 3600.0;
639        // Should be ~99.4 hours
640        assert!(
641            (time_h - 99.4).abs() < 1.0,
642            "Transit time = {time_h:.1} h (expected ~99.4 h)"
643        );
644    }
645
646    #[test]
647    fn test_dose_rate_continuity_at_boundaries() {
648        // Check approximate continuity at region boundaries
649        let config = JupiterRadiationConfig::default_model();
650
651        // At 15 RJ boundary (inner → middle)
652        let rate_inner = config.regions[0].d0_krad_per_hour
653            * (14.999 / config.regions[0].r0_rj).powf(-config.regions[0].alpha);
654        let rate_middle = config.dose_rate_krad_h(15.0);
655        // These may not match perfectly due to calibration approach,
656        // but should be within order of magnitude
657        let ratio = rate_inner / rate_middle;
658        assert!(
659            ratio > 0.5 && ratio < 2.0,
660            "15 RJ boundary discontinuity: inner={rate_inner:.4} vs middle={rate_middle:.4} (ratio={ratio:.2})"
661        );
662    }
663
664    #[test]
665    fn test_zero_velocity_returns_zero_dose() {
666        let config = JupiterRadiationConfig::default_model();
667        let result = config.transit_analysis(15.0, 50.0, 0.0, 100.0);
668        assert_eq!(result.total_dose_krad, 0.0);
669    }
670
671    #[test]
672    fn test_higher_velocity_means_lower_dose() {
673        // Faster transit → less time in radiation → less dose
674        let config = JupiterRadiationConfig::default_model();
675        let result_slow = config.transit_analysis(15.0, 50.0, 5.0, 100.0);
676        let result_fast = config.transit_analysis(15.0, 50.0, 10.0, 100.0);
677        assert!(
678            result_slow.total_dose_krad > result_fast.total_dose_krad,
679            "Slower transit should accumulate more dose: slow={:.4} > fast={:.4}",
680            result_slow.total_dose_krad,
681            result_fast.total_dose_krad,
682        );
683    }
684
685    #[test]
686    fn test_dose_rate_continuity_at_30rj_boundary() {
687        // Check approximate continuity at the 30 RJ boundary (middle → outer)
688        let config = JupiterRadiationConfig::default_model();
689
690        // Rate just inside 30 RJ (middle region)
691        let rate_just_below = config.dose_rate_krad_h(29.999);
692        // Rate at exactly 30 RJ (outer region)
693        let rate_at_30 = config.dose_rate_krad_h(30.0);
694
695        // The outer region's d0 is calibrated to the middle region's endpoint,
696        // so these should be nearly identical
697        let ratio = rate_just_below / rate_at_30;
698        assert!(
699            ratio > 0.5 && ratio < 2.0,
700            "30 RJ boundary: below={rate_just_below:.8} vs at={rate_at_30:.8} (ratio={ratio:.3})"
701        );
702    }
703
704    #[test]
705    fn test_shield_life_increases_outward() {
706        // For outward transit, arrival is farther from Jupiter where dose rate is lower.
707        // So shield_life_at_arrival_h should be > shield_life_at_departure_h
708        // (more hours remain because the dose rate dropped AND budget was consumed slowly)
709        let config = JupiterRadiationConfig::default_model();
710        let departure_rate = config.dose_rate_krad_h(15.0);
711        let large_budget = departure_rate * 10.0; // 10 hours budget — plenty
712
713        let result = config.transit_analysis(15.0, 50.0, 60.0, large_budget);
714
715        // With a large budget and fast transit, shield life at arrival should increase
716        // because the dose rate at 50 RJ is much lower than at 15 RJ
717        assert!(
718            result.shield_life_at_arrival_h > result.shield_life_at_departure_h,
719            "Shield life should increase outward: arrival={:.2}h > departure={:.2}h",
720            result.shield_life_at_arrival_h,
721            result.shield_life_at_departure_h,
722        );
723    }
724
725    #[test]
726    fn test_dose_inversely_proportional_to_velocity() {
727        // For a constant radial path, dose ∝ transit_time ∝ 1/velocity
728        // So halving the velocity should approximately double the dose
729        let config = JupiterRadiationConfig::default_model();
730        let result_10 = config.transit_analysis(15.0, 50.0, 10.0, 100.0);
731        let result_20 = config.transit_analysis(15.0, 50.0, 20.0, 100.0);
732
733        // dose at v=10 should be ~2x dose at v=20
734        let ratio = result_10.total_dose_krad / result_20.total_dose_krad;
735        assert!(
736            (ratio - 2.0).abs() < 0.1,
737            "Dose ratio (v=10/v=20) = {ratio:.3} (expected ~2.0)"
738        );
739    }
740
741    /// Print full analysis report (run with --nocapture to see output).
742    /// This test always passes — it's used to generate report data.
743    #[test]
744    fn test_print_analysis_report() {
745        let config = JupiterRadiationConfig::default_model();
746        let dep_rate = config.dose_rate_krad_h(15.0);
747        let budget = dep_rate * (42.0 / 60.0);
748        let threshold = minimum_survival_velocity(&config, 15.0, 50.0, budget);
749
750        eprintln!("=== EP02 Jupiter Radiation Analysis ===");
751        eprintln!("Departure rate at 15 RJ: {dep_rate:.6} krad/h");
752        eprintln!("Shield budget (42 min): {budget:.6} krad");
753        eprintln!("Min survival velocity: {threshold:.1} km/s");
754
755        let results = ep02_jupiter_escape_analysis();
756        for (label, r) in &results {
757            eprintln!("\n--- {label} ---");
758            eprintln!(
759                "  Dose: {:.6} krad, Survives: {}",
760                r.total_dose_krad, r.shield_survives
761            );
762            eprintln!(
763                "  Dep rate: {:.6} krad/h, Arr rate: {:.10} krad/h",
764                r.departure_dose_rate_krad_h, r.arrival_dose_rate_krad_h
765            );
766            eprintln!(
767                "  Life@dep: {:.2}h ({:.1}min), Life@arr: {:.1}h",
768                r.shield_life_at_departure_h,
769                r.shield_life_at_departure_h * 60.0,
770                r.shield_life_at_arrival_h
771            );
772        }
773
774        eprintln!("\n=== Dose Rate Profile ===");
775        for r in [10, 12, 15, 17, 20, 25, 30, 40, 50, 63] {
776            let rate = config.dose_rate_krad_h(r as f64);
777            eprintln!("  {r:3} RJ: {rate:.8} krad/h");
778        }
779    }
780}