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))]
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 pub fn from_static_weather(weather: StaticSpaceWeather) -> Self {
228 Self {
229 records: BTreeMap::new(),
230 fallback: weather,
231 }
232 }
233
234 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 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 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 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 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 let f107_daily = self.fallback.resolve_f107(current_day.map(|r| r.f107_obs));
307
308 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 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 fn build_ap_history(&self, midnight: Epoch, bin_idx: usize) -> [f64; 7] {
329 let one_day = Unit::Day * 1.0;
330
331 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 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, 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), ]
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
419impl 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
430mod 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#[derive(Debug, Clone, Copy, Default)]
479#[cfg_attr(feature = "python", pyclass(from_py_object))]
480pub struct Msise00DailyWeather {
481 pub f107_daily_sfu: f64,
483 pub f107_avg_sfu: f64,
485 pub ap_daily: f64,
487 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}