use std::ops::ControlFlow;
use chrono::{DateTime, Datelike as _, Timelike as _, Utc};
use crate::fetch::{FetchError, FetchProgress, FetchSpec, FetchStats, fetch_to_grib2_with_url};
const GEFS_BUCKET: &str = "https://noaa-gefs-pds.s3.amazonaws.com";
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GefsMember(pub u8);
impl GefsMember {
pub const CONTROL: Self = Self(0);
pub fn is_valid(self) -> bool {
self.0 <= 30
}
pub fn filename_prefix(self) -> String {
if self.0 == 0 {
"gec00".to_owned()
} else {
format!("gep{:02}", self.0)
}
}
}
pub fn stride_sample_members(k: usize) -> Vec<GefsMember> {
if k == 0 {
return Vec::new();
}
let k = k.min(31);
let mut out = Vec::with_capacity(k);
out.push(GefsMember::CONTROL);
if k == 1 {
return out;
}
let perturbed = (k - 1) as f64;
for i in 0..(k - 1) {
let idx = ((i as f64 + 0.5) * 30.0 / perturbed).floor() as u8 + 1;
out.push(GefsMember(idx.min(30)));
}
out
}
pub fn gefs_url_for_frame(cycle: DateTime<Utc>, forecast_hour: u32, member: GefsMember) -> String {
let yyyymmdd = format!("{:04}{:02}{:02}", cycle.year(), cycle.month(), cycle.day());
let hh = format!("{:02}", cycle.hour());
let prefix = member.filename_prefix();
format!(
"{GEFS_BUCKET}/gefs.{yyyymmdd}/{hh}/atmos/pgrb2sp25/\
{prefix}.t{hh}z.pgrb2s.0p25.f{forecast_hour:03}"
)
}
pub fn fetch_member_to_grib2<W: std::io::Write>(
spec: &FetchSpec,
member: GefsMember,
out: &mut W,
progress: impl FnMut(FetchProgress) -> ControlFlow<()>,
) -> Result<FetchStats, FetchError> {
fetch_to_grib2_with_url(spec, out, progress, move |cycle, fh| {
gefs_url_for_frame(cycle, fh, member)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_member_filename_is_gec00() {
assert_eq!(GefsMember::CONTROL.filename_prefix(), "gec00");
assert_eq!(GefsMember(0).filename_prefix(), "gec00");
}
#[test]
fn perturbed_members_zero_pad_to_two_digits() {
assert_eq!(GefsMember(1).filename_prefix(), "gep01");
assert_eq!(GefsMember(9).filename_prefix(), "gep09");
assert_eq!(GefsMember(10).filename_prefix(), "gep10");
assert_eq!(GefsMember(30).filename_prefix(), "gep30");
}
#[test]
fn member_validity_is_bounded_at_thirty() {
assert!(GefsMember(0).is_valid());
assert!(GefsMember(30).is_valid());
assert!(!GefsMember(31).is_valid());
}
#[test]
fn stride_sample_zero_returns_empty() {
assert!(stride_sample_members(0).is_empty());
}
#[test]
fn stride_sample_one_returns_only_control() {
assert_eq!(stride_sample_members(1), vec![GefsMember::CONTROL]);
}
#[test]
fn stride_sample_default_ten_spans_the_perturbed_range() {
let s = stride_sample_members(10);
assert_eq!(s.len(), 10);
assert_eq!(s[0], GefsMember::CONTROL);
let perturbed: Vec<u8> = s[1..].iter().map(|m| m.0).collect();
assert!(perturbed.iter().all(|&i| (1..=30).contains(&i)));
assert!(perturbed.windows(2).all(|w| w[0] < w[1]));
assert!(perturbed.first().copied().unwrap_or(0) <= 5);
assert!(perturbed.last().copied().unwrap_or(0) >= 25);
}
#[test]
fn stride_sample_clamps_above_thirty_one() {
assert_eq!(stride_sample_members(40).len(), 31);
}
#[test]
fn url_construction_matches_gefs_bucket_layout() {
use chrono::TimeZone as _;
let cycle = Utc.with_ymd_and_hms(2026, 5, 20, 6, 0, 0).unwrap();
let url = gefs_url_for_frame(cycle, 3, GefsMember(7));
assert_eq!(
url,
"https://noaa-gefs-pds.s3.amazonaws.com/gefs.20260520/06/atmos/pgrb2sp25/\
gep07.t06z.pgrb2s.0p25.f003",
);
}
#[test]
fn url_for_control_member_uses_gec00_prefix() {
use chrono::TimeZone as _;
let cycle = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let url = gefs_url_for_frame(cycle, 0, GefsMember::CONTROL);
assert!(url.contains("/gec00.t00z.pgrb2s.0p25.f000"));
}
}