Let the /path forecast chart start at the current analysis hour

point_forecast used a strict \`>= now\` cutoff, so the leftmost
"now" sample was always dropped — the HRRR publishing lag means the
newest analysis file is typically 30–60 min behind the wall clock,
which made the filter evict the very hour the chart most wanted to
anchor on. If the chain ever fell behind by even one step the whole
forecast went empty and the chart disappeared.

Use the same window as available_valid_times: keep everything from
one hour before now onward, and fall back to the single newest file
when nothing is fresh enough. Both the cache-path and store-path
flows go through a shared forecast_window helper so the behavior
stays in lockstep with the map timeline.
This commit is contained in:
Graham McIntire 2026-04-15 10:46:38 -05:00
parent 30b4baf792
commit e227978f2a
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 86 additions and 6 deletions

View file

@ -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
# ~3060 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)}

View file

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