use super::record::{parse_date, parse_datetime, F107Kind, F107Record};
use super::store::F107Store;
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MonthlyCompleteness {
CompleteObserved,
CompleteForecast,
ProvisionalObservedPlusForecast,
OfficialMonthlyPrediction,
Climatology,
}
impl MonthlyCompleteness {
pub const fn as_str(self) -> &'static str {
match self {
Self::CompleteObserved => "complete-observed",
Self::CompleteForecast => "complete-forecast",
Self::ProvisionalObservedPlusForecast => "provisional-observed-plus-forecast",
Self::OfficialMonthlyPrediction => "official-monthly-prediction",
Self::Climatology => "climatology",
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct MonthlyF107Evidence {
pub value_sfu: f64,
pub method: MonthlyCompleteness,
pub month_start: NaiveDate,
pub month_end: NaiveDate,
pub observed_days: u32,
pub forecast_days: u32,
pub total_days: u32,
pub provider: String,
pub forecast_issued_at_utc: Option<String>,
pub retrieved_at_utc: Option<String>,
pub record: F107Record,
pub resolution_step: &'static str,
}
pub(crate) fn month_bounds_for(date: NaiveDate) -> (NaiveDate, NaiveDate) {
let start = NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month start");
let end = if date.month() == 12 {
NaiveDate::from_ymd_opt(date.year() + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(date.year(), date.month() + 1, 1)
}
.expect("valid next month")
- Duration::days(1);
(start, end)
}
pub(crate) fn days_in_month(date: NaiveDate) -> u32 {
let (start, end) = month_bounds_for(date);
(end - start).num_days() as u32 + 1
}
pub(crate) fn is_finalized_monthly_observation(
record: &F107Record,
evidence_as_of: NaiveDate,
) -> bool {
if record.kind != F107Kind::Observed {
return false;
}
if record.cadence.as_deref() != Some("monthly") {
return false;
}
if record.product.contains("month-to-date") || record.product.contains("provisional") {
return false;
}
let Ok((_, month_end)) = record.validity_window() else {
return false;
};
month_end < evidence_as_of
}
pub fn forecast_issued_not_after(
issued_at_utc: &str,
requested_at: NaiveDateTime,
) -> Result<bool, String> {
let issued =
parse_datetime(issued_at_utc, "forecast_issued_at_utc").map_err(|e| e.to_string())?;
Ok(issued <= requested_at)
}
fn parse_requested_at(requested_date: NaiveDate, requested_at: NaiveDateTime) -> NaiveDateTime {
let _ = requested_date;
requested_at
}
pub fn resolve_monthly_evidence(
store: &F107Store,
requested_date: NaiveDate,
requested_at: NaiveDateTime,
) -> Result<MonthlyF107Evidence, String> {
let _ = parse_requested_at(requested_date, requested_at);
let (month_start, month_end) = month_bounds_for(requested_date);
let total_days = days_in_month(requested_date);
if let Some(evidence) =
try_complete_observed(store, requested_date, month_start, month_end, total_days)?
{
return Ok(evidence);
}
if let Some(evidence) = try_provisional_observed_plus_forecast(
store,
requested_date,
requested_at,
month_start,
month_end,
total_days,
)? {
return Ok(evidence);
}
if let Some(evidence) = try_complete_45_day_forecast_mean(
store,
requested_date,
requested_at,
month_start,
month_end,
total_days,
)? {
return Ok(evidence);
}
if let Some(evidence) = try_official_monthly_prediction(
store,
requested_date,
requested_at,
month_start,
month_end,
total_days,
)? {
return Ok(evidence);
}
Ok(MonthlyF107Evidence {
value_sfu: store.climatology_sfu,
method: MonthlyCompleteness::Climatology,
month_start,
month_end,
observed_days: 0,
forecast_days: 0,
total_days,
provider: "nsb".into(),
forecast_issued_at_utc: None,
retrieved_at_utc: store.retrieved_at_utc.clone(),
record: super::record::climatology_record(
requested_date,
store.climatology_sfu,
"documented-climatology-fallback",
),
resolution_step: "climatology-fallback",
})
}
fn try_complete_observed(
store: &F107Store,
requested_date: NaiveDate,
month_start: NaiveDate,
month_end: NaiveDate,
total_days: u32,
) -> Result<Option<MonthlyF107Evidence>, String> {
let evidence_as_of = store
.retrieved_at_utc
.as_deref()
.and_then(|value| {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.naive_utc().date())
})
.or_else(|| {
store
.records
.iter()
.filter_map(|r| r.retrieved_at_utc.as_deref())
.filter_map(|value| {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.naive_utc().date())
})
.max()
})
.unwrap_or(requested_date);
let mut matches = Vec::new();
for record in &store.records {
if !is_finalized_monthly_observation(record, evidence_as_of) {
continue;
}
if !record.covers(requested_date).map_err(|e| e.to_string())? {
continue;
}
matches.push(record);
}
if matches.is_empty() {
return Ok(None);
}
matches.sort_by_key(|record| {
(
validity_span_days(record).unwrap_or(u32::MAX),
std::cmp::Reverse(record.product.as_str()),
)
});
let record = matches[0].clone();
Ok(Some(MonthlyF107Evidence {
value_sfu: record.value_sfu,
method: MonthlyCompleteness::CompleteObserved,
month_start,
month_end,
observed_days: total_days,
forecast_days: 0,
total_days,
provider: record.provider.clone(),
forecast_issued_at_utc: None,
retrieved_at_utc: record.retrieved_at_utc.clone(),
record,
resolution_step: "monthly-observed-complete",
}))
}
fn try_complete_45_day_forecast_mean(
store: &F107Store,
requested_date: NaiveDate,
requested_at: NaiveDateTime,
month_start: NaiveDate,
month_end: NaiveDate,
total_days: u32,
) -> Result<Option<MonthlyF107Evidence>, String> {
let by_day = collect_time_valid_45_day_by_day(store, requested_date, requested_at)?;
if by_day.len() as u32 != total_days {
return Ok(None);
}
let mut day = month_start;
while day <= month_end {
if !by_day.contains_key(&day) {
return Ok(None);
}
day += Duration::days(1);
}
Ok(Some(build_forecast_month_mean(
&by_day,
month_start,
month_end,
total_days,
MonthlyCompleteness::CompleteForecast,
"45-day-forecast-monthly-mean",
"short-range-forecast-monthly-mean-complete",
0,
total_days,
)?))
}
fn try_provisional_observed_plus_forecast(
store: &F107Store,
requested_date: NaiveDate,
requested_at: NaiveDateTime,
month_start: NaiveDate,
month_end: NaiveDate,
total_days: u32,
) -> Result<Option<MonthlyF107Evidence>, String> {
if month_end < requested_date {
return Ok(None);
}
let mut by_day: BTreeMap<NaiveDate, F107Record> = BTreeMap::new();
let mut observed_days = 0u32;
let mut forecast_days = 0u32;
for record in &store.records {
if record.kind != F107Kind::Observed || record.cadence.as_deref() != Some("daily") {
continue;
}
let day = parse_date(&record.date, "date").map_err(|e| e.to_string())?;
if day.year() != requested_date.year() || day.month() != requested_date.month() {
continue;
}
if day > requested_date {
continue;
}
record.validate().map_err(|e| e.to_string())?;
if by_day.insert(day, record.clone()).is_none() {
observed_days += 1;
}
}
let forecast_by_day = collect_time_valid_45_day_by_day(store, requested_date, requested_at)?;
for day_offset in 0..total_days {
let day = month_start + Duration::days(i64::from(day_offset));
if day <= requested_date {
if !by_day.contains_key(&day) {
return Ok(None); }
continue;
}
let Some(forecast) = forecast_by_day.get(&day) else {
return Ok(None); };
by_day.insert(day, forecast.clone());
forecast_days += 1;
}
if by_day.len() as u32 != total_days {
return Ok(None);
}
if observed_days == 0 {
return Ok(None);
}
let mut evidence = build_forecast_month_mean(
&by_day,
month_start,
month_end,
total_days,
MonthlyCompleteness::ProvisionalObservedPlusForecast,
"current-month-observed-plus-forecast-mean",
"provisional-current-month-observed-plus-forecast",
observed_days,
forecast_days,
)?;
evidence.record.kind = F107Kind::Forecast;
evidence.observed_days = observed_days;
evidence.forecast_days = forecast_days;
Ok(Some(evidence))
}
fn try_official_monthly_prediction(
store: &F107Store,
requested_date: NaiveDate,
requested_at: NaiveDateTime,
month_start: NaiveDate,
month_end: NaiveDate,
total_days: u32,
) -> Result<Option<MonthlyF107Evidence>, String> {
let mut matches = Vec::new();
for record in &store.records {
if record.kind != F107Kind::Forecast {
continue;
}
if record.cadence.as_deref() != Some("monthly") {
continue;
}
if record.product.contains("45-day") {
continue;
}
if !record.covers(requested_date).map_err(|e| e.to_string())? {
continue;
}
if !forecast_evidence_not_after(record, requested_at)? {
continue;
}
matches.push(record);
}
if matches.is_empty() {
return Ok(None);
}
matches.sort_by(|a, b| {
b.forecast_issued_at_utc
.cmp(&a.forecast_issued_at_utc)
.then_with(|| b.retrieved_at_utc.cmp(&a.retrieved_at_utc))
.then_with(|| a.product.cmp(&b.product))
.then_with(|| a.value_sfu.to_bits().cmp(&b.value_sfu.to_bits()))
});
let record = matches[0].clone();
Ok(Some(MonthlyF107Evidence {
value_sfu: record.value_sfu,
method: MonthlyCompleteness::OfficialMonthlyPrediction,
month_start,
month_end,
observed_days: 0,
forecast_days: 0,
total_days,
provider: record.provider.clone(),
forecast_issued_at_utc: record.forecast_issued_at_utc.clone(),
retrieved_at_utc: record.retrieved_at_utc.clone(),
record,
resolution_step: "monthly-solar-cycle-forecast",
}))
}
fn forecast_evidence_not_after(
record: &F107Record,
requested_at: NaiveDateTime,
) -> Result<bool, String> {
if let Some(issued) = record.forecast_issued_at_utc.as_deref() {
return forecast_issued_not_after(issued, requested_at);
}
let Some(retrieved) = record.retrieved_at_utc.as_deref() else {
return Ok(false);
};
let retrieved_dt = parse_datetime(retrieved, "retrieved_at_utc").map_err(|e| e.to_string())?;
Ok(retrieved_dt <= requested_at)
}
fn collect_time_valid_45_day_by_day(
store: &F107Store,
requested_date: NaiveDate,
requested_at: NaiveDateTime,
) -> Result<BTreeMap<NaiveDate, F107Record>, String> {
let year = requested_date.year();
let month = requested_date.month();
let mut by_day: BTreeMap<NaiveDate, F107Record> = BTreeMap::new();
for record in &store.records {
if record.kind != F107Kind::Forecast {
continue;
}
if record.product != "45-day-forecast" {
continue;
}
if record.cadence.as_deref() != Some("daily") {
continue;
}
let Some(issued) = record.forecast_issued_at_utc.as_deref() else {
continue;
};
if !forecast_issued_not_after(issued, requested_at)? {
continue;
}
let day = parse_date(&record.date, "date").map_err(|e| e.to_string())?;
if day.year() != year || day.month() != month {
continue;
}
record.validate().map_err(|e| e.to_string())?;
match by_day.get(&day) {
None => {
by_day.insert(day, record.clone());
}
Some(prior) => {
if record.forecast_issued_at_utc > prior.forecast_issued_at_utc {
by_day.insert(day, record.clone());
}
}
}
}
Ok(by_day)
}
#[allow(clippy::too_many_arguments)]
fn build_forecast_month_mean(
by_day: &BTreeMap<NaiveDate, F107Record>,
month_start: NaiveDate,
month_end: NaiveDate,
total_days: u32,
method: MonthlyCompleteness,
product: &str,
resolution_step: &'static str,
observed_days: u32,
forecast_days: u32,
) -> Result<MonthlyF107Evidence, String> {
let days: Vec<_> = by_day.values().collect();
let mean = days.iter().map(|r| r.value_sfu).sum::<f64>() / days.len() as f64;
if !mean.is_finite() || mean <= 0.0 {
return Err("monthly aggregation produced a non-positive mean".into());
}
let issued = days
.iter()
.filter_map(|r| r.forecast_issued_at_utc.as_ref())
.max()
.cloned();
let provider = days[0].provider.clone();
let retrieved = days
.iter()
.filter_map(|r| r.retrieved_at_utc.as_ref())
.max()
.cloned();
let source_locator = days[0].source_locator.clone();
let provenance = format!(
"derived aggregation={} method={} observed_days={observed_days} forecast_days={forecast_days} total_days={total_days} complete={} source={}",
"arithmetic-calendar-month-mean",
method.as_str(),
by_day.len() as u32 == total_days,
source_locator.unwrap_or_else(|| "45-day-forecast".into()),
);
let record = F107Record {
date: month_start.format("%Y-%m-%d").to_string(),
value_sfu: mean,
kind: F107Kind::Forecast,
provider: provider.clone(),
product: product.into(),
observation_date: None,
forecast_issued_at_utc: issued.clone(),
retrieved_at_utc: retrieved.clone(),
valid_from: Some(month_start.format("%Y-%m-%d").to_string()),
valid_through: Some(month_end.format("%Y-%m-%d").to_string()),
cadence: Some("monthly".into()),
uncertainty_sfu: None,
range_low_sfu: None,
range_high_sfu: None,
source_locator: Some(provenance),
};
Ok(MonthlyF107Evidence {
value_sfu: mean,
method,
month_start,
month_end,
observed_days,
forecast_days,
total_days,
provider,
forecast_issued_at_utc: issued,
retrieved_at_utc: retrieved,
record,
resolution_step,
})
}
fn validity_span_days(record: &F107Record) -> Option<u32> {
let (from, through) = record.validity_window().ok()?;
Some((through - from).num_days().max(0) as u32)
}