diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 03aca25c..1adecfa0 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -333,7 +333,7 @@ defmodule Microwaveprop.Propagation do defp point_forecast_from_store(band_mhz, lat, lon, now) do band_mhz |> ScoresFile.list_valid_times() - |> Enum.filter(fn t -> DateTime.compare(t, now) != :lt end) + |> forecast_window(now) |> Enum.map(&point_forecast_entry(&1, band_mhz, lat, lon)) |> Enum.reject(&is_nil/1) end @@ -347,7 +347,7 @@ defmodule Microwaveprop.Propagation do defp point_forecast_from_cache(band_mhz, lat, lon, now, cached_times) do cached_times - |> Enum.filter(fn t -> DateTime.compare(t, now) != :lt end) + |> forecast_window(now) |> Enum.map(fn t -> case ScoreCache.fetch_point(band_mhz, t, lat, lon) do {:ok, score} -> %{valid_time: t, score: score} @@ -357,6 +357,20 @@ defmodule Microwaveprop.Propagation do |> Enum.reject(&is_nil/1) end + # Select the set of valid_times the forecast chart should render. + # Mirrors `available_valid_times`: keep everything from one hour + # before now onward so the most recent analysis hour (typically + # ~30–60 min behind wall clock due to HRRR publishing lag) sits at + # the left edge of the chart as "now". When every hour on disk is + # older than that cutoff, fall back to just the newest entry so the + # chart can still render a single data point. + defp forecast_window([], _now), do: [] + + defp forecast_window(times, now) do + cutoff = DateTime.add(now, -3600, :second) + filter_or_latest(times, cutoff) + end + defp snap_to_grid(lat, lon) do step = Grid.step() {Float.round(Float.round(lat / step) * step, 3), Float.round(Float.round(lon / step) * step, 3)} diff --git a/test/microwaveprop/propagation_test.exs b/test/microwaveprop/propagation_test.exs index ab960df3..a51c3abc 100644 --- a/test/microwaveprop/propagation_test.exs +++ b/test/microwaveprop/propagation_test.exs @@ -318,15 +318,37 @@ defmodule Microwaveprop.PropagationTest do ] = result end - test "filters past valid_times out" do - past = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second) + test "filters valid_times older than one hour but keeps recent past as the 'now' anchor" do + # Two hours old: dropped. Thirty minutes old: kept as the + # leftmost "now" point. One hour future: kept. + stale = DateTime.utc_now() |> DateTime.add(-2 * 3600, :second) |> DateTime.truncate(:second) + recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second) future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second) - ScoreCache.put(10_000, past, [%{lat: 32.875, lon: -97.0, score: 60}]) + ScoreCache.put(10_000, stale, [%{lat: 32.875, lon: -97.0, score: 40}]) + ScoreCache.put(10_000, recent, [%{lat: 32.875, lon: -97.0, score: 60}]) ScoreCache.put(10_000, future, [%{lat: 32.875, lon: -97.0, score: 80}]) result = Propagation.point_forecast(10_000, 32.875, -97.0) - assert [%{valid_time: ^future, score: 80}] = result + + assert [ + %{valid_time: ^recent, score: 60}, + %{valid_time: ^future, score: 80} + ] = result + end + + test "falls back to the single latest valid_time when everything is older than one hour" do + # HRRR just cut out (nothing fresh); we still want the chart to + # render one point at the newest time we have, matching + # available_valid_times semantics. + older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second) + newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second) + + ScoreCache.put(10_000, older, [%{lat: 32.875, lon: -97.0, score: 30}]) + ScoreCache.put(10_000, newer, [%{lat: 32.875, lon: -97.0, score: 45}]) + + result = Propagation.point_forecast(10_000, 32.875, -97.0) + assert [%{valid_time: ^newer, score: 45}] = result end test "snaps coordinates to the nearest grid point" do @@ -343,6 +365,50 @@ defmodule Microwaveprop.PropagationTest do end end + describe "point_forecast/3 from store" do + test "includes recent past valid_times and falls back to the latest when stale" do + # Three .ntms files on disk: one 2h old (dropped by cutoff), + # one 30min old (kept as "now"), one 1h future (kept). + stale = DateTime.utc_now() |> DateTime.add(-2 * 3600, :second) |> DateTime.truncate(:second) + recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second) + future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second) + + Enum.each([{stale, 40}, {recent, 60}, {future, 80}], fn {t, score} -> + Propagation.replace_scores( + [%{lat: 32.875, lon: -97.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}], + t + ) + end) + + # Bypass the cache so we go through the store-backed path. + ScoreCache.clear() + + result = Propagation.point_forecast(10_000, 32.875, -97.0) + + assert [ + %{valid_time: ^recent, score: 60}, + %{valid_time: ^future, score: 80} + ] = result + end + + test "returns just the latest store entry when every hour is stale" do + older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second) + newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second) + + Enum.each([{older, 30}, {newer, 45}], fn {t, score} -> + Propagation.replace_scores( + [%{lat: 32.875, lon: -97.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}], + t + ) + end) + + ScoreCache.clear() + + result = Propagation.point_forecast(10_000, 32.875, -97.0) + assert [%{valid_time: ^newer, score: 45}] = result + end + end + describe "available_valid_times/1" do test "reads from the on-disk .ntms store" do valid_time = DateTime.truncate(DateTime.utc_now(), :second)