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}
227
228impl SpaceWeatherData {
229 pub fn from_static_weather(weather: StaticSpaceWeather) -> Self {
231 Self {
232 records: BTreeMap::new(),
233 fallback: weather,
234 }
235 }
236
237 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 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 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 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 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 let f107_daily = self.fallback.resolve_f107(current_day.map(|r| r.f107_obs));
313
314 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 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 fn build_ap_history(&self, midnight: Epoch, bin_idx: usize) -> [f64; 7] {
339 let one_day = Unit::Day * 1.0;
340
341 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 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, 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), ]
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
429impl 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
440mod 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#[derive(Debug, Clone, Copy, Default)]
489#[cfg_attr(feature = "python", pyclass(from_py_object))]
490pub struct Msise00DailyWeather {
491 pub f107_daily_sfu: f64,
493 pub f107_avg_sfu: f64,
495 pub ap_daily: f64,
497 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}