use std::cmp::Ordering;
use std::path::PathBuf;
use crate::utils::{datadir, download_file, download_if_not_exist, RefreshableSingleton};
use crate::Instant;
use crate::TimeLike;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Invalid number in file: {0}")]
InvalidNumber(&'static str),
#[error("Invalid entry in space weather file: too few fields")]
InvalidEntry,
#[error("No space weather record found for date")]
NoRecordForDate,
#[error(
"Data directory is read-only. Try setting the environment variable SATKIT_DATA \
to a writeable directory and re-starting or explicitly set data directory to \
a writeable directory"
)]
DataDirReadOnly,
#[error("space-weather byte buffer is not valid UTF-8: {0}")]
Utf8(#[from] std::str::Utf8Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
InvalidEpoch(#[from] crate::time::InstantError),
#[error(transparent)]
Datadir(#[from] crate::utils::datadir::Error),
#[error(transparent)]
Download(#[from] crate::utils::download::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone)]
pub struct SpaceWeatherRecord {
pub date: Instant,
pub bsrn: i32,
pub nd: i32,
pub kp: [i32; 8],
pub kp_sum: i32,
pub ap: [i32; 8],
pub ap_avg: i32,
pub cp: f64,
pub c9: i32,
pub isn: i32,
pub f10p7_obs: f64,
pub f10p7_adj: f64,
pub f10p7_obs_c81: f64,
pub f10p7_obs_l81: f64,
pub f10p7_adj_c81: f64,
pub f10p7_adj_l81: f64,
}
fn str2num<T: core::str::FromStr>(
s: &str,
sidx: usize,
eidx: usize,
field: &'static str,
) -> Result<T> {
s.chars()
.skip(sidx)
.take(eidx - sidx)
.collect::<String>()
.trim()
.parse()
.map_err(|_| Error::InvalidNumber(field))
}
impl PartialEq for SpaceWeatherRecord {
fn eq(&self, other: &Self) -> bool {
self.date == other.date
}
}
impl PartialOrd for SpaceWeatherRecord {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.date.partial_cmp(&other.date)
}
}
impl PartialEq<Instant> for SpaceWeatherRecord {
fn eq(&self, other: &Instant) -> bool {
self.date == *other
}
}
impl PartialOrd<Instant> for SpaceWeatherRecord {
fn partial_cmp(&self, other: &Instant) -> Option<Ordering> {
self.date.partial_cmp(other)
}
}
fn parse_csv(text: &str) -> Result<Vec<SpaceWeatherRecord>> {
text.lines()
.skip(1)
.filter(|line| !line.trim().is_empty())
.map(|line| -> Result<SpaceWeatherRecord> {
let lvals: Vec<&str> = line.split(",").collect();
if lvals.len() < 31 {
return Err(Error::InvalidEntry);
}
let year: u32 = str2num(lvals[0], 0, 4, "year")?;
let mon: u32 = str2num(lvals[0], 5, 7, "month")?;
let day: u32 = str2num(lvals[0], 8, 10, "day of month")?;
Ok(SpaceWeatherRecord {
date: (Instant::from_date(year as i32, mon as i32, day as i32)?),
bsrn: lvals[1].parse().unwrap_or(-1),
nd: lvals[2].parse().unwrap_or(-1),
kp: {
let mut kparr: [i32; 8] = [-1, -1, -1, -1, -1, -1, -1, -1];
for idx in 0..8 {
kparr[idx] = lvals[idx + 3].parse().unwrap_or(-1);
}
kparr
},
kp_sum: lvals[11].parse().unwrap_or(-1),
ap: {
let mut aparr: [i32; 8] = [-1, -1, -1, -1, -1, -1, -1, -1];
for idx in 0..8 {
aparr[idx] = lvals[12 + idx].parse().unwrap_or(-1)
}
aparr
},
ap_avg: lvals[20].parse().unwrap_or(-1),
cp: lvals[21].parse().unwrap_or(-1.0),
c9: lvals[22].parse().unwrap_or(-1),
isn: lvals[23].parse().unwrap_or(-1),
f10p7_obs: lvals[24].parse().unwrap_or(-1.0),
f10p7_adj: lvals[25].parse().unwrap_or(-1.0),
f10p7_obs_c81: lvals[27].parse().unwrap_or(-1.0),
f10p7_obs_l81: lvals[28].parse().unwrap_or(-1.0),
f10p7_adj_c81: lvals[29].parse().unwrap_or(-1.0),
f10p7_adj_l81: lvals[30].parse().unwrap_or(-1.0),
})
})
.collect()
}
fn load_default_path() -> PathBuf {
datadir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("SW-All.csv")
}
fn load_space_weather_csv() -> Result<Vec<SpaceWeatherRecord>> {
let path = load_default_path();
download_if_not_exist(&path, Some("http://celestrak.org/SpaceData/"))?;
parse_csv(&std::fs::read_to_string(&path)?)
}
static SPACE_WEATHER: RefreshableSingleton<Vec<SpaceWeatherRecord>> = RefreshableSingleton::new();
pub fn init_from_bytes(bytes: &[u8]) -> Result<()> {
SPACE_WEATHER.set(parse_csv(std::str::from_utf8(bytes)?)?);
Ok(())
}
pub fn init_from_path(path: &std::path::Path) -> Result<()> {
SPACE_WEATHER.set(parse_csv(&std::fs::read_to_string(path)?)?);
Ok(())
}
fn ensure_default_loaded() {
SPACE_WEATHER.ensure_default_loaded(|| load_space_weather_csv().ok());
if SPACE_WEATHER.read().is_none() {
let path = load_default_path();
if path.is_file() {
if let Ok(text) = std::fs::read_to_string(&path) {
if let Ok(records) = parse_csv(&text) {
if !records.is_empty() {
SPACE_WEATHER.set(records);
}
}
}
}
}
}
pub fn get<T: TimeLike>(tm: &T) -> Result<SpaceWeatherRecord> {
let tm = tm.as_instant();
ensure_default_loaded();
let guard = SPACE_WEATHER.read();
let sw = guard.as_ref().ok_or(Error::NoRecordForDate)?;
let first = sw.first().ok_or(Error::NoRecordForDate)?;
let idx = (tm - first.date).as_days().floor() as usize;
if idx < sw.len() && (tm - sw[idx].date).as_days().abs() < 1.0 {
return Ok(sw[idx].clone());
}
sw.iter()
.rev()
.find(|x| x.date <= tm)
.cloned()
.ok_or(Error::NoRecordForDate)
}
pub fn update() -> Result<()> {
let d = datadir()?;
if d.metadata()?.permissions().readonly() {
return Err(Error::DataDirReadOnly);
}
let url = "https://celestrak.org/SpaceData/SW-All.csv";
download_file(url, &d, true)?;
SPACE_WEATHER.set(load_space_weather_csv()?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load() {
let tm: Instant = Instant::from_datetime(2023, 11, 14, 0, 0, 0.0).unwrap();
let r = get(&tm);
println!("r = {:?}", r);
println!("rdate = {}", r.unwrap().date);
}
#[test]
fn test_parse_truncated_line_errors_not_panics() {
let csv = "HEADER\n2023-11-14,1,2,3\n";
assert!(matches!(parse_csv(csv), Err(Error::InvalidEntry)));
}
#[test]
fn test_parse_skips_blank_trailing_line() {
let mut row = String::from("2023-11-14");
for _ in 0..30 {
row.push_str(",0");
}
let csv = format!("HEADER\n{row}\n\n");
let recs = parse_csv(&csv).unwrap();
assert_eq!(recs.len(), 1);
}
}