use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum F107Kind {
Observed,
Forecast,
Climatology,
Explicit,
}
impl F107Kind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Observed => "observed",
Self::Forecast => "forecast",
Self::Climatology => "climatology",
Self::Explicit => "explicit",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct F107Record {
pub date: String,
pub value_sfu: f64,
pub kind: F107Kind,
pub provider: String,
pub product: String,
#[serde(default)]
pub observation_date: Option<String>,
#[serde(default)]
pub forecast_issued_at_utc: Option<String>,
#[serde(default)]
pub retrieved_at_utc: Option<String>,
#[serde(default)]
pub valid_from: Option<String>,
#[serde(default)]
pub valid_through: Option<String>,
#[serde(default)]
pub cadence: Option<String>,
#[serde(default)]
pub uncertainty_sfu: Option<f64>,
#[serde(default)]
pub range_low_sfu: Option<f64>,
#[serde(default)]
pub range_high_sfu: Option<f64>,
#[serde(default)]
pub source_locator: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{0}")]
pub struct F107ValidationError(String);
impl F107ValidationError {
pub(crate) fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl F107Record {
pub fn validate(&self) -> Result<(), F107ValidationError> {
if !self.value_sfu.is_finite() || self.value_sfu <= 0.0 {
return Err(F107ValidationError::new(format!(
"value_sfu must be finite and positive, got {}",
self.value_sfu
)));
}
let date = parse_date(&self.date, "date")?;
if self.provider.trim().is_empty() {
return Err(F107ValidationError::new("provider must be non-empty"));
}
if self.product.trim().is_empty() {
return Err(F107ValidationError::new("product must be non-empty"));
}
for (label, optional) in [
("observation_date", &self.observation_date),
("valid_from", &self.valid_from),
("valid_through", &self.valid_through),
] {
if let Some(value) = optional {
parse_date(value, label)?;
}
}
if let Some(issued) = &self.forecast_issued_at_utc {
parse_datetime(issued, "forecast_issued_at_utc")?;
}
if let Some(retrieved) = &self.retrieved_at_utc {
parse_datetime(retrieved, "retrieved_at_utc")?;
}
for (label, optional) in [
("uncertainty_sfu", self.uncertainty_sfu),
("range_low_sfu", self.range_low_sfu),
("range_high_sfu", self.range_high_sfu),
] {
if let Some(value) = optional {
if !value.is_finite() || value < 0.0 {
return Err(F107ValidationError::new(format!(
"{label} must be finite and non-negative"
)));
}
}
}
if let (Some(low), Some(high)) = (self.range_low_sfu, self.range_high_sfu) {
if low > high {
return Err(F107ValidationError::new(
"range_low_sfu must not exceed range_high_sfu",
));
}
}
let valid_from = self
.valid_from
.as_deref()
.map(|value| parse_date(value, "valid_from"))
.transpose()?
.unwrap_or(date);
let valid_through = self
.valid_through
.as_deref()
.map(|value| parse_date(value, "valid_through"))
.transpose()?
.unwrap_or(date);
if valid_from > valid_through {
return Err(F107ValidationError::new(
"valid_from must not follow valid_through",
));
}
match self.kind {
F107Kind::Observed => {
if self.observation_date.is_none() {
return Err(F107ValidationError::new(
"observed records require observation_date",
));
}
if self.forecast_issued_at_utc.is_some() {
return Err(F107ValidationError::new(
"observed records must not set forecast_issued_at_utc",
));
}
}
F107Kind::Forecast => {
if self.observation_date.is_some() {
return Err(F107ValidationError::new(
"forecast records must not set observation_date",
));
}
}
F107Kind::Climatology | F107Kind::Explicit => {}
}
Ok(())
}
pub fn validity_window(&self) -> Result<(NaiveDate, NaiveDate), F107ValidationError> {
let date = parse_date(&self.date, "date")?;
let valid_from = self
.valid_from
.as_deref()
.map(|value| parse_date(value, "valid_from"))
.transpose()?
.unwrap_or(date);
let valid_through = self
.valid_through
.as_deref()
.map(|value| parse_date(value, "valid_through"))
.transpose()?
.unwrap_or(date);
Ok((valid_from, valid_through))
}
pub fn covers(&self, requested: NaiveDate) -> Result<bool, F107ValidationError> {
let (from, through) = self.validity_window()?;
Ok(requested >= from && requested <= through)
}
}
pub(crate) fn parse_date(value: &str, label: &str) -> Result<NaiveDate, F107ValidationError> {
NaiveDate::parse_from_str(value, "%Y-%m-%d")
.map_err(|_| F107ValidationError::new(format!("{label} must be YYYY-MM-DD, got {value:?}")))
}
pub(crate) fn parse_datetime(
value: &str,
label: &str,
) -> Result<NaiveDateTime, F107ValidationError> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
return Ok(dt.naive_utc());
}
NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%SZ")
.or_else(|_| NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.fZ"))
.map_err(|_| {
F107ValidationError::new(format!("{label} must be RFC3339 UTC, got {value:?}"))
})
}
pub fn explicit_record(date: NaiveDate, value_sfu: f64) -> F107Record {
let date = date.format("%Y-%m-%d").to_string();
F107Record {
date: date.clone(),
value_sfu,
kind: F107Kind::Explicit,
provider: "caller".into(),
product: "explicit-override".into(),
observation_date: None,
forecast_issued_at_utc: None,
retrieved_at_utc: None,
valid_from: Some(date.clone()),
valid_through: Some(date),
cadence: None,
uncertainty_sfu: None,
range_low_sfu: None,
range_high_sfu: None,
source_locator: None,
}
}
pub fn climatology_record(date: NaiveDate, value_sfu: f64, notes_product: &str) -> F107Record {
let date = date.format("%Y-%m-%d").to_string();
F107Record {
date: date.clone(),
value_sfu,
kind: F107Kind::Climatology,
provider: "nsb".into(),
product: notes_product.into(),
observation_date: None,
forecast_issued_at_utc: None,
retrieved_at_utc: None,
valid_from: Some(date.clone()),
valid_through: Some(date),
cadence: Some("climatology".into()),
uncertainty_sfu: None,
range_low_sfu: None,
range_high_sfu: None,
source_locator: Some("bundled F10.7 store climatology_sfu".into()),
}
}