prop/rust/prop_grid_rs/src/hrdps_fetcher.rs
Graham McIntire 46c2d84e5c
feat(rust/hrdps): prop-grid-rs HRDPS branch
Stages 4-5 of the HRDPS plan. Lands the Rust forecast-hour pipeline
for source='hrdps' grid_tasks rows.

What ships:

- `hrdps_fetcher.rs` — sibling of fetcher.rs for ECCC's MSC Datamart
  shape: date-prefixed URLs, one variable per GRIB2 file, concurrent
  fetch + byte-concat into a single multi-record blob. wgrib2 reads
  multi-record files natively so the downstream decoder path is
  unchanged. Mirrors lib/microwaveprop/weather/hrdps_client.ex.
- `grid::hrdps_only_points()` + `grid::hrdps_grid_spec()` — Canadian
  cells (49-60°N, -141 to -52°W) minus the HRRR overlap. Disjoint
  from conus_points by construction.
- `db::TaskSource` enum + `source` column on ClaimedTask. claim_next
  surfaces source for forecast lane dispatch; claim_next_analysis
  filters to source='hrrr' only (HRDPS analysis path is not yet
  ported — would need native-duct + NEXRAD + commercial merges that
  Rust doesn't have for HRDPS).
- `pipeline::run_chain_step_hrdps` — fetch combined blob, decode with
  HRDPS grid spec, drop HRRR-overlap cells via a HashSet of
  hrdps_only_points, back-fill DPT from DEPR (HRDPS publishes DEPR
  as primary), score every band, write `<band>/<iso>.hrdps.prop`.
  Skips per-valid_time profile + scalar artifacts since those would
  clobber HRRR's at the same valid_time — surfacing HRDPS on /weather
  is a separate stage.
- worker.rs dispatches forecast lane on (kind, source) — Hrrr →
  run_chain_step, Hrdps → run_chain_step_hrdps. Analysis stays
  HRRR-only. Worker constructs an HrdpsClient alongside HrrrClient.
- `scores_file::write_atomic_hrdps` writes to `.hrdps.prop` with the
  Canadian grid spec via the new encode_with_spec. Coexists with the
  HRRR `.prop` file for the same (band, valid_time).

163 unit tests pass; backfill_dpt_from_depr handles surface and
pressure-level levels with no double-fill when DPT exists.
2026-04-29 17:29:16 -05:00

312 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! HRDPS (Canadian) fetcher. Sibling of `fetcher.rs` (HRRR) but with
//! ECCC's MSC Datamart shape:
//!
//! * Date-prefixed URL structure
//! (`https://dd.weather.gc.ca/{YYYYMMDD}/WXO-DD/model_hrdps/...`)
//! * **One variable per GRIB2 file** (~3.5 MB each, ~14 files total).
//! No idx / no byte-range partials.
//! * Concurrent per-variable fetch + byte-concatenate into a single
//! multi-record blob — wgrib2 reads multi-record files natively, so
//! the downstream `decoder::extract_grid` path is unchanged from HRRR.
//! * Rotated lat/lon projection handled transparently by wgrib2.
//!
//! 1:1 port of `lib/microwaveprop/weather/hrdps_client.ex`.
use std::time::Duration;
use chrono::{DateTime, Timelike, Utc};
use reqwest::Client;
use tokio::task::JoinSet;
pub const DATAMART_BASE_DEFAULT: &str = "https://dd.weather.gc.ca";
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
pub const MAX_PARALLEL_FETCHES: usize = 4;
/// Pressure levels matching `lib/microwaveprop/weather/hrdps_client.ex`'s
/// `@grid_pressure_levels`. Mirrors HRRR's grid set (1000-700 mb) so
/// SoundingParams.derive sees a comparable refractivity-gradient profile.
pub const HRDPS_GRID_PRESSURE_LEVELS: &[u16] = &[1000, 950, 900, 850, 800, 750, 700];
/// Surface variables we fetch per cycle/forecast hour. Each entry is
/// (internal_atom, msc_var, msc_level) — the MSC level slug becomes part
/// of the filename. Mirrors `HrdpsClient.@surface_vars` in Elixir.
pub const HRDPS_SURFACE_VARS: &[(&str, &str, &str)] = &[
("tmp_2m", "TMP", "AGL-2m"),
("depr_2m", "DEPR", "AGL-2m"),
("dpt_2m", "DPT", "AGL-2m"),
("pres_sfc", "PRES", "Sfc"),
("hpbl_sfc", "HPBL", "Sfc"),
("ugrd_10m", "UGRD", "AGL-10m"),
("vgrd_10m", "VGRD", "AGL-10m"),
("tcdc_sfc", "TCDC", "Sfc"),
];
#[derive(Debug, thiserror::Error)]
pub enum HrdpsFetchError {
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("HRDPS file fetch HTTP {status} for {url}")]
Status { status: u16, url: String },
#[error("HRDPS fetch returned empty body for {url}")]
EmptyBody { url: String },
}
/// Build the MSC Datamart URL for one HRDPS variable file. ECCC reorganized
/// to a date-prefixed structure as of 2026 — the old `/model_hrdps/...`
/// flat root 404s. Filename pattern verified against the live datamart on
/// 2026-04-29.
pub fn hrdps_url(
base: &str,
cycle: DateTime<Utc>,
forecast_hour: u8,
msc_var: &str,
msc_level: &str,
) -> String {
let date = cycle.date_naive();
let date_str = date.format("%Y%m%d").to_string();
let hour_str = format!("{:02}", cycle.hour());
let fff_str = format!("{:03}", forecast_hour);
let trimmed = base.trim_end_matches('/');
format!(
"{trimmed}/{date_str}/WXO-DD/model_hrdps/continental/2.5km/{hour_str}/{fff_str}/\
{date_str}T{hour_str}Z_MSC_HRDPS_{msc_var}_{msc_level}_RLatLon0.0225_PT{fff_str}H.grib2"
)
}
/// Build the variable manifest the fetcher pulls per cycle/forecast hour.
/// Returns `[(msc_var, msc_level)]` in stable order so cargo-test fixtures
/// match production output byte-for-byte. Surface variables come first,
/// then pressure-level (TMP / DEPR / HGT × levels).
pub fn variable_manifest() -> Vec<(&'static str, String)> {
let mut out: Vec<(&'static str, String)> = HRDPS_SURFACE_VARS
.iter()
.map(|(_, var, level)| (*var, level.to_string()))
.collect();
for &level in HRDPS_GRID_PRESSURE_LEVELS {
let level_slug = format!("ISBL_{:04}", level);
out.push(("TMP", level_slug.clone()));
out.push(("DEPR", level_slug.clone()));
out.push(("HGT", level_slug));
}
out
}
#[derive(Debug, Clone)]
pub struct HrdpsClient {
http: Client,
base: String,
}
impl HrdpsClient {
pub fn new(base: impl Into<String>) -> Result<Self, HrdpsFetchError> {
let http = Client::builder()
.timeout(REQUEST_TIMEOUT)
.pool_max_idle_per_host(MAX_PARALLEL_FETCHES)
.build()?;
Ok(Self {
http,
base: base.into(),
})
}
pub fn default_base() -> Result<Self, HrdpsFetchError> {
Self::new(DATAMART_BASE_DEFAULT)
}
pub fn base(&self) -> &str {
&self.base
}
/// Fetch every variable file for `(cycle, forecast_hour)` concurrently,
/// then byte-concatenate into a single multi-record GRIB2 blob. wgrib2
/// reads multi-record files natively, so the returned blob plugs into
/// `decoder::extract_grid` exactly like an HRRR `wrfsfcf`/`wrfprsf`
/// blob would — same `match` pattern works because HRDPS uses the same
/// NCEP-style level strings (`TMP:2 m above ground`, etc.) at the
/// wgrib2 inventory layer regardless of the MSC filename.
pub async fn fetch_combined_blob(
&self,
cycle: DateTime<Utc>,
forecast_hour: u8,
) -> Result<Vec<u8>, HrdpsFetchError> {
let manifest = variable_manifest();
let mut futs: JoinSet<Result<(usize, Vec<u8>), HrdpsFetchError>> = JoinSet::new();
// Cap concurrent fetches at MAX_PARALLEL_FETCHES (datamart isn't
// aggressively rate-limited but ECCC has explicit guidance to
// limit hammer-rate). Index preserves variable order so the
// concatenated output is deterministic.
let manifest_len = manifest.len();
for (idx, (msc_var, msc_level)) in manifest.into_iter().enumerate() {
let client = self.http.clone();
let url = hrdps_url(&self.base, cycle, forecast_hour, msc_var, &msc_level);
futs.spawn(async move {
let bytes = retry_get(&client, &url).await?;
Ok((idx, bytes))
});
}
let mut results: Vec<(usize, Vec<u8>)> = Vec::with_capacity(manifest_len);
while let Some(joined) = futs.join_next().await {
// Per CLAUDE.md: never silently swallow a task failure. A
// single missing variable here means a Canadian dead-zone
// we won't notice for hours.
results.push(joined.expect("hrdps fetch task panicked")?);
}
// Stable ordering by manifest index. wgrib2 doesn't care about
// record order but deterministic concatenation makes byte-level
// golden tests possible.
results.sort_by_key(|(idx, _)| *idx);
let total: usize = results.iter().map(|(_, b)| b.len()).sum();
let mut out = Vec::with_capacity(total);
for (_, bytes) in results {
out.extend_from_slice(&bytes);
}
Ok(out)
}
}
async fn retry_get(client: &Client, url: &str) -> Result<Vec<u8>, HrdpsFetchError> {
for attempt in 0..5 {
match client.get(url).send().await {
Ok(resp) => {
let status = resp.status().as_u16();
if status == 200 {
let bytes = resp.bytes().await?;
if bytes.is_empty() {
return Err(HrdpsFetchError::EmptyBody {
url: url.to_string(),
});
}
return Ok(bytes.to_vec());
}
if !is_retryable_status(status) {
return Err(HrdpsFetchError::Status {
status,
url: url.to_string(),
});
}
tracing::warn!(status, attempt, url, "hrdps retryable status");
}
Err(e) if e.is_connect() || e.is_timeout() => {
tracing::warn!(error = %e, attempt, url, "hrdps transient error");
}
Err(e) => return Err(HrdpsFetchError::Http(e)),
}
tokio::time::sleep(retry_backoff(attempt)).await;
}
Err(HrdpsFetchError::Status {
status: 0,
url: url.to_string(),
})
}
fn is_retryable_status(status: u16) -> bool {
matches!(status, 429 | 500 | 502 | 503 | 504)
}
fn retry_backoff(attempt: u32) -> Duration {
let base_ms = 1000_u64 << attempt;
let jitter = rand_jitter_ms();
Duration::from_millis(base_ms + jitter)
}
// Tiny stdlib-only jitter — avoids pulling in `rand` for one call site.
// Returns 0..=999 ms.
fn rand_jitter_ms() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
nanos % 1_000
}
/// Snap a UTC timestamp to the start of the most recent HRDPS cycle hour
/// (00/06/12/18Z). Always rounds DOWN — never points at a future cycle
/// that may not have published yet.
pub fn nearest_hrdps_cycle(dt: DateTime<Utc>) -> DateTime<Utc> {
let cycle_hour = (dt.hour() / 6) * 6;
dt.with_hour(cycle_hour)
.and_then(|d| d.with_minute(0))
.and_then(|d| d.with_second(0))
.and_then(|d| d.with_nanosecond(0))
.unwrap_or(dt)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn url_matches_live_datamart_format() {
let cycle = Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap();
let url = hrdps_url(DATAMART_BASE_DEFAULT, cycle, 0, "TMP", "AGL-2m");
assert_eq!(
url,
"https://dd.weather.gc.ca/20260429/WXO-DD/model_hrdps/continental/2.5km/12/000/\
20260429T12Z_MSC_HRDPS_TMP_AGL-2m_RLatLon0.0225_PT000H.grib2"
);
}
#[test]
fn url_pads_forecast_hour_to_three_digits() {
let cycle = Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap();
let url = hrdps_url(DATAMART_BASE_DEFAULT, cycle, 24, "TMP", "AGL-2m");
assert!(url.contains("/024/"));
assert!(url.contains("PT024H.grib2"));
}
#[test]
fn url_pads_cycle_hour_to_two_digits() {
let cycle = Utc.with_ymd_and_hms(2026, 4, 29, 6, 0, 0).unwrap();
let url = hrdps_url(DATAMART_BASE_DEFAULT, cycle, 0, "TMP", "AGL-2m");
assert!(url.contains("/06/000/"));
assert!(url.contains("T06Z_"));
}
#[test]
fn pressure_level_url_uses_isbl_padding() {
let cycle = Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap();
let url = hrdps_url(DATAMART_BASE_DEFAULT, cycle, 0, "TMP", "ISBL_0850");
assert!(url.contains("MSC_HRDPS_TMP_ISBL_0850_"));
}
#[test]
fn manifest_covers_surface_and_pressure_set() {
let manifest = variable_manifest();
// 8 surface + 7 pressure levels × 3 vars (TMP/DEPR/HGT) = 29
assert_eq!(manifest.len(), 29);
assert!(manifest.iter().any(|(v, l)| *v == "TMP" && l == "AGL-2m"));
assert!(manifest.iter().any(|(v, l)| *v == "TMP" && l == "ISBL_0850"));
assert!(manifest.iter().any(|(v, l)| *v == "HGT" && l == "ISBL_1000"));
}
#[test]
fn nearest_cycle_snaps_to_six_hour_boundary() {
let dt = Utc.with_ymd_and_hms(2026, 4, 29, 13, 45, 0).unwrap();
let cycle = nearest_hrdps_cycle(dt);
assert_eq!(cycle, Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap());
}
#[test]
fn nearest_cycle_rounds_down_at_boundary() {
// Just inside the 06Z window; mustn't jump forward to 12Z.
let dt = Utc.with_ymd_and_hms(2026, 4, 29, 11, 59, 0).unwrap();
let cycle = nearest_hrdps_cycle(dt);
assert_eq!(cycle, Utc.with_ymd_and_hms(2026, 4, 29, 6, 0, 0).unwrap());
}
#[test]
fn nearest_cycle_idempotent_on_boundary() {
let dt = Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap();
assert_eq!(nearest_hrdps_cycle(dt), dt);
}
}