use crate::utils::datadir;
#[cfg(feature = "download")]
use crate::utils::download_to_string;
use crate::{Instant, TimeLike};
use std::num::ParseIntError;
use thiserror::Error;
use std::path::PathBuf;
use std::sync::{Once, RwLock};
#[derive(Debug, Error)]
pub enum Error {
#[error("Solar cycle forecast file not found")]
FileNotFound,
#[error("Expected JSON array in solar cycle forecast")]
NotJsonArray,
#[error("Missing time-tag")]
MissingTimeTag,
#[error("Missing predicted_f10.7")]
MissingPredictedF107,
#[error("Downloaded forecast contains no records")]
EmptyForecast,
#[error("solar-cycle forecast byte buffer is not valid UTF-8: {0}")]
Utf8(#[from] std::str::Utf8Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
InvalidEpoch(#[from] crate::time::InstantError),
#[error(transparent)]
Datadir(#[from] crate::utils::datadir::Error),
#[error(transparent)]
ParseInt(#[from] ParseIntError),
#[error(transparent)]
Download(#[from] crate::utils::download::Error),
#[error("satkit was built without the `download` feature")]
DownloadFeatureDisabled,
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone)]
pub struct ForecastRecord {
pub date: Instant,
pub predicted_f107: f64,
}
fn forecast_path() -> Result<PathBuf> {
Ok(datadir()?.join("predicted-solar-cycle.json"))
}
fn load_forecast() -> Result<Vec<ForecastRecord>> {
let path = forecast_path()?;
if !path.is_file() {
return Err(Error::FileNotFound);
}
let contents = std::fs::read_to_string(&path)?;
parse_forecast_json(&contents)
}
fn parse_forecast_json(contents: &str) -> Result<Vec<ForecastRecord>> {
let parsed: serde_json::Value = serde_json::from_str(contents)?;
let entries = parsed.as_array().ok_or(Error::NotJsonArray)?;
let mut records = Vec::new();
for entry in entries {
let time_tag = entry["time-tag"].as_str().ok_or(Error::MissingTimeTag)?;
let parts: Vec<&str> = time_tag.split('-').collect();
if parts.len() != 2 {
continue;
}
let year: i32 = parts[0].parse()?;
let month: i32 = parts[1].parse()?;
let date = Instant::from_date(year, month, 15)?;
let f107 = entry["predicted_f10.7"]
.as_f64()
.ok_or(Error::MissingPredictedF107)?;
records.push(ForecastRecord {
date,
predicted_f107: f107,
});
}
records.sort_by(|a, b| a.date.partial_cmp(&b.date).unwrap());
Ok(records)
}
static SOLAR_FORECAST: RwLock<Option<Vec<ForecastRecord>>> = RwLock::new(None);
static DEFAULT_LOAD_ONCE: Once = Once::new();
fn ensure_default_loaded() {
DEFAULT_LOAD_ONCE.call_once(|| {
if let Ok(records) = load_forecast() {
*SOLAR_FORECAST.write().unwrap() = Some(records);
}
});
}
pub fn init_from_bytes(bytes: &[u8]) -> Result<()> {
let records = parse_forecast_json(std::str::from_utf8(bytes)?)?;
DEFAULT_LOAD_ONCE.call_once(|| {});
*SOLAR_FORECAST.write().unwrap() = Some(records);
Ok(())
}
pub fn init_from_path(path: &std::path::Path) -> Result<()> {
let records = parse_forecast_json(&std::fs::read_to_string(path)?)?;
DEFAULT_LOAD_ONCE.call_once(|| {});
*SOLAR_FORECAST.write().unwrap() = Some(records);
Ok(())
}
pub fn get_predicted_f107<T: TimeLike>(tm: &T) -> Option<f64> {
let tm = tm.as_instant();
ensure_default_loaded();
let lock = SOLAR_FORECAST.read().unwrap();
let records = lock.as_ref()?;
if records.is_empty() {
return None;
}
if tm < records[0].date {
return None;
}
if tm >= records[records.len() - 1].date {
return Some(records[records.len() - 1].predicted_f107);
}
let idx = records.partition_point(|r| r.date <= tm);
if idx == 0 {
return Some(records[0].predicted_f107);
}
let r0 = &records[idx - 1];
let r1 = &records[idx];
let frac = (tm - r0.date).as_seconds() / (r1.date - r0.date).as_seconds();
Some(r0.predicted_f107 + frac * (r1.predicted_f107 - r0.predicted_f107))
}
#[cfg(feature = "download")]
pub fn update() -> Result<()> {
let url = "https://services.swpc.noaa.gov/json/solar-cycle/predicted-solar-cycle.json";
let contents = download_to_string(url)?;
let records = parse_forecast_json(&contents)?;
if records.is_empty() {
return Err(Error::EmptyForecast);
}
let path = forecast_path()?;
std::fs::write(&path, &contents)?;
DEFAULT_LOAD_ONCE.call_once(|| {});
*SOLAR_FORECAST.write().unwrap() = Some(records);
Ok(())
}
#[cfg(not(feature = "download"))]
pub fn update() -> Result<()> {
Err(Error::DownloadFeatureDisabled)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_json() {
let json = r#"[
{"time-tag": "2026-01", "predicted_ssn": 100.0, "predicted_f10.7": 145.0},
{"time-tag": "2026-06", "predicted_ssn": 90.0, "predicted_f10.7": 135.0},
{"time-tag": "2027-01", "predicted_ssn": 80.0, "predicted_f10.7": 125.0}
]"#;
let records = parse_forecast_json(json).unwrap();
assert_eq!(records.len(), 3);
assert!((records[0].predicted_f107 - 145.0).abs() < 1e-6);
assert!((records[2].predicted_f107 - 125.0).abs() < 1e-6);
}
#[test]
fn test_interpolation() {
let json = r#"[
{"time-tag": "2026-01", "predicted_ssn": 100.0, "predicted_f10.7": 140.0},
{"time-tag": "2026-07", "predicted_ssn": 90.0, "predicted_f10.7": 120.0}
]"#;
let records = parse_forecast_json(json).unwrap();
DEFAULT_LOAD_ONCE.call_once(|| {});
*SOLAR_FORECAST.write().unwrap() = Some(records);
let mid = Instant::from_date(2026, 4, 15).unwrap();
let f107 = get_predicted_f107(&mid).unwrap();
assert!((f107 - 130.0).abs() < 2.0);
let early = Instant::from_date(2025, 1, 1).unwrap();
assert!(get_predicted_f107(&early).is_none());
}
#[cfg(feature = "download")]
#[test]
fn test_download_and_parse() {
let url = "https://services.swpc.noaa.gov/json/solar-cycle/predicted-solar-cycle.json";
let contents = crate::utils::download_to_string(url).unwrap();
let records = parse_forecast_json(&contents).unwrap();
assert!(
records.len() > 10,
"Expected at least 10 forecast records, got {}",
records.len()
);
for r in &records {
assert!(
r.predicted_f107 > 50.0 && r.predicted_f107 < 400.0,
"Unreasonable F10.7 value: {}",
r.predicted_f107
);
}
}
}