Skip to main content

nyx_space/io/
space_weather.rs

1/*
2    Nyx, blazing fast astrodynamics
3    Copyright (C) 2018-onwards Christopher Rabotin <christopher.rabotin@gmail.com>
4
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU Affero General Public License as published
7    by the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9
10    This program is distributed in the hope that it will be useful,
11    but WITHOUT ANY WARRANTY; without even the implied warranty of
12    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13    GNU Affero General Public License for more details.
14
15    You should have received a copy of the GNU Affero General Public License
16    along with this program.  If not, see <https://www.gnu.org/licenses/>.
17*/
18
19use crate::io::InputOutputError;
20use flate2::read::GzDecoder;
21use hifitime::prelude::*;
22use serde::{Deserialize, Serialize};
23use serde_dhall::{SimpleType, StaticType};
24use std::collections::{BTreeMap, HashMap};
25use std::fmt;
26use std::fs::File;
27use std::io::{BufRead, BufReader, Read};
28use std::path::Path;
29use std::str::FromStr;
30
31#[cfg(feature = "python")]
32use pyo3::prelude::*;
33#[cfg(feature = "python")]
34use std::path::PathBuf;
35
36/// Strategy for resolving missing predictive space weather parameters (F10.7, Ap, Kp).
37#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
38#[cfg_attr(feature = "python", pyclass(from_py_object, get_all))]
39pub enum StaticSpaceWeather {
40    /// Solar minimum conditions (F10.7 = 65.0 SFU, Ap = 4.0, Kp = 1.0).
41    SolarMinimum(),
42    /// 11-year solar cycle average (F10.7 = 130.0 SFU, Ap = 15.0, Kp = 3.0). Standard default.
43    SolarAverage(),
44    /// Sustained solar maximum conditions (F10.7 = 200.0 SFU, Ap = 30.0, Kp = 4.3).
45    SolarMaximum(),
46    /// Custom operator-defined fallback values for missing data.
47    Custom { f107: f64, ap: f64, kp: f64 },
48}
49
50impl Default for StaticSpaceWeather {
51    fn default() -> Self {
52        Self::SolarAverage()
53    }
54}
55
56impl StaticSpaceWeather {
57    /// Resolves the effective F10.7 solar flux [SFU].
58    pub fn resolve_f107(&self, value: Option<f64>) -> f64 {
59        value.unwrap_or(match self {
60            Self::SolarMinimum() => 65.0,
61            Self::SolarAverage() => 130.0,
62            Self::SolarMaximum() => 200.0,
63            Self::Custom { f107, .. } => *f107,
64        })
65    }
66
67    /// Resolves the effective Ap index.
68    pub fn resolve_ap(&self, value: Option<f64>) -> f64 {
69        value.unwrap_or(match self {
70            Self::SolarMinimum() => 4.0,
71            Self::SolarAverage() => 15.0,
72            Self::SolarMaximum() => 30.0,
73            Self::Custom { ap, .. } => *ap,
74        })
75    }
76
77    /// Resolves the effective Kp index (unscaled float, e.g., 3.0).
78    pub fn resolve_kp(&self, value: Option<f64>) -> f64 {
79        value.unwrap_or(match self {
80            Self::SolarMinimum() => 1.0,
81            Self::SolarAverage() => 3.0,
82            Self::SolarMaximum() => 4.3,
83            Self::Custom { kp, .. } => *kp,
84        })
85    }
86}
87
88/// Comprehensive representation of a single daily record in the CelesTrak Space Weather CSV.
89#[derive(Debug, Clone, Deserialize, Serialize, StaticType, PartialEq)]
90#[cfg_attr(feature = "python", pyclass(from_py_object, get_all))]
91pub struct RawSpaceWeatherRow {
92    #[serde(rename = "DATE")]
93    pub date: String,
94    #[serde(rename = "BSRN")]
95    pub bsrn: u32,
96    #[serde(rename = "ND")]
97    pub nd: u32,
98
99    // Kp Planetary Indices (Encoded as Kp * 10 in CelesTrak CSV)
100    #[serde(rename = "KP1")]
101    pub kp1: Option<f64>,
102    #[serde(rename = "KP2")]
103    pub kp2: Option<f64>,
104    #[serde(rename = "KP3")]
105    pub kp3: Option<f64>,
106    #[serde(rename = "KP4")]
107    pub kp4: Option<f64>,
108    #[serde(rename = "KP5")]
109    pub kp5: Option<f64>,
110    #[serde(rename = "KP6")]
111    pub kp6: Option<f64>,
112    #[serde(rename = "KP7")]
113    pub kp7: Option<f64>,
114    #[serde(rename = "KP8")]
115    pub kp8: Option<f64>,
116    #[serde(rename = "KP_SUM")]
117    pub kp_sum: Option<f64>,
118
119    // Ap Linear Indices
120    #[serde(rename = "AP1")]
121    pub ap1: Option<f64>,
122    #[serde(rename = "AP2")]
123    pub ap2: Option<f64>,
124    #[serde(rename = "AP3")]
125    pub ap3: Option<f64>,
126    #[serde(rename = "AP4")]
127    pub ap4: Option<f64>,
128    #[serde(rename = "AP5")]
129    pub ap5: Option<f64>,
130    #[serde(rename = "AP6")]
131    pub ap6: Option<f64>,
132    #[serde(rename = "AP7")]
133    pub ap7: Option<f64>,
134    #[serde(rename = "AP8")]
135    pub ap8: Option<f64>,
136    #[serde(rename = "AP_AVG")]
137    pub ap_avg: Option<f64>,
138
139    // Geophysical & Solar Indicators
140    #[serde(rename = "CP")]
141    pub cp: Option<f64>,
142    #[serde(rename = "C9")]
143    pub c9: Option<u16>,
144    #[serde(rename = "ISN")]
145    pub isn: Option<u32>,
146
147    // Solar Radio Flux (10.7 cm) - Observed & Adjusted
148    #[serde(rename = "F10.7_OBS")]
149    pub f107_obs: f64,
150    #[serde(rename = "F10.7_ADJ")]
151    pub f107_adj: f64,
152    #[serde(rename = "F10.7_DATA_TYPE")]
153    pub f107_data_type: String,
154    #[serde(rename = "F10.7_OBS_CENTER81")]
155    pub f107_obs_center81: Option<f64>,
156    #[serde(rename = "F10.7_OBS_LAST81")]
157    pub f107_obs_last81: Option<f64>,
158    #[serde(rename = "F10.7_ADJ_CENTER81")]
159    pub f107_adj_center81: Option<f64>,
160    #[serde(rename = "F10.7_ADJ_LAST81")]
161    pub f107_adj_last81: Option<f64>,
162}
163
164impl RawSpaceWeatherRow {
165    /// Returns the eight 3-hour Kp values rescaled to standard floating-point bounds [0.0, 9.0].
166    ///
167    /// Missing bins default first to the row's mean daily Kp (derived from `KP_SUM`),
168    /// and secondarily to the provided `SpaceWeatherFallback` policy.
169    #[inline]
170    pub fn kp_bins(&self, fallback: StaticSpaceWeather) -> [f64; 8] {
171        // KP_SUM in CelesTrak CSV is the sum of the eight 3-hour $K_p \times 10$ values.
172        // Dividing KP_SUM by $80.0$ yields the mean 3-hour $K_p$ index in standard $0.0\text{--}9.0$ scale
173        let daily_mean_kp = self
174            .kp_sum
175            .map(|sum| sum / 80.0)
176            .unwrap_or_else(|| fallback.resolve_kp(None));
177
178        let resolve = |bin: Option<f64>| bin.map(|v| v / 10.0).unwrap_or(daily_mean_kp);
179
180        [
181            resolve(self.kp1),
182            resolve(self.kp2),
183            resolve(self.kp3),
184            resolve(self.kp4),
185            resolve(self.kp5),
186            resolve(self.kp6),
187            resolve(self.kp7),
188            resolve(self.kp8),
189        ]
190    }
191
192    /// Returns the eight 3-hour linear Ap values.
193    ///
194    /// Missing bins default first to the row's `AP_AVG`, and secondarily to the
195    /// provided `SpaceWeatherFallback` policy.
196    #[inline]
197    pub fn ap_bins(&self, fallback: StaticSpaceWeather) -> [f64; 8] {
198        let daily_mean_ap = self.ap_avg.unwrap_or_else(|| fallback.resolve_ap(None));
199
200        let resolve = |bin: Option<f64>| bin.unwrap_or(daily_mean_ap);
201
202        [
203            resolve(self.ap1),
204            resolve(self.ap2),
205            resolve(self.ap3),
206            resolve(self.ap4),
207            resolve(self.ap5),
208            resolve(self.ap6),
209            resolve(self.ap7),
210            resolve(self.ap8),
211        ]
212    }
213}
214
215/// Stores SpaceWeather data as provided by [CelesTrak](https://celestrak.org/SpaceData/).
216/// Data may be provided either as original CSV or in a compressed (non-archived) gunzip (gz) format.
217///
218/// :type path: str | None
219/// :type fallback: StaticSpaceWeather | None
220#[cfg_attr(feature = "python", pyclass(from_py_object))]
221#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
222pub struct SpaceWeatherData {
223    #[serde(with = "as_vec")]
224    pub records: BTreeMap<Epoch, RawSpaceWeatherRow>,
225    pub fallback: StaticSpaceWeather,
226}
227
228impl SpaceWeatherData {
229    /// Initialize new space weather by provided fixed values only.
230    pub fn from_static_weather(weather: StaticSpaceWeather) -> Self {
231        Self {
232            records: BTreeMap::new(),
233            fallback: weather,
234        }
235    }
236
237    /// Ingests a complete CelesTrak Space Weather CSV file into an Epoch-indexed map.
238    pub fn from_csv_file<P: AsRef<Path>>(
239        path: P,
240        fallback: StaticSpaceWeather,
241    ) -> Result<Self, InputOutputError> {
242        let path_ref = path.as_ref();
243        let file = File::open(path_ref).map_err(|e| InputOutputError::StdIOError {
244            source: e,
245            action: "reading space weather file",
246        })?;
247
248        let mut buf_reader = BufReader::new(file);
249
250        // Peek at the first 2 bytes to detect Gzip magic header (0x1F, 0x8B) without consuming the buffer
251        let is_gzipped = match buf_reader.fill_buf() {
252            Ok(header) => header.len() >= 2 && header[0] == 0x1f && header[1] == 0x8b,
253            Err(source) => {
254                return Err(InputOutputError::StdIOError {
255                    source,
256                    action: "reading header of CSV file",
257                });
258            }
259        };
260
261        let stream: Box<dyn Read> = if is_gzipped {
262            Box::new(GzDecoder::new(buf_reader))
263        } else {
264            Box::new(buf_reader)
265        };
266
267        let mut rdr = csv::ReaderBuilder::new()
268            .trim(csv::Trim::All)
269            .from_reader(stream);
270
271        let mut records = BTreeMap::new();
272
273        for result in rdr.deserialize() {
274            let record: RawSpaceWeatherRow =
275                result.map_err(|source| InputOutputError::CsvData {
276                    source,
277                    action: "reading space weather",
278                })?;
279            if let Ok(epoch) = Epoch::from_str(&format!("{}T00:00:00 UTC", record.date)) {
280                records.insert(epoch, record);
281            }
282        }
283
284        Ok(Self { records, fallback })
285    }
286
287    /// Returns a reference to the raw, unparsed daily row for an exact midnight UTC epoch.
288    pub fn raw_daily_record(&self, midnight_epoch: Epoch) -> Option<&RawSpaceWeatherRow> {
289        self.records.get(&midnight_epoch)
290    }
291}
292
293#[cfg_attr(feature = "python", pymethods)]
294impl SpaceWeatherData {
295    /// Evaluates the space weather state at `epoch` and constructs the `Msise00DailyWeather` payload.
296    ///
297    /// Missing daily records or unforecasted fields are resolved using `SpaceWeatherFallback`.
298    ///
299    /// :type epoch: Epoch
300    /// :rtype: Msise00DailyWeather
301    pub fn msise_weather(&self, epoch: Epoch) -> Msise00DailyWeather {
302        let target_midnight = epoch.with_hms(0, 0, 0);
303        let current_day = self.records.get(&target_midnight);
304
305        let seconds_into_day = (epoch - target_midnight).to_seconds();
306        // Bins are 3 hours large (0..7)
307        let bin_idx = ((seconds_into_day / (Unit::Hour * 3).to_seconds()).floor() as usize).min(7);
308
309        let ap_history = self.build_ap_history(target_midnight, bin_idx);
310
311        // 1. Daily F10.7: Prefer observed, fall back to adjusted, then global fallback
312        let f107_daily = self.fallback.resolve_f107(current_day.map(|r| r.f107_obs));
313
314        // 2. 81-day Centered Mean F10.7: Prefer observed 81d, then adjusted 81d,
315        // fall back to resolved daily F10.7 before applying static global fallback
316        let f107_avg = current_day
317            .and_then(|r| r.f107_obs_center81.or(r.f107_adj_center81))
318            .unwrap_or(f107_daily);
319
320        // 3. Daily Ap: Prefer recorded ap_avg, fall back to fallback policy
321        let ap_daily = self.fallback.resolve_ap(current_day.and_then(|r| r.ap_avg));
322
323        Msise00DailyWeather {
324            f107_daily_sfu: f107_daily,
325            f107_avg_sfu: f107_avg,
326            ap_daily,
327            ap_3hour_history: ap_history,
328        }
329    }
330
331    /// Assembles the 7-element Ap array spanning current bin back 57 hours across 4 calendar days.
332    ///
333    /// Missing daily records or unforecasted bins are populated using the configured `SpaceWeatherFallback`.
334    ///
335    /// :type midnight: Epoch
336    /// :type bin_idx: int
337    /// :rtype: list[float]
338    fn build_ap_history(&self, midnight: Epoch, bin_idx: usize) -> [f64; 7] {
339        let one_day = Unit::Day * 1.0;
340
341        // Helper to retrieve or synthesize a 8-bin 3-hour Ap slice for a given day offset.
342        let get_ap_bins = |offset_days: f64| -> [f64; 8] {
343            let target_epoch = midnight - one_day * offset_days;
344            match self.records.get(&target_epoch) {
345                Some(row) => row.ap_bins(self.fallback),
346                None => [self.fallback.resolve_ap(None); 8],
347            }
348        };
349
350        // Extract day 0 metadata and bins
351        let day_0_row = self.records.get(&midnight);
352        let daily_ap = self.fallback.resolve_ap(day_0_row.and_then(|r| r.ap_avg));
353        let day_0_bins = match day_0_row {
354            Some(row) => row.ap_bins(self.fallback),
355            None => [self.fallback.resolve_ap(None); 8],
356        };
357
358        let mut continuous_ap = [0.0; 32];
359        continuous_ap[0..8].copy_from_slice(&get_ap_bins(3.0));
360        continuous_ap[8..16].copy_from_slice(&get_ap_bins(2.0));
361        continuous_ap[16..24].copy_from_slice(&get_ap_bins(1.0));
362        continuous_ap[24..32].copy_from_slice(&day_0_bins);
363
364        let idx = 24 + bin_idx;
365
366        let avg_slice = |start: usize, end: usize| -> f64 {
367            let slice = &continuous_ap[start..=end];
368            slice.iter().sum::<f64>() / slice.len() as f64
369        };
370
371        [
372            daily_ap,                      // ap_3hour_history[0]: Daily Ap
373            continuous_ap[idx],            // ap_3hour_history[1]: Ap at target epoch
374            continuous_ap[idx - 1],        // ap_3hour_history[2]: Ap at T - 3h
375            continuous_ap[idx - 2],        // ap_3hour_history[3]: Ap at T - 6h
376            continuous_ap[idx - 3],        // ap_3hour_history[4]: Ap at T - 9h
377            avg_slice(idx - 11, idx - 4),  // ap_3hour_history[5]: Average Ap from T-12h to T-33h
378            avg_slice(idx - 19, idx - 12), // ap_3hour_history[6]: Average Ap from T-36h to T-57h
379        ]
380    }
381}
382
383#[cfg(feature = "python")]
384#[cfg_attr(feature = "python", pymethods)]
385impl SpaceWeatherData {
386    #[new]
387    fn py_new(
388        path: Option<PathBuf>,
389        fallback: Option<StaticSpaceWeather>,
390    ) -> Result<Self, InputOutputError> {
391        if let Some(path) = path {
392            Self::from_csv_file(path, fallback.unwrap_or_default())
393        } else if let Some(weather) = fallback {
394            Ok(Self::from_static_weather(weather))
395        } else {
396            Err(InputOutputError::MissingData {
397                which:
398                    "must provide at least either a path to a weather file or a fallback, or both"
399                        .to_string(),
400            })
401        }
402    }
403
404    fn __str__(&self) -> String {
405        format!("{self}")
406    }
407
408    fn __repr__(&self) -> String {
409        format!("{self} @ {self:p}")
410    }
411}
412
413impl fmt::Display for SpaceWeatherData {
414    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415        if self.records.is_empty() {
416            write!(f, "empty SpaceWeatherData")
417        } else {
418            write!(
419                f,
420                "SpaceWeatherData from {} to {} ({:?})",
421                self.records.first_key_value().unwrap().0,
422                self.records.last_key_value().unwrap().0,
423                self.fallback
424            )
425        }
426    }
427}
428
429// Implement StaticType manually by mapping BTreeMap to a List of key-value pairs
430impl StaticType for SpaceWeatherData {
431    fn static_type() -> SimpleType {
432        let mut rcrd = HashMap::new();
433        rcrd.insert("epoch".to_string(), String::static_type());
434        rcrd.insert("raw_weather".to_string(), RawSpaceWeatherRow::static_type());
435
436        SimpleType::List(Box::new(SimpleType::Record(rcrd)))
437    }
438}
439
440/// Serde helper module to serialize BTreeMap as a vector of pairs
441/// so it matches Dhall's List of records representation.
442mod as_vec {
443    use super::*;
444    use serde::{Deserializer, Serializer};
445
446    #[derive(Serialize, Deserialize)]
447    struct WeatherEntry {
448        epoch: Epoch,
449        raw_weather: RawSpaceWeatherRow,
450    }
451
452    pub fn serialize<S>(
453        map: &BTreeMap<Epoch, RawSpaceWeatherRow>,
454        serializer: S,
455    ) -> Result<S::Ok, S::Error>
456    where
457        S: Serializer,
458    {
459        let vec: Vec<WeatherEntry> = map
460            .iter()
461            .map(|(epoch, raw_weather)| WeatherEntry {
462                epoch: *epoch,
463                raw_weather: raw_weather.clone(),
464            })
465            .collect();
466        vec.serialize(serializer)
467    }
468
469    pub fn deserialize<'de, D>(
470        deserializer: D,
471    ) -> Result<BTreeMap<Epoch, RawSpaceWeatherRow>, D::Error>
472    where
473        D: Deserializer<'de>,
474    {
475        use serde::Deserialize;
476        let vec: Vec<WeatherEntry> = Vec::deserialize(deserializer)?;
477        let mut rcrd = BTreeMap::new();
478        for entry in vec {
479            rcrd.insert(entry.epoch, entry.raw_weather);
480        }
481        Ok(rcrd)
482    }
483}
484
485// Define the model-specific weather data extracted from the space weather
486
487/// Target weather payload required by the NRLMSISE-00 density model.
488#[derive(Debug, Clone, Copy, Default)]
489#[cfg_attr(feature = "python", pyclass(from_py_object))]
490pub struct Msise00DailyWeather {
491    /// Daily F10.7 solar radio flux: $\text{ SFU} = 10^{-22} \text{ W}\cdot\text{m}^{-2}\cdot\text{Hz}^{-1}$.
492    pub f107_daily_sfu: f64,
493    /// 81-day centered average F10.7 solar radio flux [SFU].
494    pub f107_avg_sfu: f64,
495    /// Daily mean planetary Ap index.
496    pub ap_daily: f64,
497    /// 7-element Ap historical array covering the 57-hour lookback window.
498    pub ap_3hour_history: [f64; 7],
499}
500
501#[cfg(feature = "python")]
502#[cfg_attr(feature = "python", pymethods)]
503impl Msise00DailyWeather {
504    fn __str__(&self) -> String {
505        format!("{self:?}")
506    }
507
508    fn __repr__(&self) -> String {
509        format!("{self:?} @ {self:p}")
510    }
511}