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#[cfg_attr(feature = "python", pyclass(from_py_object))]
218#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
219pub struct SpaceWeatherData {
220    #[serde(with = "as_vec")]
221    pub records: BTreeMap<Epoch, RawSpaceWeatherRow>,
222    pub fallback: StaticSpaceWeather,
223}
224
225impl SpaceWeatherData {
226    /// Initialize new space weather by provided fixed values only.
227    pub fn from_static_weather(weather: StaticSpaceWeather) -> Self {
228        Self {
229            records: BTreeMap::new(),
230            fallback: weather,
231        }
232    }
233
234    /// Ingests a complete CelesTrak Space Weather CSV file into an Epoch-indexed map.
235    pub fn from_csv_file<P: AsRef<Path>>(
236        path: P,
237        fallback: StaticSpaceWeather,
238    ) -> Result<Self, InputOutputError> {
239        let path_ref = path.as_ref();
240        let file = File::open(path_ref).map_err(|e| InputOutputError::StdIOError {
241            source: e,
242            action: "reading space weather file",
243        })?;
244
245        let mut buf_reader = BufReader::new(file);
246
247        // Peek at the first 2 bytes to detect Gzip magic header (0x1F, 0x8B) without consuming the buffer
248        let is_gzipped = match buf_reader.fill_buf() {
249            Ok(header) => header.len() >= 2 && header[0] == 0x1f && header[1] == 0x8b,
250            Err(source) => {
251                return Err(InputOutputError::StdIOError {
252                    source,
253                    action: "reading header of CSV file",
254                });
255            }
256        };
257
258        let stream: Box<dyn Read> = if is_gzipped {
259            Box::new(GzDecoder::new(buf_reader))
260        } else {
261            Box::new(buf_reader)
262        };
263
264        let mut rdr = csv::ReaderBuilder::new()
265            .trim(csv::Trim::All)
266            .from_reader(stream);
267
268        let mut records = BTreeMap::new();
269
270        for result in rdr.deserialize() {
271            let record: RawSpaceWeatherRow =
272                result.map_err(|source| InputOutputError::CsvData {
273                    source,
274                    action: "reading space weather",
275                })?;
276            if let Ok(epoch) = Epoch::from_str(&format!("{}T00:00:00 UTC", record.date)) {
277                records.insert(epoch, record);
278            }
279        }
280
281        Ok(Self { records, fallback })
282    }
283
284    /// Returns a reference to the raw, unparsed daily row for an exact midnight UTC epoch.
285    pub fn raw_daily_record(&self, midnight_epoch: Epoch) -> Option<&RawSpaceWeatherRow> {
286        self.records.get(&midnight_epoch)
287    }
288}
289
290#[cfg_attr(feature = "python", pymethods)]
291impl SpaceWeatherData {
292    /// Evaluates the space weather state at `epoch` and constructs the `Msise00DailyWeather` payload.
293    ///
294    /// Missing daily records or unforecasted fields are resolved using `SpaceWeatherFallback`.
295    pub fn msise_weather(&self, epoch: Epoch) -> Msise00DailyWeather {
296        let target_midnight = epoch.with_hms(0, 0, 0);
297        let current_day = self.records.get(&target_midnight);
298
299        let seconds_into_day = (epoch - target_midnight).to_seconds();
300        // Bins are 3 hours large (0..7)
301        let bin_idx = ((seconds_into_day / (Unit::Hour * 3).to_seconds()).floor() as usize).min(7);
302
303        let ap_history = self.build_ap_history(target_midnight, bin_idx);
304
305        // 1. Daily F10.7: Prefer observed, fall back to adjusted, then global fallback
306        let f107_daily = self.fallback.resolve_f107(current_day.map(|r| r.f107_obs));
307
308        // 2. 81-day Centered Mean F10.7: Prefer observed 81d, then adjusted 81d,
309        // fall back to resolved daily F10.7 before applying static global fallback
310        let f107_avg = current_day
311            .and_then(|r| r.f107_obs_center81.or(r.f107_adj_center81))
312            .unwrap_or(f107_daily);
313
314        // 3. Daily Ap: Prefer recorded ap_avg, fall back to fallback policy
315        let ap_daily = self.fallback.resolve_ap(current_day.and_then(|r| r.ap_avg));
316
317        Msise00DailyWeather {
318            f107_daily_sfu: f107_daily,
319            f107_avg_sfu: f107_avg,
320            ap_daily,
321            ap_3hour_history: ap_history,
322        }
323    }
324
325    /// Assembles the 7-element Ap array spanning current bin back 57 hours across 4 calendar days.
326    ///
327    /// Missing daily records or unforecasted bins are populated using the configured `SpaceWeatherFallback`.
328    fn build_ap_history(&self, midnight: Epoch, bin_idx: usize) -> [f64; 7] {
329        let one_day = Unit::Day * 1.0;
330
331        // Helper to retrieve or synthesize a 8-bin 3-hour Ap slice for a given day offset.
332        let get_ap_bins = |offset_days: f64| -> [f64; 8] {
333            let target_epoch = midnight - one_day * offset_days;
334            match self.records.get(&target_epoch) {
335                Some(row) => row.ap_bins(self.fallback),
336                None => [self.fallback.resolve_ap(None); 8],
337            }
338        };
339
340        // Extract day 0 metadata and bins
341        let day_0_row = self.records.get(&midnight);
342        let daily_ap = self.fallback.resolve_ap(day_0_row.and_then(|r| r.ap_avg));
343        let day_0_bins = match day_0_row {
344            Some(row) => row.ap_bins(self.fallback),
345            None => [self.fallback.resolve_ap(None); 8],
346        };
347
348        let mut continuous_ap = [0.0; 32];
349        continuous_ap[0..8].copy_from_slice(&get_ap_bins(3.0));
350        continuous_ap[8..16].copy_from_slice(&get_ap_bins(2.0));
351        continuous_ap[16..24].copy_from_slice(&get_ap_bins(1.0));
352        continuous_ap[24..32].copy_from_slice(&day_0_bins);
353
354        let idx = 24 + bin_idx;
355
356        let avg_slice = |start: usize, end: usize| -> f64 {
357            let slice = &continuous_ap[start..=end];
358            slice.iter().sum::<f64>() / slice.len() as f64
359        };
360
361        [
362            daily_ap,                      // ap_3hour_history[0]: Daily Ap
363            continuous_ap[idx],            // ap_3hour_history[1]: Ap at target epoch
364            continuous_ap[idx - 1],        // ap_3hour_history[2]: Ap at T - 3h
365            continuous_ap[idx - 2],        // ap_3hour_history[3]: Ap at T - 6h
366            continuous_ap[idx - 3],        // ap_3hour_history[4]: Ap at T - 9h
367            avg_slice(idx - 11, idx - 4),  // ap_3hour_history[5]: Average Ap from T-12h to T-33h
368            avg_slice(idx - 19, idx - 12), // ap_3hour_history[6]: Average Ap from T-36h to T-57h
369        ]
370    }
371}
372
373#[cfg(feature = "python")]
374#[cfg_attr(feature = "python", pymethods)]
375impl SpaceWeatherData {
376    #[new]
377    fn py_new(
378        path: Option<PathBuf>,
379        fallback: Option<StaticSpaceWeather>,
380    ) -> Result<Self, InputOutputError> {
381        if let Some(path) = path {
382            Self::from_csv_file(path, fallback.unwrap_or_default())
383        } else if let Some(weather) = fallback {
384            Ok(Self::from_static_weather(weather))
385        } else {
386            Err(InputOutputError::MissingData {
387                which:
388                    "must provide at least either a path to a weather file or a fallback, or both"
389                        .to_string(),
390            })
391        }
392    }
393
394    fn __str__(&self) -> String {
395        format!("{self}")
396    }
397
398    fn __repr__(&self) -> String {
399        format!("{self} @ {self:p}")
400    }
401}
402
403impl fmt::Display for SpaceWeatherData {
404    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405        if self.records.is_empty() {
406            write!(f, "empty SpaceWeatherData")
407        } else {
408            write!(
409                f,
410                "SpaceWeatherData from {} to {} ({:?})",
411                self.records.first_key_value().unwrap().0,
412                self.records.last_key_value().unwrap().0,
413                self.fallback
414            )
415        }
416    }
417}
418
419// Implement StaticType manually by mapping BTreeMap to a List of key-value pairs
420impl StaticType for SpaceWeatherData {
421    fn static_type() -> SimpleType {
422        let mut rcrd = HashMap::new();
423        rcrd.insert("epoch".to_string(), String::static_type());
424        rcrd.insert("raw_weather".to_string(), RawSpaceWeatherRow::static_type());
425
426        SimpleType::List(Box::new(SimpleType::Record(rcrd)))
427    }
428}
429
430/// Serde helper module to serialize BTreeMap as a vector of pairs
431/// so it matches Dhall's List of records representation.
432mod as_vec {
433    use super::*;
434    use serde::{Deserializer, Serializer};
435
436    #[derive(Serialize, Deserialize)]
437    struct WeatherEntry {
438        epoch: Epoch,
439        raw_weather: RawSpaceWeatherRow,
440    }
441
442    pub fn serialize<S>(
443        map: &BTreeMap<Epoch, RawSpaceWeatherRow>,
444        serializer: S,
445    ) -> Result<S::Ok, S::Error>
446    where
447        S: Serializer,
448    {
449        let vec: Vec<WeatherEntry> = map
450            .iter()
451            .map(|(epoch, raw_weather)| WeatherEntry {
452                epoch: *epoch,
453                raw_weather: raw_weather.clone(),
454            })
455            .collect();
456        vec.serialize(serializer)
457    }
458
459    pub fn deserialize<'de, D>(
460        deserializer: D,
461    ) -> Result<BTreeMap<Epoch, RawSpaceWeatherRow>, D::Error>
462    where
463        D: Deserializer<'de>,
464    {
465        use serde::Deserialize;
466        let vec: Vec<WeatherEntry> = Vec::deserialize(deserializer)?;
467        let mut rcrd = BTreeMap::new();
468        for entry in vec {
469            rcrd.insert(entry.epoch, entry.raw_weather);
470        }
471        Ok(rcrd)
472    }
473}
474
475// Define the model-specific weather data extracted from the space weather
476
477/// Target weather payload required by the NRLMSISE-00 density model.
478#[derive(Debug, Clone, Copy, Default)]
479#[cfg_attr(feature = "python", pyclass(from_py_object))]
480pub struct Msise00DailyWeather {
481    /// Daily F10.7 solar radio flux: $\text{ SFU} = 10^{-22} \text{ W}\cdot\text{m}^{-2}\cdot\text{Hz}^{-1}$.
482    pub f107_daily_sfu: f64,
483    /// 81-day centered average F10.7 solar radio flux [SFU].
484    pub f107_avg_sfu: f64,
485    /// Daily mean planetary Ap index.
486    pub ap_daily: f64,
487    /// 7-element Ap historical array covering the 57-hour lookback window.
488    pub ap_3hour_history: [f64; 7],
489}
490
491#[cfg(feature = "python")]
492#[cfg_attr(feature = "python", pymethods)]
493impl Msise00DailyWeather {
494    fn __str__(&self) -> String {
495        format!("{self:?}")
496    }
497
498    fn __repr__(&self) -> String {
499        format!("{self:?} @ {self:p}")
500    }
501}