use super::{bundled_f107_store, resolve_f107, F107Store, SolarActivitySource};
use crate::components::airglow::units::SolarFluxUnits;
use chrono::{DateTime, NaiveDate};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use tempoch::{Time, UTC};
#[derive(Debug, Clone)]
pub(crate) struct SolarActivityValueCache {
source: SolarActivitySource,
values: Arc<RwLock<HashMap<NaiveDate, SolarFluxUnits>>>,
volatile_dates: HashSet<NaiveDate>,
transitions: Arc<[Time<UTC>]>,
}
impl SolarActivityValueCache {
pub(crate) fn new(source: &SolarActivitySource) -> crate::Result<Self> {
let store = match source {
SolarActivitySource::Explicit(_) => None,
SolarActivitySource::Dataset(store) => Some(store.as_ref()),
SolarActivitySource::Automatic => Some(bundled_f107_store()?),
};
let transitions: Vec<_> = store.into_iter().flat_map(transition_instants).collect();
let volatile_dates = transitions
.iter()
.filter_map(|t| t.to_chrono().map(|dt| dt.date_naive()))
.collect();
Ok(Self {
source: source.clone(),
values: Arc::new(RwLock::new(HashMap::new())),
volatile_dates,
transitions: transitions.into(),
})
}
pub(crate) fn transitions(&self) -> &[Time<UTC>] {
&self.transitions
}
pub(crate) fn value_at(&self, time: Time<UTC>) -> crate::Result<SolarFluxUnits> {
let date = time
.to_chrono()
.expect("F10.7 resolution requires a chrono-representable UTC instant")
.date_naive();
if !self.volatile_dates.contains(&date) {
if let Some(value) = self
.values
.read()
.expect("solar cache lock poisoned")
.get(&date)
{
return Ok(*value);
}
}
let value = resolve_f107(time, &self.source)?.value;
if !self.volatile_dates.contains(&date) {
self.values
.write()
.expect("solar cache lock poisoned")
.insert(date, value);
}
Ok(value)
}
}
fn transition_instants(store: &F107Store) -> Vec<Time<UTC>> {
store
.records
.iter()
.flat_map(|record| {
[
record.forecast_issued_at_utc.as_deref(),
record.retrieved_at_utc.as_deref(),
]
})
.flatten()
.chain(store.retrieved_at_utc.as_deref())
.filter_map(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|date_time| Time::<UTC>::from_chrono(date_time.with_timezone(&chrono::Utc)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone, Utc};
fn transition_dates(store: &F107Store) -> HashSet<NaiveDate> {
transition_instants(store)
.iter()
.map(|t| t.to_chrono().unwrap().date_naive())
.collect()
}
#[test]
fn transition_dates_are_normalized_to_utc() {
let mut store = bundled_f107_store().unwrap().clone();
let mut record = store
.records
.first()
.expect("bundled F10.7 store must contain records")
.clone();
record.forecast_issued_at_utc = None;
record.retrieved_at_utc = Some("2026-01-01T23:30:00-02:00".into());
store.records = vec![record];
let transitions = transition_dates(&store);
let local_offset_date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let utc_date = NaiveDate::from_ymd_opt(2026, 1, 2).unwrap();
assert!(!transitions.contains(&local_offset_date));
assert!(transitions.contains(&utc_date));
}
#[test]
fn cached_values_match_full_resolution_across_stable_and_transition_dates() {
let source = SolarActivitySource::Automatic;
let cache = SolarActivityValueCache::new(&source).unwrap();
let transitions = transition_dates(bundled_f107_store().unwrap());
assert!(!transitions.is_empty());
let start = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap();
let mut checked_transition = false;
let mut checked_stable = false;
for hour in (0..365 * 24).step_by(6) {
let time = Time::<UTC>::from_chrono(start + Duration::hours(hour));
let date = time.to_chrono().unwrap().date_naive();
let cached = cache.value_at(time).unwrap();
let exact = resolve_f107(time, &source).unwrap().value;
assert_eq!(cached, exact, "cache changed F10.7 at {date}");
checked_transition |= transitions.contains(&date);
checked_stable |= !transitions.contains(&date);
}
assert!(checked_transition);
assert!(checked_stable);
}
}