fix(weather): snap HRDPS reads to nearest valid_time within 6h

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.
This commit is contained in:
Graham McIntire 2026-04-30 13:46:32 -05:00
parent b6fb7d2a13
commit 040ebf9d3d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 96 additions and 1 deletions

View file

@ -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 """

View file

@ -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

View file

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