From fbbfe037bc1d016cb4a3fa30a0bfb96666b59eb0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 16 Apr 2026 15:10:36 -0500 Subject: [PATCH] fix(status): align NARR done-count tolerance with fetcher snap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit count_narr_done/0 checked narr_profiles.valid_time within qso_timestamp ± 30 min — but NARR analyses live at 3-hour marks (00Z/03Z/.../21Z) and NarrClient.snap_to_analysis_hour/1 floors qso_timestamp to the previous mark before fetching. A QSO at 18:50Z snaps to 18:00Z on the fetcher side; the profile stored at 18:00Z fell outside the widget's [18:20, 19:20] window, so the status page showed 6/208 done when actual coverage was 208/208. Fix: use equality against the Postgres equivalent of the snap (date_trunc('hour', ts) - make_interval(hours => hour % 3)). Comment cites NarrClient.snap_to_analysis_hour/1 so future changes keep them in sync. --- lib/microwaveprop_web/live/status_live.ex | 12 ++++-- .../live/status_live_test.exs | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lib/microwaveprop_web/live/status_live.ex b/lib/microwaveprop_web/live/status_live.ex index 9cbd2b08..82eeaee6 100644 --- a/lib/microwaveprop_web/live/status_live.ex +++ b/lib/microwaveprop_web/live/status_live.ex @@ -110,8 +110,13 @@ defmodule MicrowavepropWeb.StatusLive do ) end - # "Done" means a narr_profiles row within 0.15° + 30 min of the contact, - # matching `Weather.find_nearest_narr/3` lookup tolerance. + # "Done" means a narr_profiles row within 0.15° of the contact and + # stamped at the floor-snapped 3-hour analysis mark. + # + # NARR analyses live at 00/03/06/09/12/15/18/21 UTC. The fetcher calls + # `NarrClient.snap_to_analysis_hour/1`, which floors qso_timestamp to the + # previous 3-hour mark — so a contact at 18:50Z yields a profile valid at + # 18:00Z. The SQL snap below must stay in sync with that Elixir function. defp count_narr_done do coverage_end = NarrClient.coverage_end() @@ -127,7 +132,8 @@ defmodule MicrowavepropWeb.StatusLive do FROM narr_profiles p WHERE p.lat BETWEEN ((c.pos1->>'lat')::float - 0.15) AND ((c.pos1->>'lat')::float + 0.15) AND p.lon BETWEEN ((c.pos1->>'lon')::float - 0.15) AND ((c.pos1->>'lon')::float + 0.15) - AND p.valid_time BETWEEN (c.qso_timestamp - INTERVAL '30 minutes') AND (c.qso_timestamp + INTERVAL '30 minutes') + AND p.valid_time = date_trunc('hour', c.qso_timestamp) + - make_interval(hours => CAST(EXTRACT(HOUR FROM c.qso_timestamp) AS integer) % 3) ) """, [coverage_end] diff --git a/test/microwaveprop_web/live/status_live_test.exs b/test/microwaveprop_web/live/status_live_test.exs index a512b389..70f568d9 100644 --- a/test/microwaveprop_web/live/status_live_test.exs +++ b/test/microwaveprop_web/live/status_live_test.exs @@ -98,6 +98,44 @@ defmodule MicrowavepropWeb.StatusLiveTest do assert narr_row =~ "0 / 1" end + # NARR analyses are archived at 3-hour marks (00/03/06/09/12/15/18/21 UTC). + # `NarrClient.snap_to_analysis_hour/1` floors the QSO timestamp to the + # previous 3-hour mark before fetch. The status numerator query must mirror + # that snap — a naive ±30 min window around the raw qso_timestamp misses + # the stored profile whenever the contact lands more than 30 min past the + # last analysis hour. + test "NARR progress counts contacts whose qso_timestamp doesn't land on a 3-hour analysis mark", + %{conn: conn} do + {:ok, _} = + %Contact{} + |> Contact.changeset(%{ + station1: "W5OFF", + station2: "K5OFF", + qso_timestamp: ~U[2010-06-15 18:50:00Z], + mode: "CW", + band: Decimal.new("1296"), + grid1: "EM12", + grid2: "EM00", + pos1: %{"lat" => 33.0, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("300") + }) + |> Ecto.Changeset.change(%{hrrr_status: :unavailable}) + |> Repo.insert() + + {:ok, _} = + Repo.insert(%NarrProfile{ + valid_time: ~U[2010-06-15 18:00:00Z], + lat: 33.0, + lon: -97.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/status") + narr_row = lv |> element(~s|[data-progress-key="narr"]|) |> render() + assert narr_row =~ "1 / 1" + end + # NARR eligibility is defined by qso_timestamp, not hrrr_status. A # pre-2014 contact whose hrrr_status is still :queued (the ping-pong # window before reconcile flips it) must still be counted — otherwise