From 11617e730b224f24d5289f96b5aa439889d93766 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 15 Apr 2026 09:04:54 -0500 Subject: [PATCH] Sync map progress chip to what's actually loadable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Updating propagation +Nh" chip was double-misleading in prod: the label's +Nh was relative to the HRRR run time (which lags wall clock by ~2h), and the progress broadcast fired before the hour was fetched + persisted — so the chip could read "+8h" while the map timeline only extended to +4h. Reframe the label as "through now" / "through +Nh" / "through Nh ago" by computing the offset from valid_time → now in PipelineStatus. running_detail, so it matches the semantic the user reads off the timeline. Move the PubSub broadcast in PropagationGridWorker to fire after Propagation.replace_scores/2 succeeds, so the chip only advances once that forecast hour is readable from ScoresFile. --- .../propagation/pipeline_status.ex | 30 ++++++++++++++++--- .../workers/propagation_grid_worker.ex | 20 ++++++++----- .../propagation/pipeline_status_test.exs | 28 +++++++++++++---- test/microwaveprop_web/live/map_live_test.exs | 22 +++++++------- 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/lib/microwaveprop/propagation/pipeline_status.ex b/lib/microwaveprop/propagation/pipeline_status.ex index 2314245b..0b4a4f27 100644 --- a/lib/microwaveprop/propagation/pipeline_status.ex +++ b/lib/microwaveprop/propagation/pipeline_status.ex @@ -45,16 +45,38 @@ defmodule Microwaveprop.Propagation.PipelineStatus do @doc """ Format a forecast-progress payload (as broadcast by `PropagationGridWorker`) into a short detail line for the running - chip ("now", "+3h", …). + chip ("through now", "through +3h", …). - Returns `nil` when the payload has no `forecast_hour` so callers can + The label expresses how far the pipeline has advanced relative to + *now*, not relative to the HRRR run time: the map can scrub the + timeline through this instant. This is the semantic the user expects + when watching the chip advance — HRRR lagging the wall clock by + ~2 hours means f00's valid_time is actually "2h ago", and "+Nh" in + worker-frame coordinates is Nh-2 in user-frame coordinates. + + Returns `nil` when the payload has no `valid_time` so callers can fall back to the base detail (e.g. "HRRR run") from `current/0`. """ @spec running_detail(map() | nil) :: String.t() | nil - def running_detail(%{forecast_hour: 0}), do: "now" - def running_detail(%{forecast_hour: fh}) when is_integer(fh) and fh > 0, do: "+#{fh}h" + def running_detail(%{valid_time: %DateTime{} = valid_time}) do + diff_minutes = DateTime.diff(valid_time, DateTime.utc_now(), :minute) + "through " <> format_offset(diff_minutes) + end + def running_detail(_), do: nil + defp format_offset(diff) when diff >= -30 and diff <= 30, do: "now" + + defp format_offset(diff) when diff > 30 do + hours = div(diff + 30, 60) + "+#{hours}h" + end + + defp format_offset(diff) do + hours = div(-diff + 30, 60) + "#{hours}h ago" + end + @spec current() :: t() def current do case running_workers() do diff --git a/lib/microwaveprop/workers/propagation_grid_worker.ex b/lib/microwaveprop/workers/propagation_grid_worker.ex index 737b093c..eb6b95c7 100644 --- a/lib/microwaveprop/workers/propagation_grid_worker.ex +++ b/lib/microwaveprop/workers/propagation_grid_worker.ex @@ -157,14 +157,6 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do defp process_forecast_hour(points, run_time, forecast_hour, valid_time) do label = "f#{String.pad_leading(Integer.to_string(forecast_hour), 2, "0")}" - # Emit progress so the map page's pipeline status chip can show - # which forecast hour is currently being scored ("now", "+1h", …). - Phoenix.PubSub.broadcast( - Microwaveprop.PubSub, - "propagation:pipeline", - {:propagation_pipeline_progress, %{forecast_hour: forecast_hour, valid_time: valid_time}} - ) - case timed(label, fn -> HrrrClient.fetch_grid(points, run_time, forecast_hour: forecast_hour) end) do @@ -234,6 +226,18 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do {:ok, count} -> Logger.info("PropagationGrid: #{label} → #{count} scores for #{valid_time}") warm_cache(valid_time) + + # Broadcast progress *after* persistence so the map's + # pipeline chip only advances to "through +Nh" once that + # hour is actually readable from the scores file. Emitting + # this before the fetch would push the chip ahead of the + # map by the full forecast-hour wall time (~10 minutes). + Phoenix.PubSub.broadcast( + Microwaveprop.PubSub, + "propagation:pipeline", + {:propagation_pipeline_progress, %{forecast_hour: forecast_hour, valid_time: valid_time}} + ) + :ok error -> diff --git a/test/microwaveprop/propagation/pipeline_status_test.exs b/test/microwaveprop/propagation/pipeline_status_test.exs index 855c3646..1db9c7ab 100644 --- a/test/microwaveprop/propagation/pipeline_status_test.exs +++ b/test/microwaveprop/propagation/pipeline_status_test.exs @@ -166,18 +166,34 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do end describe "running_detail/1" do - test "forecast hour 0 renders as 'now'" do - assert PipelineStatus.running_detail(%{forecast_hour: 0}) == "now" + test "valid_time within half an hour of now renders as 'through now'" do + now = DateTime.utc_now() + assert PipelineStatus.running_detail(%{valid_time: now}) == "through now" + assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 20 * 60, :second)}) == "through now" + assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, -20 * 60, :second)}) == "through now" end - test "positive forecast hours render as +Nh" do - assert PipelineStatus.running_detail(%{forecast_hour: 1}) == "+1h" - assert PipelineStatus.running_detail(%{forecast_hour: 18}) == "+18h" + test "valid_time in the future renders as 'through +Nh'" do + now = DateTime.utc_now() + + assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 3 * 3600, :second)}) == + "through +3h" + + assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 12 * 3600, :second)}) == + "through +12h" end - test "returns nil when no forecast hour is provided" do + test "valid_time in the past renders as 'through Nh ago'" do + now = DateTime.utc_now() + + assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, -2 * 3600, :second)}) == + "through 2h ago" + end + + test "returns nil when no valid_time is provided" do assert PipelineStatus.running_detail(nil) == nil assert PipelineStatus.running_detail(%{}) == nil + assert PipelineStatus.running_detail(%{forecast_hour: 3}) == nil end end end diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index 85f11733..b8c3717f 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -218,23 +218,25 @@ defmodule MicrowavepropWeb.MapLiveTest do assert html =~ "Updating propagation" assert html =~ ~s(data-pipeline-state="running") - Phoenix.PubSub.broadcast( - Microwaveprop.PubSub, - "propagation:pipeline", - {:propagation_pipeline_progress, %{forecast_hour: 3, valid_time: ~U[2026-04-14 18:00:00Z]}} - ) - - html = render(lv) - assert html =~ "+3h" + future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second) Phoenix.PubSub.broadcast( Microwaveprop.PubSub, "propagation:pipeline", - {:propagation_pipeline_progress, %{forecast_hour: 0, valid_time: ~U[2026-04-14 15:00:00Z]}} + {:propagation_pipeline_progress, %{forecast_hour: 5, valid_time: future}} ) html = render(lv) - assert html =~ "now" + assert html =~ "through +3h" + + Phoenix.PubSub.broadcast( + Microwaveprop.PubSub, + "propagation:pipeline", + {:propagation_pipeline_progress, %{forecast_hour: 2, valid_time: DateTime.utc_now()}} + ) + + html = render(lv) + assert html =~ "through now" end end end