1use 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
32pub const SOLAR_CONSTANT: f64 = 1361.0;
38
39pub fn beta_angle(orbit_normal: DVec3, sun_direction: DVec3) -> f64 {
52 orbit_normal.dot(sun_direction).clamp(-1.0, 1.0).asin()
53}
54
55pub fn solar_flux(distance_m: f64) -> f64 {
63 let ratio = ASTRONOMICAL_UNIT / distance_m;
64 SOLAR_CONSTANT * ratio * ratio
65}
66
67type SpacecraftPowerData = (
73 AssetId,
74 Vec<TimeInterval<Tai>>,
75 TimeSeries<Tai>,
76 TimeSeries<Tai>,
77);
78
79#[derive(Debug, Error)]
81pub enum PowerError {
82 #[error(transparent)]
84 Detect(#[from] lox_orbits::events::DetectError),
85 #[error(transparent)]
87 Eval(#[from] EvalError),
88}
89
90struct 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 Ok(self.sc.origin().line_of_sight(r_sc, r_sun)?)
115 }
116}
117
118pub 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 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 pub fn all_eclipse_intervals(&self) -> &HashMap<AssetId, Vec<TimeInterval<Tai>>> {
141 &self.eclipse_intervals
142 }
143
144 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 pub fn sunlit_fraction(&self, id: &AssetId) -> Option<f64> {
157 self.eclipse_fraction(id).map(|f| 1.0 - f)
158 }
159
160 pub fn beta_angles_for(&self, id: &AssetId) -> Option<&TimeSeries<Tai>> {
162 self.beta_angles.get(id)
163 }
164
165 pub fn solar_flux_for(&self, id: &AssetId) -> Option<&TimeSeries<Tai>> {
167 self.solar_fluxes.get(id)
168 }
169}
170
171#[derive(Clone)]
177pub enum SpacecraftFilter {
178 Ids(Vec<AssetId>),
180 Constellation(ConstellationId),
182}
183
184pub 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 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 pub fn with_step(mut self, step: TimeDelta) -> Self {
221 self.step = step;
222 self
223 }
224
225 pub fn with_filter(mut self, filter: SpacecraftFilter) -> Self {
229 self.filter = Some(filter);
230 self
231 }
232
233 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 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 let eclipse_fn = EclipseDetectFn {
286 sc: sc_traj,
287 ephemeris: self.ephemeris,
288 };
289 let detector = RootFindingDetector::new(eclipse_fn, self.step);
290 let sunlit_intervals = EventsToIntervals::new(detector).detect(interval)?;
293
294 let eclipse_intervals = intervals::complement_intervals(&sunlit_intervals, interval);
296
297 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#[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 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 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 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 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 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 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 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 assert!(f > 1300.0 && f < 1420.0, "unexpected flux: {f}");
473 }
474 }
475}