use std::fmt;
use std::sync::Arc;
use arika::epoch::Epoch;
use crate::space_weather::{SpaceWeather, SpaceWeatherProvider};
#[derive(Debug, Clone)]
pub struct CssiDailyRecord {
pub jd_midnight: f64,
pub year: i32,
pub month: u32,
pub day: u32,
pub ap_3h: [f64; 8],
pub ap_daily: f64,
pub f107_obs: f64,
pub f107_obs_ctr81: f64,
}
#[derive(Debug)]
pub enum CssiParseError {
LineTooShort { line_number: usize, length: usize },
ParseField {
line_number: usize,
field: &'static str,
value: String,
},
NoData,
}
impl fmt::Display for CssiParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LineTooShort {
line_number,
length,
} => {
write!(
f,
"line {line_number}: too short ({length} chars, need at least 124)"
)
}
Self::ParseField {
line_number,
field,
value,
} => {
write!(f, "line {line_number}: failed to parse {field}: {value:?}")
}
Self::NoData => write!(f, "no data records found in file"),
}
}
}
impl std::error::Error for CssiParseError {}
#[derive(Debug, Clone)]
pub struct CssiData {
records: Vec<CssiDailyRecord>,
}
impl CssiData {
pub(crate) fn from_records(mut records: Vec<CssiDailyRecord>) -> Result<Self, CssiParseError> {
if records.is_empty() {
return Err(CssiParseError::NoData);
}
records.sort_by(|a, b| a.jd_midnight.partial_cmp(&b.jd_midnight).unwrap());
Ok(Self { records })
}
pub fn parse(text: &str) -> Result<Self, CssiParseError> {
let mut records = Vec::new();
let mut in_section = false;
let mut is_observed_section = false;
let mut observed_jds = std::collections::HashSet::new();
for (i, line) in text.lines().enumerate() {
let line_number = i + 1;
let trimmed = line.trim();
if trimmed == "BEGIN OBSERVED" {
in_section = true;
is_observed_section = true;
continue;
}
if trimmed == "BEGIN DAILY_PREDICTED" || trimmed == "BEGIN MONTHLY_PREDICTED" {
in_section = true;
is_observed_section = false;
continue;
}
if trimmed.starts_with("END ") {
in_section = false;
continue;
}
if !in_section || trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if line.len() < 124 {
continue;
}
match Self::parse_line(line, line_number) {
Ok(record) => {
let jd_key = record.jd_midnight.to_bits();
if is_observed_section {
observed_jds.insert(jd_key);
records.push(record);
} else if !observed_jds.contains(&jd_key) {
records.push(record);
}
}
Err(_) => {
if is_observed_section {
}
continue;
}
}
}
if records.is_empty() {
return Err(CssiParseError::NoData);
}
records.sort_by(|a, b| a.jd_midnight.partial_cmp(&b.jd_midnight).unwrap());
Ok(Self { records })
}
fn parse_line(line: &str, line_number: usize) -> Result<CssiDailyRecord, CssiParseError> {
let parse_int =
|start: usize, end: usize, field: &'static str| -> Result<i64, CssiParseError> {
let s = line[start..end].trim();
s.parse::<i64>().map_err(|_| CssiParseError::ParseField {
line_number,
field,
value: s.to_string(),
})
};
let parse_float =
|start: usize, end: usize, field: &'static str| -> Result<f64, CssiParseError> {
let s = line[start..end].trim();
if s.is_empty() {
return Ok(0.0);
}
s.parse::<f64>().map_err(|_| CssiParseError::ParseField {
line_number,
field,
value: s.to_string(),
})
};
let year = parse_int(0, 4, "year")? as i32;
let month = parse_int(4, 7, "month")? as u32;
let day = parse_int(7, 10, "day")? as u32;
let ap_3h = [
parse_int(46, 50, "ap0")? as f64,
parse_int(50, 54, "ap3")? as f64,
parse_int(54, 58, "ap6")? as f64,
parse_int(58, 62, "ap9")? as f64,
parse_int(62, 66, "ap12")? as f64,
parse_int(66, 70, "ap15")? as f64,
parse_int(70, 74, "ap18")? as f64,
parse_int(74, 78, "ap21")? as f64,
];
let ap_daily = parse_int(78, 82, "ap_daily")? as f64;
let f107_obs = parse_float(112, 118, "f107_obs")?;
let f107_obs_ctr81 = parse_float(118, 124, "f107_obs_ctr81")?;
let jd_midnight = Epoch::from_gregorian(year, month, day, 0, 0, 0.0).jd();
Ok(CssiDailyRecord {
jd_midnight,
year,
month,
day,
ap_3h,
ap_daily,
f107_obs,
f107_obs_ctr81,
})
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
pub fn date_range(&self) -> Option<(Epoch, Epoch)> {
if self.records.is_empty() {
return None;
}
Some((
Epoch::from_jd(self.records.first().unwrap().jd_midnight),
Epoch::from_jd(self.records.last().unwrap().jd_midnight),
))
}
pub fn records(&self) -> &[CssiDailyRecord] {
&self.records
}
pub fn truncate_after(&self, epoch: &Epoch) -> Self {
let jd_cutoff = epoch.jd();
let records: Vec<CssiDailyRecord> = self
.records
.iter()
.filter(|r| r.jd_midnight <= jd_cutoff)
.cloned()
.collect();
CssiData { records }
}
}
#[derive(Debug, Clone, Copy)]
pub enum OutOfRangeBehavior {
Clamp,
Panic,
}
#[derive(Clone)]
pub struct CssiSpaceWeather {
data: Arc<CssiData>,
out_of_range: OutOfRangeBehavior,
}
impl CssiSpaceWeather {
pub fn new(data: CssiData) -> Self {
Self {
data: Arc::new(data),
out_of_range: OutOfRangeBehavior::Clamp,
}
}
pub fn from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
let data = CssiData::parse(&text)?;
Ok(Self::new(data))
}
pub fn with_out_of_range(mut self, behavior: OutOfRangeBehavior) -> Self {
self.out_of_range = behavior;
self
}
pub fn data(&self) -> &CssiData {
&self.data
}
pub fn into_data(self) -> CssiData {
Arc::try_unwrap(self.data).unwrap_or_else(|arc| (*arc).clone())
}
}
fn ap_at_offset(
records: &[CssiDailyRecord],
day_idx: usize,
current_slot: usize,
slots_back: usize,
) -> f64 {
let total_current = day_idx * 8 + current_slot;
if slots_back > total_current {
return records[0].ap_daily;
}
let total_target = total_current - slots_back;
let target_day = total_target / 8;
let target_slot = total_target % 8;
records[target_day].ap_3h[target_slot]
}
impl SpaceWeatherProvider for CssiSpaceWeather {
fn get(&self, epoch: &Epoch) -> SpaceWeather {
let jd = epoch.jd();
let records = &self.data.records;
let idx = match records.binary_search_by(|r| r.jd_midnight.partial_cmp(&jd).unwrap()) {
Ok(i) => i,
Err(i) => {
if i == 0 {
match self.out_of_range {
OutOfRangeBehavior::Clamp => 0,
OutOfRangeBehavior::Panic => panic!(
"epoch JD {jd} is before CSSI data range (starts JD {})",
records[0].jd_midnight
),
}
} else {
i - 1
}
}
};
let idx = idx.min(records.len() - 1);
if jd > records.last().unwrap().jd_midnight + 1.0 {
match self.out_of_range {
OutOfRangeBehavior::Clamp => {} OutOfRangeBehavior::Panic => panic!(
"epoch JD {jd} is after CSSI data range (ends JD {})",
records.last().unwrap().jd_midnight
),
}
}
let day = &records[idx];
let ut_hours = (jd - day.jd_midnight) * 24.0;
let current_slot = (ut_hours / 3.0).floor().clamp(0.0, 7.0) as usize;
let ap_array = [
day.ap_daily,
ap_at_offset(records, idx, current_slot, 0), ap_at_offset(records, idx, current_slot, 1), ap_at_offset(records, idx, current_slot, 2), ap_at_offset(records, idx, current_slot, 3), (4..=11)
.map(|s| ap_at_offset(records, idx, current_slot, s))
.sum::<f64>()
/ 8.0,
(12..=19)
.map(|s| ap_at_offset(records, idx, current_slot, s))
.sum::<f64>()
/ 8.0,
];
let f107_daily = if idx > 0 {
records[idx - 1].f107_obs
} else {
day.f107_obs
};
let f107_avg = if day.f107_obs_ctr81 > 0.0 {
day.f107_obs_ctr81
} else {
f107_daily
};
SpaceWeather {
f107_daily,
f107_avg,
ap_daily: day.ap_daily,
ap_3hour_history: ap_array,
}
}
}
#[cfg(feature = "fetch")]
mod fetch_impl {
use super::*;
use std::time::{Duration, SystemTime};
const CELESTRAK_SW_URL: &str = "https://celestrak.org/SpaceData/SW-Last5Years.txt";
const DEFAULT_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
impl CssiSpaceWeather {
pub fn fetch(max_age: Option<Duration>) -> Result<Self, Box<dyn std::error::Error>> {
let max_age = max_age.unwrap_or(DEFAULT_MAX_AGE);
let cache_path = cache_file_path()?;
if let Ok(metadata) = std::fs::metadata(&cache_path)
&& let Ok(modified) = metadata.modified()
&& SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::MAX)
< max_age
{
eprintln!("Using cached space weather data: {}", cache_path.display());
let text = std::fs::read_to_string(&cache_path)?;
let data = CssiData::parse(&text)?;
return Ok(Self::new(data));
}
eprintln!("Downloading space weather data from CelesTrak...");
let body = ureq::get(CELESTRAK_SW_URL)
.call()
.map_err(|e| format!("HTTP request failed: {e}"))?
.body_mut()
.read_to_string()
.map_err(|e| format!("Failed to read response body: {e}"))?;
let data = CssiData::parse(&body)?;
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&cache_path, &body)?;
eprintln!("Cached {} records to {}", data.len(), cache_path.display());
Ok(Self::new(data))
}
pub fn fetch_default() -> Result<Self, Box<dyn std::error::Error>> {
Self::fetch(None)
}
}
fn cache_file_path() -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
let home = std::env::var("HOME").map_err(|_| "HOME environment variable not set")?;
Ok(std::path::PathBuf::from(home)
.join(".cache")
.join("orts")
.join("SW-Last5Years.txt"))
}
}
#[cfg(test)]
mod tests {
use super::*;
const CSSI_FRAGMENT: &str = "\
DATATYPE CssiSpaceWeather
VERSION 1.2
# Test data
NUM_OBSERVED_POINTS 3
BEGIN OBSERVED
2024 01 01 2567 1 7 7 3 3 7 3 3 3 37 3 3 2 2 3 2 2 2 2 0.1 1 52 144.2 0 155.9 155.0 148.7 161.5 160.4
2024 01 02 2567 2 3 7 3 7 3 0 0 3 27 2 3 2 3 2 0 0 2 2 0.0 0 46 152.3 0 155.6 155.2 158.2 161.1 160.8
2024 01 03 2567 3 0 3 3 3 3 3 7 7 30 0 2 2 2 2 2 3 3 2 0.0 0 47 157.0 0 155.6 155.4 161.3 161.0 161.0
END OBSERVED
";
#[test]
fn parse_cssi_fragment() {
let data = CssiData::parse(CSSI_FRAGMENT).unwrap();
assert_eq!(data.len(), 3);
let r0 = &data.records()[0];
assert_eq!(r0.year, 2024);
assert_eq!(r0.month, 1);
assert_eq!(r0.day, 1);
assert_eq!(r0.ap_3h, [3.0, 3.0, 2.0, 2.0, 3.0, 2.0, 2.0, 2.0]);
assert!((r0.ap_daily - 2.0).abs() < 0.01);
assert!((r0.f107_obs - 148.7).abs() < 0.1);
assert!((r0.f107_obs_ctr81 - 161.5).abs() < 0.1);
let r2 = &data.records()[2];
assert_eq!(r2.year, 2024);
assert_eq!(r2.month, 1);
assert_eq!(r2.day, 3);
assert!((r2.f107_obs - 161.3).abs() < 0.1);
}
#[test]
fn parse_empty_gives_error() {
let result = CssiData::parse("# empty file\n");
assert!(matches!(result, Err(CssiParseError::NoData)));
}
#[test]
fn date_range() {
let data = CssiData::parse(CSSI_FRAGMENT).unwrap();
let (first, last) = data.date_range().unwrap();
let dt_first = first.to_datetime();
assert_eq!(dt_first.year, 2024);
assert_eq!(dt_first.month, 1);
assert_eq!(dt_first.day, 1);
let dt_last = last.to_datetime();
assert_eq!(dt_last.year, 2024);
assert_eq!(dt_last.month, 1);
assert_eq!(dt_last.day, 3);
}
#[test]
fn provider_lookup_mid_day() {
let data = CssiData::parse(CSSI_FRAGMENT).unwrap();
let provider = CssiSpaceWeather::new(data);
let epoch = Epoch::from_gregorian(2024, 1, 2, 12, 0, 0.0);
let sw = provider.get(&epoch);
assert!((sw.ap_daily - 2.0).abs() < 0.01);
assert!((sw.f107_daily - 148.7).abs() < 0.1);
assert!((sw.f107_avg - 161.1).abs() < 0.1);
}
#[test]
fn provider_3hr_slot_mapping() {
let data = CssiData::parse(CSSI_FRAGMENT).unwrap();
let provider = CssiSpaceWeather::new(data);
let epoch = Epoch::from_gregorian(2024, 1, 2, 1, 30, 0.0);
let sw = provider.get(&epoch);
assert!((sw.ap_3hour_history[1] - 2.0).abs() < 0.01);
assert!((sw.ap_3hour_history[2] - 2.0).abs() < 0.01);
}
#[test]
fn provider_clamp_before_data() {
let data = CssiData::parse(CSSI_FRAGMENT).unwrap();
let provider = CssiSpaceWeather::new(data);
let epoch = Epoch::from_gregorian(2023, 12, 31, 12, 0, 0.0);
let sw = provider.get(&epoch);
assert!((sw.ap_daily - 2.0).abs() < 0.01);
}
#[test]
fn provider_clamp_after_data() {
let data = CssiData::parse(CSSI_FRAGMENT).unwrap();
let provider = CssiSpaceWeather::new(data);
let epoch = Epoch::from_gregorian(2024, 1, 10, 12, 0, 0.0);
let sw = provider.get(&epoch);
assert!((sw.ap_daily - 2.0).abs() < 0.01);
}
#[test]
fn predicted_section_parsed() {
let text = "\
DATATYPE CssiSpaceWeather
VERSION 1.2
BEGIN OBSERVED
2024 01 01 2567 1 7 7 3 3 7 3 3 3 37 3 3 2 2 3 2 2 2 2 0.1 1 52 144.2 0 155.9 155.0 148.7 161.5 160.4
END OBSERVED
BEGIN DAILY_PREDICTED
2024 01 02 2567 2 3 7 3 7 3 0 0 3 27 2 3 2 3 2 0 0 2 2 0.0 0 46 152.3 0 155.6 155.2 158.2 161.1 160.8
END DAILY_PREDICTED
";
let data = CssiData::parse(text).unwrap();
assert_eq!(data.len(), 2);
}
#[test]
fn observed_takes_precedence_over_predicted() {
let text = "\
DATATYPE CssiSpaceWeather
VERSION 1.2
BEGIN OBSERVED
2024 01 01 2567 1 7 7 3 3 7 3 3 3 37 3 3 2 2 3 2 2 2 2 0.1 1 52 144.2 0 155.9 155.0 148.7 161.5 160.4
END OBSERVED
BEGIN DAILY_PREDICTED
2024 01 01 2567 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.0 0 0 100.0 0 100.0 100.0 100.0 100.0 100.0
END DAILY_PREDICTED
";
let data = CssiData::parse(text).unwrap();
assert_eq!(data.len(), 1);
assert!((data.records()[0].f107_obs - 148.7).abs() < 0.1);
}
#[test]
fn ap_history_averaging() {
let data = CssiData::parse(CSSI_FRAGMENT).unwrap();
let provider = CssiSpaceWeather::new(data);
let epoch = Epoch::from_gregorian(2024, 1, 3, 12, 0, 0.0);
let sw = provider.get(&epoch);
let expected_avg = (0.0 + 2.0 + 0.0 + 0.0 + 2.0 + 3.0 + 2.0 + 3.0) / 8.0;
assert!(
(sw.ap_3hour_history[5] - expected_avg).abs() < 0.01,
"ap_array[5] = {}, expected {}",
sw.ap_3hour_history[5],
expected_avg
);
}
}