From 040ebf9d3df9bff8b5572dd2066b51213baf0bd1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 30 Apr 2026 13:46:32 -0500 Subject: [PATCH] fix(weather): snap HRDPS reads to nearest valid_time within 6h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /weather dual-source timeline picks times from HRRR's hourly cadence, but HRDPS publishes 4×/day with multi-hour latency, so the requested time almost never has an exact HRDPS dir on disk. The controller was returning empty rows for HRDPS, leaving the Canadian half of the map blank. Snap to the closest HRDPS valid_time within a 6h window. /weather-ca keeps exact-time semantics in practice because its timeline is built from HRDPS-only listings, so the nearest is always the same time. --- lib/microwaveprop/weather.ex | 12 ++++- lib/microwaveprop/weather/scalar_file.ex | 33 ++++++++++++ .../weather/scalar_file_test.exs | 52 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index 55a059c1..f6d1775f 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -933,10 +933,20 @@ defmodule Microwaveprop.Weather do because the cache mixes HRRR + HRDPS rows by design — caching this separately would double the per-pod memory budget for marginal benefit (Canadian viewport reads are infrequent compared to CONUS). + + The /weather (dual-source) timeline picks valid_times from HRRR's + hourly cadence, but HRDPS publishes 4×/day with multi-hour latency, + so the requested time will frequently miss any on-disk HRDPS dir. + Snapping to the nearest available HRDPS time within a 6 h window + keeps the Canadian overlay rendering slightly stale instead of + disappearing entirely. /weather-ca picks times from HRDPS-only listings + so the exact time always matches and the snap is a no-op. """ + @hrdps_nearest_window_seconds 6 * 3600 + @spec weather_grid_hrdps_at(DateTime.t(), %{optional(String.t()) => float()} | nil) :: [map()] def weather_grid_hrdps_at(%DateTime{} = valid_time, bounds) do - ScalarFile.read_bounds_hrdps(valid_time, bounds) + ScalarFile.read_bounds_hrdps_nearest(valid_time, bounds, @hrdps_nearest_window_seconds) end @doc """ diff --git a/lib/microwaveprop/weather/scalar_file.ex b/lib/microwaveprop/weather/scalar_file.ex index 50ac4f67..fbbadfdd 100644 --- a/lib/microwaveprop/weather/scalar_file.ex +++ b/lib/microwaveprop/weather/scalar_file.ex @@ -191,6 +191,39 @@ defmodule Microwaveprop.Weather.ScalarFile do read_chunks(list_chunk_files_hrdps(valid_time), bounds) end + @doc """ + Read HRDPS rows for the closest available valid_time within + `window_seconds` of `target`. Returns rows from the snapped time, or + `[]` if no HRDPS dir falls inside the window. + + HRDPS publishes 4×/day with ~3-4 h latency, so the /weather timeline + (driven by HRRR's hourly cadence) will frequently request a time that + has no HRDPS dir on disk. Snapping to the nearest available time lets + the Canadian overlay render slightly stale rather than disappear + entirely whenever the user's selected forecast hour misses HRDPS. + """ + @spec read_bounds_hrdps_nearest(DateTime.t(), bounds() | nil, non_neg_integer()) :: [row()] + def read_bounds_hrdps_nearest(%DateTime{} = target, bounds, window_seconds) + when is_integer(window_seconds) and window_seconds >= 0 do + case nearest_hrdps_valid_time(target, window_seconds) do + nil -> [] + vt -> read_chunks(list_chunk_files_hrdps(vt), bounds) + end + end + + defp nearest_hrdps_valid_time(target, window_seconds) do + target_unix = DateTime.to_unix(target) + + list_valid_times_hrdps() + |> Enum.map(fn vt -> {vt, abs(DateTime.to_unix(vt) - target_unix)} end) + |> Enum.filter(fn {_vt, delta} -> delta <= window_seconds end) + |> Enum.min_by(fn {_vt, delta} -> delta end, fn -> nil end) + |> case do + nil -> nil + {vt, _delta} -> vt + end + end + defp read_chunks([], _bounds), do: [] defp read_chunks(files, bounds) do diff --git a/test/microwaveprop/weather/scalar_file_test.exs b/test/microwaveprop/weather/scalar_file_test.exs index ecf62724..bffccb10 100644 --- a/test/microwaveprop/weather/scalar_file_test.exs +++ b/test/microwaveprop/weather/scalar_file_test.exs @@ -246,6 +246,58 @@ defmodule Microwaveprop.Weather.ScalarFileTest do end end + # The /weather (dual-source) timeline picks valid_times from HRRR's + # hourly cadence. HRDPS publishes 4×/day with multi-hour latency, so + # the requested time will frequently miss any HRDPS dir. Snapping to + # the closest HRDPS dir within a window lets the Canadian overlay + # render slightly stale rather than disappear. + describe "read_bounds_hrdps_nearest/3" do + test "returns rows from the exact time when its HRDPS dir exists" do + vt = ~U[2026-04-29 12:00:00Z] + hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0) + + assert [%{lat: 53.0, lon: -60.0, temperature: 8.0}] = + ScalarFile.read_bounds_hrdps_nearest(vt, nil, 6 * 3600) + end + + test "snaps to the closest HRDPS dir within the window when exact time is absent" do + requested = ~U[2026-04-29 14:00:00Z] + closer = ~U[2026-04-29 13:00:00Z] + farther = ~U[2026-04-29 10:00:00Z] + + hand_write_chunk(ScalarFile.dir_for_hrdps(closer), 53.0, -60.0, 7.0) + hand_write_chunk(ScalarFile.dir_for_hrdps(farther), 53.0, -60.0, 99.0) + + assert [%{temperature: 7.0}] = + ScalarFile.read_bounds_hrdps_nearest(requested, nil, 6 * 3600) + end + + test "snaps forward as well as backward (HRDPS forecast can lead requested time)" do + requested = ~U[2026-04-29 12:00:00Z] + future = ~U[2026-04-29 13:00:00Z] + + hand_write_chunk(ScalarFile.dir_for_hrdps(future), 53.0, -60.0, 11.0) + + assert [%{temperature: 11.0}] = + ScalarFile.read_bounds_hrdps_nearest(requested, nil, 6 * 3600) + end + + test "returns [] when no HRDPS dir exists inside the window" do + requested = ~U[2026-04-29 14:00:00Z] + stale = ~U[2026-04-29 02:00:00Z] + + hand_write_chunk(ScalarFile.dir_for_hrdps(stale), 53.0, -60.0, 8.0) + + # 12h gap, 6h window → not eligible. + assert ScalarFile.read_bounds_hrdps_nearest(requested, nil, 6 * 3600) == [] + end + + test "returns [] when no HRDPS dirs exist at all" do + requested = ~U[2026-04-29 14:00:00Z] + assert ScalarFile.read_bounds_hrdps_nearest(requested, nil, 6 * 3600) == [] + end + end + defp hand_write_chunk(dir, lat, lon, temp) do File.mkdir_p!(dir) lat_band = (lat / 5) |> Float.floor() |> trunc()