satkit 0.18.1

Satellite Toolkit
Documentation
//! Solar Cycle Forecast data from NOAA/SWPC
//!
//! Provides predicted F10.7 solar flux values for future dates,
//! sourced from the NOAA Space Weather Prediction Center's
//! solar cycle prediction JSON endpoint.
//!
//! Used as a fallback when historical space weather data is not
//! available (i.e., for future propagation dates).

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};

/// Errors produced by the [`solar_cycle_forecast`](crate::solar_cycle_forecast)
/// module.
#[derive(Debug, Error)]
pub enum Error {
    /// The cached forecast file is missing on disk.
    #[error("Solar cycle forecast file not found")]
    FileNotFound,

    /// The downloaded JSON document is not the expected array of records.
    #[error("Expected JSON array in solar cycle forecast")]
    NotJsonArray,

    /// A forecast entry is missing its `time-tag` field.
    #[error("Missing time-tag")]
    MissingTimeTag,

    /// A forecast entry is missing its `predicted_f10.7` field.
    #[error("Missing predicted_f10.7")]
    MissingPredictedF107,

    /// The downloaded forecast contains zero records.
    #[error("Downloaded forecast contains no records")]
    EmptyForecast,

    /// Bytes passed to [`init_from_bytes`] were not valid UTF-8 — the
    /// solar-cycle forecast file is a JSON text format.
    #[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),

    /// The crate was built without the `download` feature enabled.
    #[error("satkit was built without the `download` feature")]
    DownloadFeatureDisabled,
}

/// Convenient type alias used throughout the `solar_cycle_forecast` module.
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)?;

        // Parse "YYYY-MM" format
        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()?;
        // Use the 15th of each month as the representative date
        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)
}

/// Module-scope refreshable singleton. Const-initialized as `None`;
/// the lazy default load (best-effort, silent on missing file) runs at
/// most once via [`DEFAULT_LOAD_ONCE`]. [`init_from_bytes`] /
/// [`init_from_path`] / [`update`] replace any current contents.
static SOLAR_FORECAST: RwLock<Option<Vec<ForecastRecord>>> = RwLock::new(None);
static DEFAULT_LOAD_ONCE: Once = Once::new();

/// Best-effort default load on first read. Failures are silent — the
/// forecast is a fallback for future propagation dates; if it's missing,
/// callers get `None` from [`get_predicted_f107`].
fn ensure_default_loaded() {
    DEFAULT_LOAD_ONCE.call_once(|| {
        if let Ok(records) = load_forecast() {
            *SOLAR_FORECAST.write().unwrap() = Some(records);
        }
    });
}

/// Initialize the solar-cycle-forecast singleton from an in-memory byte
/// buffer.
///
/// The bytes must be a valid NOAA/SWPC `predicted-solar-cycle.json` text
/// document (UTF-8). Always succeeds and replaces any previously loaded
/// data — like spaceweather, this subsystem is intentionally
/// refresh-in-place.
pub fn init_from_bytes(bytes: &[u8]) -> Result<()> {
    let records = parse_forecast_json(std::str::from_utf8(bytes)?)?;
    // Mark the default-load slot as consumed so it doesn't later overwrite.
    DEFAULT_LOAD_ONCE.call_once(|| {});
    *SOLAR_FORECAST.write().unwrap() = Some(records);
    Ok(())
}

/// Initialize the solar-cycle-forecast singleton from a JSON file at
/// `path`.
///
/// Same semantics as [`init_from_bytes`]; always replaces.
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(())
}

/// Get predicted F10.7 solar flux for a given time.
///
/// Linearly interpolates between monthly forecast entries.
///
/// Returns `None` if no forecast data is available or the time
/// is outside the forecast range.
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;
    }

    // Before first entry
    if tm < records[0].date {
        return None;
    }

    // After last entry — use last value
    if tm >= records[records.len() - 1].date {
        return Some(records[records.len() - 1].predicted_f107);
    }

    // Find bracketing entries and interpolate
    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))
}

/// Download the latest solar cycle forecast from NOAA/SWPC.
///
/// Requires the `download` Cargo feature.
#[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)?;

    // Validate before saving
    let records = parse_forecast_json(&contents)?;
    if records.is_empty() {
        return Err(Error::EmptyForecast);
    }

    let path = forecast_path()?;
    std::fs::write(&path, &contents)?;

    // Reload singleton
    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);

        // Midpoint should be ~130
        let mid = Instant::from_date(2026, 4, 15).unwrap();
        let f107 = get_predicted_f107(&mid).unwrap();
        assert!((f107 - 130.0).abs() < 2.0);

        // Before range
        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()
        );
        // F10.7 values should be physically reasonable (50-400)
        for r in &records {
            assert!(
                r.predicted_f107 > 50.0 && r.predicted_f107 < 400.0,
                "Unreasonable F10.7 value: {}",
                r.predicted_f107
            );
        }
    }
}