fix(status): align NARR done-count tolerance with fetcher snap

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.
This commit is contained in:
Graham McIntire 2026-04-16 15:10:36 -05:00
parent 432ae83743
commit fbbfe037bc
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 47 additions and 3 deletions

View file

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

View file

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