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()