1use 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
38#[cfg_attr(feature = "python", pyclass(from_py_object, get_all))]
39pub enum StaticSpaceWeather {
40 SolarMinimum(),
42 SolarAverage(),
44 SolarMaximum(),
46 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 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 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 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#[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 #[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 #[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 #[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 #[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 #[inline]
170 pub fn kp_bins(&self, fallback: StaticSpaceWeather) -> [f64; 8] {
171 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 #[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#[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 #[serde(default)]
228 pub interpolate: bool,
229}
230
231impl SpaceWeatherData {
232 pub fn from_static_weather(weather: StaticSpaceWeather) -> Self {
234 Self {
235 records: BTreeMap::new(),
236 fallback: weather,
237 interpolate: false,
238 }
239 }
240
241 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 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 pub fn with_interpolation(mut self, interpolate: bool) -> Self {
297 self.interpolate = interpolate;
298 self
299 }
300
301 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 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 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 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 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 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 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 let yesterday = self
368 .records
369 .get(&(epoch.with_hms_strict(0, 0, 0) - Unit::Day * 1));
370 let current_day = self.records.get(&target_midnight);
372
373 let f107_daily = self.fallback.resolve_f107(yesterday.map(|r| r.f107_obs));
375
376 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 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 fn build_ap_history(&self, midnight: Epoch, bin_idx: usize) -> [f64; 7] {
404 let one_day = Unit::Day * 1.0;
405
406 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 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, continuous_ap[idx], continuous_ap[idx - 1], continuous_ap[idx - 2], continuous_ap[idx - 3], avg_slice(idx - 11, idx - 4), avg_slice(idx - 19, idx - 12), ]
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
510impl 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
521mod 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#[derive(Debug, Clone, Copy, Default)]
570#[cfg_attr(feature = "python", pyclass(from_py_object))]
571pub struct Msise00DailyWeather {
572 pub f107_daily_sfu: f64,
574 pub f107_avg_sfu: f64,
576 pub ap_daily: f64,
578 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 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 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 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 let query_epoch = Epoch::from_str("2024-02-02T12:00:00 UTC").unwrap();
731
732 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 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}