Skip to main content

lox_analysis/
power.rs

1// SPDX-FileCopyrightText: 2026 Helge Eichhorn <git@helgeeichhorn.de>
2//
3// SPDX-License-Identifier: MPL-2.0
4
5//! Power budget analysis: sun beta angle, eclipse detection, solar flux.
6//!
7//! The shadow model uses cylindrical (umbra-only) geometry via the existing
8//! [`line_of_sight`](crate::visibility::line_of_sight) function.  Penumbra is
9//! **not** modelled.
10
11use std::collections::HashMap;
12
13use lox_bodies::{DynOrigin, Origin, Sun, TryMeanRadius, TrySpheroid};
14use lox_core::glam::DVec3;
15use lox_core::math::series::InterpolationType;
16use lox_core::units::ASTRONOMICAL_UNIT;
17use lox_ephem::Ephemeris;
18use lox_time::Time;
19use lox_time::deltas::TimeDelta;
20use lox_time::intervals::{self, TimeInterval};
21use lox_time::series::TimeSeries;
22use lox_time::time_scales::{Tai, Tdb};
23use rayon::prelude::*;
24use thiserror::Error;
25
26use crate::assets::{AssetId, ConstellationId, Scenario, Spacecraft};
27use crate::visibility::{EvalError, LineOfSight};
28use lox_frames::ReferenceFrame;
29use lox_orbits::events::{DetectFn, EventsToIntervals, IntervalDetector, RootFindingDetector};
30use lox_orbits::orbits::{Ensemble, Trajectory};
31
32// ---------------------------------------------------------------------------
33// Constants
34// ---------------------------------------------------------------------------
35
36/// Solar constant at 1 AU in W/m².
37pub const SOLAR_CONSTANT: f64 = 1361.0;
38
39// ---------------------------------------------------------------------------
40// Pure geometry functions
41// ---------------------------------------------------------------------------
42
43/// Computes the Sun beta angle — the angle between the orbit plane and the
44/// Sun direction.
45///
46/// Returns radians in \[-π/2, π/2\].
47///
48/// # Arguments
49/// * `orbit_normal` — unit normal of the orbital plane (`(r × v).normalize()`)
50/// * `sun_direction` — unit vector towards the Sun
51pub fn beta_angle(orbit_normal: DVec3, sun_direction: DVec3) -> f64 {
52    orbit_normal.dot(sun_direction).clamp(-1.0, 1.0).asin()
53}
54
55/// Computes the solar flux at the given distance from the Sun.
56///
57/// Returns W/m² using the inverse-square law relative to
58/// [`SOLAR_CONSTANT`] at 1 AU.
59///
60/// # Arguments
61/// * `distance_m` — distance from the Sun in **meters**
62pub fn solar_flux(distance_m: f64) -> f64 {
63    let ratio = ASTRONOMICAL_UNIT / distance_m;
64    SOLAR_CONSTANT * ratio * ratio
65}
66
67// ---------------------------------------------------------------------------
68// Eclipse DetectFn
69// ---------------------------------------------------------------------------
70
71/// Per-spacecraft power-budget output tuple.
72type SpacecraftPowerData = (
73    AssetId,
74    Vec<TimeInterval<Tai>>,
75    TimeSeries<Tai>,
76    TimeSeries<Tai>,
77);
78
79/// Errors from power-budget analysis.
80#[derive(Debug, Error)]
81pub enum PowerError {
82    /// Event detection failed.
83    #[error(transparent)]
84    Detect(#[from] lox_orbits::events::DetectError),
85    /// Evaluation error (frame rotation, ephemeris, …).
86    #[error(transparent)]
87    Eval(#[from] EvalError),
88}
89
90/// Eclipse detect function: positive when the spacecraft is sunlit, negative
91/// when it is in eclipse (cylindrical shadow model, umbra only).
92struct EclipseDetectFn<'a, O: Origin, R: ReferenceFrame, E> {
93    sc: &'a Trajectory<Tai, O, R>,
94    ephemeris: &'a E,
95}
96
97impl<O, R, E: Ephemeris> DetectFn<Tai> for EclipseDetectFn<'_, O, R, E>
98where
99    O: TrySpheroid + TryMeanRadius + Copy,
100    R: ReferenceFrame + Copy,
101    E::Error: 'static,
102{
103    type Error = EvalError;
104
105    fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
106        let tdb = time.to_scale(Tdb);
107        let r_sun = self
108            .ephemeris
109            .position(tdb, self.sc.origin(), Sun)
110            .map_err(|e| EvalError::Ephemeris(Box::new(e)))?;
111        let r_sc = self.sc.interpolate_at(time).position();
112        // line_of_sight returns positive when the two vectors have mutual LOS
113        // (spacecraft is sunlit) and negative when occluded (eclipse).
114        Ok(self.sc.origin().line_of_sight(r_sc, r_sun)?)
115    }
116}
117
118// ---------------------------------------------------------------------------
119// PowerBudgetResults
120// ---------------------------------------------------------------------------
121
122/// Results of a power-budget analysis.
123///
124/// Contains eclipse intervals, beta-angle time series, and solar-flux time
125/// series for each spacecraft.
126pub struct PowerBudgetResults {
127    eclipse_intervals: HashMap<AssetId, Vec<TimeInterval<Tai>>>,
128    beta_angles: HashMap<AssetId, TimeSeries<Tai>>,
129    solar_fluxes: HashMap<AssetId, TimeSeries<Tai>>,
130    scenario_duration: f64,
131}
132
133impl PowerBudgetResults {
134    /// Eclipse intervals for a given spacecraft.
135    pub fn eclipse_intervals_for(&self, id: &AssetId) -> Option<&[TimeInterval<Tai>]> {
136        self.eclipse_intervals.get(id).map(|v| v.as_slice())
137    }
138
139    /// All eclipse intervals keyed by spacecraft id.
140    pub fn all_eclipse_intervals(&self) -> &HashMap<AssetId, Vec<TimeInterval<Tai>>> {
141        &self.eclipse_intervals
142    }
143
144    /// Eclipse fraction for a given spacecraft (ratio of total eclipse time to
145    /// scenario duration, in \[0, 1\]).
146    pub fn eclipse_fraction(&self, id: &AssetId) -> Option<f64> {
147        let intervals = self.eclipse_intervals.get(id)?;
148        let total_eclipse: f64 = intervals
149            .iter()
150            .map(|i| (i.end() - i.start()).to_seconds().to_f64())
151            .sum();
152        Some(total_eclipse / self.scenario_duration)
153    }
154
155    /// Sunlit fraction for a given spacecraft (`1 − eclipse_fraction`).
156    pub fn sunlit_fraction(&self, id: &AssetId) -> Option<f64> {
157        self.eclipse_fraction(id).map(|f| 1.0 - f)
158    }
159
160    /// Beta-angle time series for a given spacecraft (radians).
161    pub fn beta_angles_for(&self, id: &AssetId) -> Option<&TimeSeries<Tai>> {
162        self.beta_angles.get(id)
163    }
164
165    /// Solar-flux time series for a given spacecraft (W/m²).
166    pub fn solar_flux_for(&self, id: &AssetId) -> Option<&TimeSeries<Tai>> {
167        self.solar_fluxes.get(id)
168    }
169}
170
171// ---------------------------------------------------------------------------
172// PowerBudgetAnalysis
173// ---------------------------------------------------------------------------
174
175/// Filter for restricting which spacecraft are analysed.
176#[derive(Clone)]
177pub enum SpacecraftFilter {
178    /// Analyse only spacecraft whose id is in the given list.
179    Ids(Vec<AssetId>),
180    /// Analyse only spacecraft belonging to the given constellation.
181    Constellation(ConstellationId),
182}
183
184/// Computes eclipse intervals, beta angles, and solar flux for spacecraft
185/// in a scenario.
186///
187/// Generic over origin `O`, reference frame `R`, and ephemeris `E`.
188/// The shadow model is cylindrical (umbra only) — penumbra is not modelled.
189pub struct PowerBudgetAnalysis<'a, O: Origin, R: ReferenceFrame, E> {
190    scenario: &'a Scenario<O, R>,
191    ensemble: &'a Ensemble<AssetId, Tai, O, R>,
192    ephemeris: &'a E,
193    step: TimeDelta,
194    filter: Option<SpacecraftFilter>,
195}
196
197impl<'a, O, R, E> PowerBudgetAnalysis<'a, O, R, E>
198where
199    O: TrySpheroid + TryMeanRadius + Copy + Send + Sync + Into<DynOrigin>,
200    R: ReferenceFrame + Copy + Send + Sync,
201    E: Ephemeris + Send + Sync,
202    E::Error: 'static,
203{
204    /// Creates a new power-budget analysis.
205    pub fn new(
206        scenario: &'a Scenario<O, R>,
207        ensemble: &'a Ensemble<AssetId, Tai, O, R>,
208        ephemeris: &'a E,
209    ) -> Self {
210        Self {
211            scenario,
212            ensemble,
213            ephemeris,
214            step: TimeDelta::from_seconds(60),
215            filter: None,
216        }
217    }
218
219    /// Sets the time step for sampling and event detection.
220    pub fn with_step(mut self, step: TimeDelta) -> Self {
221        self.step = step;
222        self
223    }
224
225    /// Restricts the analysis to a subset of spacecraft.
226    ///
227    /// See [`SpacecraftFilter`] for the available filter modes.
228    pub fn with_filter(mut self, filter: SpacecraftFilter) -> Self {
229        self.filter = Some(filter);
230        self
231    }
232
233    /// Compute the power-budget analysis for all (or filtered) spacecraft in
234    /// the scenario.
235    pub fn compute(&self) -> Result<PowerBudgetResults, PowerError> {
236        let interval = *self.scenario.interval();
237        let all_spacecraft = self.scenario.spacecraft();
238        let spacecraft: Vec<&Spacecraft> = match &self.filter {
239            Some(SpacecraftFilter::Ids(ids)) => all_spacecraft
240                .iter()
241                .filter(|sc| ids.contains(sc.id()))
242                .collect(),
243            Some(SpacecraftFilter::Constellation(cid)) => all_spacecraft
244                .iter()
245                .filter(|sc| sc.constellation_id() == Some(cid))
246                .collect(),
247            None => all_spacecraft.iter().collect(),
248        };
249        let duration_s = (interval.end() - interval.start()).to_seconds().to_f64();
250
251        let results: Result<Vec<_>, PowerError> = spacecraft
252            .par_iter()
253            .map(|sc| self.compute_spacecraft(sc, interval))
254            .collect();
255
256        let mut eclipse_intervals = HashMap::new();
257        let mut beta_angles = HashMap::new();
258        let mut solar_fluxes = HashMap::new();
259
260        for (id, eclipses, betas, fluxes) in results? {
261            eclipse_intervals.insert(id.clone(), eclipses);
262            beta_angles.insert(id.clone(), betas);
263            solar_fluxes.insert(id, fluxes);
264        }
265
266        Ok(PowerBudgetResults {
267            eclipse_intervals,
268            beta_angles,
269            solar_fluxes,
270            scenario_duration: duration_s,
271        })
272    }
273
274    /// Compute all quantities for a single spacecraft.
275    fn compute_spacecraft(
276        &self,
277        sc: &Spacecraft,
278        interval: TimeInterval<Tai>,
279    ) -> Result<SpacecraftPowerData, PowerError> {
280        let sc_traj = self.ensemble.get(sc.id()).expect(
281            "trajectory not found in ensemble; did you forget to propagate this spacecraft?",
282        );
283
284        // 1. Eclipse intervals via root-finding
285        let eclipse_fn = EclipseDetectFn {
286            sc: sc_traj,
287            ephemeris: self.ephemeris,
288        };
289        let detector = RootFindingDetector::new(eclipse_fn, self.step);
290        // EventsToIntervals gives intervals where the function is positive
291        // (sunlit). We need the complement → eclipse intervals.
292        let sunlit_intervals = EventsToIntervals::new(detector).detect(interval)?;
293
294        // Complement sunlit intervals to get eclipse intervals
295        let eclipse_intervals = intervals::complement_intervals(&sunlit_intervals, interval);
296
297        // 2. Beta angle + solar flux sampled at `step`
298        let epoch = interval.start();
299        let mut offsets = Vec::new();
300        let mut beta_values = Vec::new();
301        let mut flux_values = Vec::new();
302
303        for time in interval.step_by(self.step) {
304            let tdb = time.to_scale(Tdb);
305            let state = sc_traj.interpolate_at(time);
306            let r = state.position();
307            let v = state.velocity();
308            let h = r.cross(v);
309            let h_hat = h.normalize();
310
311            let r_sun = self
312                .ephemeris
313                .position(tdb, sc_traj.origin(), Sun)
314                .map_err(|e| PowerError::Eval(EvalError::Ephemeris(Box::new(e))))?;
315            let sun_hat = r_sun.normalize();
316
317            offsets.push((time - epoch).to_seconds().to_f64());
318            beta_values.push(beta_angle(h_hat, sun_hat));
319            flux_values.push(solar_flux(r_sun.length()));
320        }
321
322        let beta_series = TimeSeries::try_new(
323            epoch,
324            offsets.clone(),
325            beta_values,
326            InterpolationType::Linear,
327        )
328        .expect("sampled series should have valid dimensions");
329        let flux_series =
330            TimeSeries::try_new(epoch, offsets, flux_values, InterpolationType::Linear)
331                .expect("sampled series should have valid dimensions");
332
333        Ok((sc.id().clone(), eclipse_intervals, beta_series, flux_series))
334    }
335}
336
337// ---------------------------------------------------------------------------
338// Tests
339// ---------------------------------------------------------------------------
340
341#[cfg(test)]
342mod tests {
343    use std::f64::consts::{FRAC_PI_2, PI};
344    use std::sync::OnceLock;
345
346    use lox_bodies::DynOrigin;
347    use lox_core::glam::DVec3;
348    use lox_ephem::spk::parser::Spk;
349    use lox_frames::DynFrame;
350    use lox_orbits::propagators::sgp4::{Elements, Sgp4};
351    use lox_orbits::propagators::{OrbitSource, Propagator};
352    use lox_test_utils::{assert_approx_eq, data_file};
353    use lox_time::intervals::Interval;
354
355    use super::*;
356
357    #[test]
358    fn test_beta_angle_sun_in_orbit_plane() {
359        // Sun in the orbit plane → beta = 0
360        let h = DVec3::Z;
361        let sun = DVec3::X;
362        assert_approx_eq!(beta_angle(h, sun), 0.0, atol <= 1e-15);
363    }
364
365    #[test]
366    fn test_beta_angle_sun_perpendicular() {
367        // Sun along orbit normal → beta = π/2
368        let h = DVec3::Z;
369        let sun = DVec3::Z;
370        assert_approx_eq!(beta_angle(h, sun), FRAC_PI_2, atol <= 1e-15);
371    }
372
373    #[test]
374    fn test_beta_angle_sun_opposite() {
375        // Sun opposite to orbit normal → beta = -π/2
376        let h = DVec3::Z;
377        let sun = -DVec3::Z;
378        assert_approx_eq!(beta_angle(h, sun), -FRAC_PI_2, atol <= 1e-15);
379    }
380
381    #[test]
382    fn test_beta_angle_45_degrees() {
383        let h = DVec3::Z;
384        let sun = DVec3::new(1.0, 0.0, 1.0).normalize();
385        assert_approx_eq!(beta_angle(h, sun), PI / 4.0, atol <= 1e-15);
386    }
387
388    #[test]
389    fn test_solar_flux_at_1au() {
390        assert_approx_eq!(solar_flux(ASTRONOMICAL_UNIT), SOLAR_CONSTANT, rtol <= 1e-10);
391    }
392
393    #[test]
394    fn test_solar_flux_inverse_square() {
395        let d = 2.0 * ASTRONOMICAL_UNIT;
396        assert_approx_eq!(solar_flux(d), SOLAR_CONSTANT / 4.0, rtol <= 1e-10);
397    }
398
399    #[test]
400    fn test_power_budget_integration() {
401        fn ephemeris() -> &'static Spk {
402            static EPHEMERIS: OnceLock<Spk> = OnceLock::new();
403            EPHEMERIS.get_or_init(|| Spk::from_file(data_file("spice/de440s.bsp")).unwrap())
404        }
405
406        // ISS in LEO — guaranteed multiple eclipses per day.
407        let iss = Elements::from_tle(
408            Some("ISS (ZARYA)".to_string()),
409            b"1 25544U 98067A   24170.37528350  .00016566  00000+0  30244-3 0  9996",
410            b"2 25544  51.6410 309.3890 0010444 339.5369 107.8830 15.49495945458731",
411        )
412        .unwrap();
413        let sgp4 = Sgp4::new(iss).unwrap();
414        let t0 = sgp4.time();
415        let t1 = t0 + TimeDelta::from_hours(24);
416        let sc_traj = sgp4
417            .with_step(TimeDelta::from_seconds(30))
418            .propagate(Interval::new(t0, t1))
419            .unwrap()
420            .into_dyn();
421
422        let tai_interval = TimeInterval::new(
423            sc_traj.start_time().to_scale(Tai),
424            sc_traj.end_time().to_scale(Tai),
425        );
426
427        let sc = Spacecraft::new("ISS", OrbitSource::Trajectory(sc_traj.clone()));
428        let scenario = Scenario::with_interval(tai_interval, DynOrigin::Earth, DynFrame::Icrf)
429            .with_spacecraft(std::slice::from_ref(&sc));
430
431        // Build ensemble
432        let (epoch, origin, frame, data) = sc_traj.into_parts();
433        let typed = Trajectory::from_parts(epoch.with_scale(Tai), origin, frame, data);
434        let mut map = HashMap::new();
435        map.insert(sc.id().clone(), typed);
436        let ensemble = Ensemble::new(map);
437
438        let spk = ephemeris();
439        let analysis = PowerBudgetAnalysis::new(&scenario, &ensemble, spk);
440        let results = analysis.compute().expect("power budget analysis");
441
442        // ISS completes ~15.5 orbits/day → expect roughly that many eclipses.
443        let eclipses = results
444            .eclipse_intervals_for(sc.id())
445            .expect("eclipse intervals");
446        assert!(
447            eclipses.len() >= 10,
448            "expected ≥10 eclipse intervals for ISS over 24h, got {}",
449            eclipses.len()
450        );
451
452        // Eclipse fraction for ISS is typically ~35%.
453        let eclipse_frac = results.eclipse_fraction(sc.id()).unwrap();
454        assert!(
455            (0.2..0.5).contains(&eclipse_frac),
456            "unexpected eclipse fraction: {eclipse_frac}"
457        );
458
459        let sunlit_frac = results.sunlit_fraction(sc.id()).unwrap();
460        assert_approx_eq!(eclipse_frac + sunlit_frac, 1.0, atol <= 1e-15);
461
462        let betas = results.beta_angles_for(sc.id()).expect("beta angles");
463        assert!(!betas.values().is_empty());
464        for &b in betas.values() {
465            assert!((-FRAC_PI_2..=FRAC_PI_2).contains(&b));
466        }
467
468        let fluxes = results.solar_flux_for(sc.id()).expect("solar flux");
469        assert!(!fluxes.values().is_empty());
470        for &f in fluxes.values() {
471            // Solar flux near Earth should be ~1361 W/m².
472            assert!(f > 1300.0 && f < 1420.0, "unexpected flux: {f}");
473        }
474    }
475}