diff --git a/rust/prop_grid_rs/src/hrrr_points.rs b/rust/prop_grid_rs/src/hrrr_points.rs index 65036f2f..ba0fc57e 100644 --- a/rust/prop_grid_rs/src/hrrr_points.rs +++ b/rust/prop_grid_rs/src/hrrr_points.rs @@ -18,7 +18,7 @@ use uuid::Uuid; use crate::decoder::{self, CellValues}; use crate::fetcher::{self, HrrrClient, Product}; -use crate::grid::wgrib2_grid_spec; +use crate::grid::{wgrib2_grid_spec, STEP}; // wgrib2 filter patterns are derived from static SURFACE_MESSAGES / // GRID_PRESSURE_LEVELS tables and never change at runtime. Building @@ -104,11 +104,16 @@ pub async fn process_batch( } // For each requested point, pull the merged cell and build an - // hrrr_profiles row. Skip the point silently if extract_grid - // produced nothing for its snapped key (e.g. off-CONUS). + // hrrr_profiles row. Elixir snaps points to 0.01° (see + // `Weather.round_to_hrrr_grid/2`), but wgrib2 resamples to the 0.125° + // CONUS prop grid — so we must re-snap before keying into `sfc_grid` + // or the lookup misses and the task completes with zero inserts. + // Store the profile at the original requested (lat, lon) so + // `Weather.has_hrrr_profile?/3` (0.07° window) finds it on the + // next backfill tick and flips the contact to `:complete`. let mut inserted = 0u32; for &(lat, lon) in points { - let key = ((lat * 1000.0).round() as i32, (lon * 1000.0).round() as i32); + let key = snap_key(lat, lon); let mut cell: CellValues = sfc_grid.get(&key).cloned().unwrap_or_default(); if let Some(prs) = prs_grid.get(&key) { cell.extend(prs.iter().map(|(k, v)| (k.clone(), *v))); @@ -210,7 +215,43 @@ async fn upsert_profile( Ok(()) } +/// Snap a requested (lat, lon) to the nearest 0.125° grid cell and +/// return its integer-millidegree key. The wgrib2 subprocess resamples +/// HRRR to that grid (see `wgrib2_grid_spec()`), so a request on the +/// HRRR native ~3 km grid (e.g. 44.94, -91.63 after +/// `Weather.round_to_hrrr_grid/2`, which rounds to 0.01°) never matches +/// a 0.125° cell key exactly and the point is silently dropped — +/// producing the "task done with 0 profiles inserted" pattern that +/// wedged 13k+ contacts at `hrrr_status = :queued`. +fn snap_key(lat: f64, lon: f64) -> (i32, i32) { + let snap_lat = (lat / STEP).round() * STEP; + let snap_lon = (lon / STEP).round() * STEP; + ( + (snap_lat * 1000.0).round() as i32, + (snap_lon * 1000.0).round() as i32, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snap_key_on_grid_is_identity() { + assert_eq!(snap_key(45.0, -91.625), (45_000, -91_625)); + assert_eq!(snap_key(25.0, -125.0), (25_000, -125_000)); + } + + #[test] + fn snap_key_rounds_to_nearest_0_125_cell() { + // 44.94 → 45.0 (0.06 away vs 44.875 which is 0.065 away) + assert_eq!(snap_key(44.94, -91.63), (45_000, -91_625)); + // 44.83 → 44.875 (0.045 vs 44.75 which is 0.08) + assert_eq!(snap_key(44.83, -91.5), (44_875, -91_500)); + // 44.73 → 44.75 (0.02 vs 44.625 which is 0.105) + assert_eq!(snap_key(44.73, -91.38), (44_750, -91_375)); + } +} + // Integration tests for process_batch + upsert_profile live in -// tests/hrrr_points_live.rs (network + DB gated). No unit tests here -// — the two public functions are thin glue over fetcher/decoder which -// carry their own test coverage. +// tests/hrrr_points_live.rs (network + DB gated).