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    /// Whether to interpolate between days for daily F10.7, F10.7 average, and daily Ap.
227    #[serde(default)]
228    pub interpolate: bool,
229}
230
231impl SpaceWeatherData {
232    /// Initialize new space weather by provided fixed values only.
233    pub fn from_static_weather(weather: StaticSpaceWeather) -> Self {
234        Self {
235            records: BTreeMap::new(),
236            fallback: weather,
237            interpolate: false,
238        }
239    }
240
241    /// Ingests a complete CelesTrak Space Weather CSV file into an Epoch-indexed map.
242    pub fn from_csv_file<P: AsRef<Path>>(
243        path: P,
244        fallback: StaticSpaceWeather,
245    ) -> Result<Self, InputOutputError> {
246        let path_ref = path.as_ref();
247        let file = File::open(path_ref).map_err(|e| InputOutputError::StdIOError {
248            source: e,
249            action: "reading space weather file",
250        })?;
251
252        let mut buf_reader = BufReader::new(file);
253
254        // Peek at the first 2 bytes to detect Gzip magic header (0x1F, 0x8B) without consuming the buffer
255        let is_gzipped = match buf_reader.fill_buf() {
256            Ok(header) => header.len() >= 2 && header[0] == 0x1f && header[1] == 0x8b,
257            Err(source) => {
258                return Err(InputOutputError::StdIOError {
259                    source,
260                    action: "reading header of CSV file",
261                });
262            }
263        };
264
265        let stream: Box<dyn Read> = if is_gzipped {
266            Box::new(GzDecoder::new(buf_reader))
267        } else {
268            Box::new(buf_reader)
269        };
270
271        let mut rdr = csv::ReaderBuilder::new()
272            .trim(csv::Trim::All)
273            .from_reader(stream);
274
275        let mut records = BTreeMap::new();
276
277        for result in rdr.deserialize() {
278            let record: RawSpaceWeatherRow =
279                result.map_err(|source| InputOutputError::CsvData {
280                    source,
281                    action: "reading space weather",
282                })?;
283            if let Ok(epoch) = Epoch::from_str(&format!("{}T00:00:00 UTC", record.date)) {
284                records.insert(epoch, record);
285            }
286        }
287
288        Ok(Self {
289            records,
290            fallback,
291            interpolate: false,
292        })
293    }
294
295    /// Enable or disable linear interpolation of daily space weather indices between days.
296    pub fn with_interpolation(mut self, interpolate: bool) -> Self {
297        self.interpolate = interpolate;
298        self
299    }
300
301    /// Returns a reference to the raw, unparsed daily row for an exact midnight UTC epoch.
302    pub fn raw_daily_record(&self, midnight_epoch: Epoch) -> Option<&RawSpaceWeatherRow> {
303        self.records.get(&midnight_epoch)
304    }
305}
306
307#[cfg_attr(feature = "python", pymethods)]
308impl SpaceWeatherData {
309    /// Evaluates the space weather state at `epoch` and constructs the `Msise00DailyWeather` payload.
310    ///
311    /// Missing daily records or unforecasted fields are resolved using `SpaceWeatherFallback`.
312    ///
313    /// :type epoch: Epoch
314    /// :rtype: Msise00DailyWeather
315    pub fn msise_weather(&self, mut epoch: Epoch) -> Msise00DailyWeather {
316        epoch = epoch.to_time_scale(TimeScale::UTC);
317        let target_midnight = epoch.with_hms_strict(0, 0, 0);
318
319        let seconds_into_day = (epoch - target_midnight).to_seconds();
320        // Bins are 3 hours large (0..7)
321        let bin_idx = ((seconds_into_day / (Unit::Hour * 3).to_seconds()).floor() as usize).min(7);
322
323        let mut ap_history = self.build_ap_history(target_midnight, bin_idx);
324
325        let (f107_daily, f107_avg, ap_daily) = if self.interpolate {
326            let fraction = (seconds_into_day / 86400.0).clamp(0.0, 1.0);
327
328            // Get the records for yesterday, today, and tomorrow
329            let yesterday_epoch = target_midnight - Unit::Day * 1;
330            let today_epoch = target_midnight;
331            let tomorrow_epoch = target_midnight + Unit::Day * 1;
332
333            let yesterday = self.records.get(&yesterday_epoch);
334            let today = self.records.get(&today_epoch);
335            let tomorrow = self.records.get(&tomorrow_epoch);
336
337            // 1. Daily F10.7 (interpolated):
338            // F10.7 daily for today (V0) is based on yesterday's record.
339            // F10.7 daily for tomorrow (V1) is based on today's record.
340            let f107_daily_0 = self.fallback.resolve_f107(yesterday.map(|r| r.f107_obs));
341            let f107_daily_1 = self.fallback.resolve_f107(today.map(|r| r.f107_obs));
342            let f107_daily = f107_daily_0 + fraction * (f107_daily_1 - f107_daily_0);
343
344            // 2. 81-day Centered Mean F10.7 (interpolated):
345            // F10.7 81d centered average for today (V0) is based on today's record.
346            // F10.7 81d centered average for tomorrow (V1) is based on tomorrow's record.
347            let f107_avg_0 = today
348                .and_then(|r| r.f107_obs_center81.or(r.f107_adj_center81))
349                .unwrap_or(f107_daily_0);
350            let f107_avg_1 = tomorrow
351                .and_then(|r| r.f107_obs_center81.or(r.f107_adj_center81))
352                .unwrap_or(f107_daily_1);
353            let f107_avg = f107_avg_0 + fraction * (f107_avg_1 - f107_avg_0);
354
355            // 3. Daily Ap (interpolated):
356            // Daily Ap for today (V0) is based on today's record.
357            // Daily Ap for tomorrow (V1) is based on tomorrow's record.
358            let ap_daily_0 = self.fallback.resolve_ap(today.and_then(|r| r.ap_avg));
359            let ap_daily_1 = self.fallback.resolve_ap(tomorrow.and_then(|r| r.ap_avg));
360            let ap_daily = ap_daily_0 + fraction * (ap_daily_1 - ap_daily_0);
361
362            ap_history[0] = ap_daily;
363
364            (f107_daily, f107_avg, ap_daily)
365        } else {
366            // The F10.7 _daily_ must be taken from the previous day
367            let yesterday = self
368                .records
369                .get(&(epoch.with_hms_strict(0, 0, 0) - Unit::Day * 1));
370            // But the rest of the data comes from today.
371            let current_day = self.records.get(&target_midnight);
372
373            // 1. Daily F10.7: Prefer observed, fall back to adjusted, then global fallback
374            let f107_daily = self.fallback.resolve_f107(yesterday.map(|r| r.f107_obs));
375
376            // 2. 81-day Centered Mean F10.7: Prefer observed 81d, then adjusted 81d,
377            // fall back to resolved daily F10.7 before applying static global fallback
378            let f107_avg = current_day
379                .and_then(|r| r.f107_obs_center81.or(r.f107_adj_center81))
380                .unwrap_or(f107_daily);
381
382            // 3. Daily Ap: Prefer recorded ap_avg, fall back to fallback policy
383            let ap_daily = self.fallback.resolve_ap(current_day.and_then(|r| r.ap_avg));
384
385            (f107_daily, f107_avg, ap_daily)
386        };
387
388        Msise00DailyWeather {
389            f107_daily_sfu: f107_daily,
390            f107_avg_sfu: f107_avg,
391            ap_daily,
392            ap_3hour_history: ap_history,
393        }
394    }
395
396    /// Assembles the 7-element Ap array spanning current bin back 57 hours across 4 calendar days.
397    ///
398    /// Missing daily records or unforecasted bins are populated using the configured `SpaceWeatherFallback`.
399    ///
400    /// :type midnight: Epoch
401    /// :type bin_idx: int
402    /// :rtype: list[float]
403    fn build_ap_history(&self, midnight: Epoch, bin_idx: usize) -> [f64; 7] {
404        let one_day = Unit::Day * 1.0;
405
406        // Helper to retrieve or synthesize a 8-bin 3-hour Ap slice for a given day offset.
407        let get_ap_bins = |offset_days: f64| -> [f64; 8] {
408            let target_epoch = midnight - one_day * offset_days;
409            match self.records.get(&target_epoch) {
410                Some(row) => row.ap_bins(self.fallback),
411                None => [self.fallback.resolve_ap(None); 8],
412            }
413        };
414
415        // Extract day 0 metadata and bins
416        let day_0_row = self.records.get(&midnight);
417        let daily_ap = self.fallback.resolve_ap(day_0_row.and_then(|r| r.ap_avg));
418        let day_0_bins = match day_0_row {
419            Some(row) => row.ap_bins(self.fallback),
420            None => [self.fallback.resolve_ap(None); 8],
421        };
422
423        let mut continuous_ap = [0.0; 32];
424        continuous_ap[0..8].copy_from_slice(&get_ap_bins(3.0));
425        continuous_ap[8..16].copy_from_slice(&get_ap_bins(2.0));
426        continuous_ap[16..24].copy_from_slice(&get_ap_bins(1.0));
427        continuous_ap[24..32].copy_from_slice(&day_0_bins);
428
429        let idx = 24 + bin_idx;
430
431        let avg_slice = |start: usize, end: usize| -> f64 {
432            let slice = &continuous_ap[start..=end];
433            slice.iter().sum::<f64>() / slice.len() as f64
434        };
435
436        [
437            daily_ap,                      // ap_3hour_history[0]: Daily Ap
438            continuous_ap[idx],            // ap_3hour_history[1]: Ap at target epoch
439            continuous_ap[idx - 1],        // ap_3hour_history[2]: Ap at T - 3h
440            continuous_ap[idx - 2],        // ap_3hour_history[3]: Ap at T - 6h
441            continuous_ap[idx - 3],        // ap_3hour_history[4]: Ap at T - 9h
442            avg_slice(idx - 11, idx - 4),  // ap_3hour_history[5]: Average Ap from T-12h to T-33h
443            avg_slice(idx - 19, idx - 12), // ap_3hour_history[6]: Average Ap from T-36h to T-57h
444        ]
445    }
446}
447
448#[cfg(feature = "python")]
449#[cfg_attr(feature = "python", pymethods)]
450impl SpaceWeatherData {
451    #[pyo3(signature=(path, fallback, interpolate=false))]
452    #[new]
453    fn py_new(
454        path: Option<PathBuf>,
455        fallback: Option<StaticSpaceWeather>,
456        interpolate: Option<bool>,
457    ) -> Result<Self, InputOutputError> {
458        let mut sw = if let Some(path) = path {
459            Self::from_csv_file(path, fallback.unwrap_or_default())?
460        } else if let Some(weather) = fallback {
461            Self::from_static_weather(weather)
462        } else {
463            return Err(InputOutputError::MissingData {
464                which:
465                    "must provide at least either a path to a weather file or a fallback, or both"
466                        .to_string(),
467            });
468        };
469        if let Some(interp) = interpolate {
470            sw.interpolate = interp;
471        }
472        Ok(sw)
473    }
474
475    #[getter]
476    fn get_interpolate(&self) -> bool {
477        self.interpolate
478    }
479
480    #[setter]
481    fn set_interpolate(&mut self, val: bool) {
482        self.interpolate = val;
483    }
484
485    fn __str__(&self) -> String {
486        format!("{self}")
487    }
488
489    fn __repr__(&self) -> String {
490        format!("{self} @ {self:p}")
491    }
492}
493
494impl fmt::Display for SpaceWeatherData {
495    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
496        if self.records.is_empty() {
497            write!(f, "empty SpaceWeatherData")
498        } else {
499            write!(
500                f,
501                "SpaceWeatherData from {} to {} ({:?})",
502                self.records.first_key_value().unwrap().0,
503                self.records.last_key_value().unwrap().0,
504                self.fallback
505            )
506        }
507    }
508}
509
510// Implement StaticType manually by mapping BTreeMap to a List of key-value pairs
511impl StaticType for SpaceWeatherData {
512    fn static_type() -> SimpleType {
513        let mut rcrd = HashMap::new();
514        rcrd.insert("epoch".to_string(), String::static_type());
515        rcrd.insert("raw_weather".to_string(), RawSpaceWeatherRow::static_type());
516
517        SimpleType::List(Box::new(SimpleType::Record(rcrd)))
518    }
519}
520
521/// Serde helper module to serialize BTreeMap as a vector of pairs
522/// so it matches Dhall's List of records representation.
523mod as_vec {
524    use super::*;
525    use serde::{Deserializer, Serializer};
526
527    #[derive(Serialize, Deserialize)]
528    struct WeatherEntry {
529        epoch: Epoch,
530        raw_weather: RawSpaceWeatherRow,
531    }
532
533    pub fn serialize<S>(
534        map: &BTreeMap<Epoch, RawSpaceWeatherRow>,
535        serializer: S,
536    ) -> Result<S::Ok, S::Error>
537    where
538        S: Serializer,
539    {
540        let vec: Vec<WeatherEntry> = map
541            .iter()
542            .map(|(epoch, raw_weather)| WeatherEntry {
543                epoch: *epoch,
544                raw_weather: raw_weather.clone(),
545            })
546            .collect();
547        vec.serialize(serializer)
548    }
549
550    pub fn deserialize<'de, D>(
551        deserializer: D,
552    ) -> Result<BTreeMap<Epoch, RawSpaceWeatherRow>, D::Error>
553    where
554        D: Deserializer<'de>,
555    {
556        use serde::Deserialize;
557        let vec: Vec<WeatherEntry> = Vec::deserialize(deserializer)?;
558        let mut rcrd = BTreeMap::new();
559        for entry in vec {
560            rcrd.insert(entry.epoch, entry.raw_weather);
561        }
562        Ok(rcrd)
563    }
564}
565
566// Define the model-specific weather data extracted from the space weather
567
568/// Target weather payload required by the NRLMSISE-00 density model.
569#[derive(Debug, Clone, Copy, Default)]
570#[cfg_attr(feature = "python", pyclass(from_py_object))]
571pub struct Msise00DailyWeather {
572    /// Daily F10.7 solar radio flux: $\text{ SFU} = 10^{-22} \text{ W}\cdot\text{m}^{-2}\cdot\text{Hz}^{-1}$.
573    pub f107_daily_sfu: f64,
574    /// 81-day centered average F10.7 solar radio flux [SFU].
575    pub f107_avg_sfu: f64,
576    /// Daily mean planetary Ap index.
577    pub ap_daily: f64,
578    /// 7-element Ap historical array covering the 57-hour lookback window.
579    pub ap_3hour_history: [f64; 7],
580}
581
582#[cfg(feature = "python")]
583#[cfg_attr(feature = "python", pymethods)]
584impl Msise00DailyWeather {
585    fn __str__(&self) -> String {
586        format!("{self:?}")
587    }
588
589    fn __repr__(&self) -> String {
590        format!("{self:?} @ {self:p}")
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use hifitime::Epoch;
598
599    #[test]
600    fn test_space_weather_interpolation() {
601        let mut records = BTreeMap::new();
602
603        // Let's create mock rows
604        // Day 1: 2024-02-01
605        let row1 = RawSpaceWeatherRow {
606            date: "2024-02-01".to_string(),
607            bsrn: 0,
608            nd: 0,
609            kp1: None,
610            kp2: None,
611            kp3: None,
612            kp4: None,
613            kp5: None,
614            kp6: None,
615            kp7: None,
616            kp8: None,
617            kp_sum: None,
618            ap1: Some(10.0),
619            ap2: Some(10.0),
620            ap3: Some(10.0),
621            ap4: Some(10.0),
622            ap5: Some(10.0),
623            ap6: Some(10.0),
624            ap7: Some(10.0),
625            ap8: Some(10.0),
626            ap_avg: Some(10.0),
627            cp: None,
628            c9: None,
629            isn: None,
630            f107_obs: 100.0,
631            f107_adj: 100.0,
632            f107_data_type: "OBS".to_string(),
633            f107_obs_center81: Some(110.0),
634            f107_obs_last81: Some(110.0),
635            f107_adj_center81: Some(110.0),
636            f107_adj_last81: Some(110.0),
637        };
638
639        // Day 2: 2024-02-02
640        let row2 = RawSpaceWeatherRow {
641            date: "2024-02-02".to_string(),
642            bsrn: 0,
643            nd: 0,
644            kp1: None,
645            kp2: None,
646            kp3: None,
647            kp4: None,
648            kp5: None,
649            kp6: None,
650            kp7: None,
651            kp8: None,
652            kp_sum: None,
653            ap1: Some(20.0),
654            ap2: Some(20.0),
655            ap3: Some(20.0),
656            ap4: Some(20.0),
657            ap5: Some(20.0),
658            ap6: Some(20.0),
659            ap7: Some(20.0),
660            ap8: Some(20.0),
661            ap_avg: Some(20.0),
662            cp: None,
663            c9: None,
664            isn: None,
665            f107_obs: 200.0,
666            f107_adj: 200.0,
667            f107_data_type: "OBS".to_string(),
668            f107_obs_center81: Some(210.0),
669            f107_obs_last81: Some(210.0),
670            f107_adj_center81: Some(210.0),
671            f107_adj_last81: Some(210.0),
672        };
673
674        // Day 3: 2024-02-03
675        let row3 = RawSpaceWeatherRow {
676            date: "2024-02-03".to_string(),
677            bsrn: 0,
678            nd: 0,
679            kp1: None,
680            kp2: None,
681            kp3: None,
682            kp4: None,
683            kp5: None,
684            kp6: None,
685            kp7: None,
686            kp8: None,
687            kp_sum: None,
688            ap1: Some(30.0),
689            ap2: Some(30.0),
690            ap3: Some(30.0),
691            ap4: Some(30.0),
692            ap5: Some(30.0),
693            ap6: Some(30.0),
694            ap7: Some(30.0),
695            ap8: Some(30.0),
696            ap_avg: Some(30.0),
697            cp: None,
698            c9: None,
699            isn: None,
700            f107_obs: 300.0,
701            f107_adj: 300.0,
702            f107_data_type: "OBS".to_string(),
703            f107_obs_center81: Some(310.0),
704            f107_obs_last81: Some(310.0),
705            f107_adj_center81: Some(310.0),
706            f107_adj_last81: Some(310.0),
707        };
708
709        let epoch1 = Epoch::from_str("2024-02-01T00:00:00 UTC").unwrap();
710        let epoch2 = Epoch::from_str("2024-02-02T00:00:00 UTC").unwrap();
711        let epoch3 = Epoch::from_str("2024-02-03T00:00:00 UTC").unwrap();
712
713        records.insert(epoch1, row1);
714        records.insert(epoch2, row2);
715        records.insert(epoch3, row3);
716
717        let sw_no_interp = SpaceWeatherData {
718            records: records.clone(),
719            fallback: StaticSpaceWeather::SolarAverage(),
720            interpolate: false,
721        };
722
723        let sw_interp = SpaceWeatherData {
724            records,
725            fallback: StaticSpaceWeather::SolarAverage(),
726            interpolate: true,
727        };
728
729        // Midday on Day 2: 2024-02-02T12:00:00 UTC
730        let query_epoch = Epoch::from_str("2024-02-02T12:00:00 UTC").unwrap();
731
732        // 1. Without interpolation:
733        // f107_daily_sfu: daily F10.7 of yesterday (Day 1: 100.0)
734        // f107_avg_sfu: 81-day centered mean of current day (Day 2: 210.0)
735        // ap_daily: daily Ap of current day (Day 2: 20.0)
736        let w_no = sw_no_interp.msise_weather(query_epoch);
737        assert_eq!(w_no.f107_daily_sfu, 100.0);
738        assert_eq!(w_no.f107_avg_sfu, 210.0);
739        assert_eq!(w_no.ap_daily, 20.0);
740        assert_eq!(w_no.ap_3hour_history[0], 20.0);
741
742        // 2. With interpolation (midday = fraction 0.5):
743        // f107_daily_sfu: interpolated between yesterday's f107_daily (which is Day 1 F10.7 = 100)
744        //                 and tomorrow's f107_daily (which is Day 2 F10.7 = 200).
745        //                 Since fraction is 0.5, it should be 150.0.
746        // f107_avg_sfu: interpolated between current day's centered mean (Day 2: 210.0)
747        //               and tomorrow's centered mean (Day 3: 310.0).
748        //               Since fraction is 0.5, it should be 260.0.
749        // ap_daily: interpolated between current day's ap_avg (Day 2: 20.0)
750        //           and tomorrow's ap_avg (Day 3: 30.0).
751        //           Since fraction is 0.5, it should be 25.0.
752        let w_yes = sw_interp.msise_weather(query_epoch);
753        assert_eq!(w_yes.f107_daily_sfu, 150.0);
754        assert_eq!(w_yes.f107_avg_sfu, 260.0);
755        assert_eq!(w_yes.ap_daily, 25.0);
756        assert_eq!(w_yes.ap_3hour_history[0], 25.0);
757    }
758}