fix(path): read HRRR points from on-disk profile store

The summary card's "0 / 9 points" came from `Weather.find_nearest_hrrr`
only checking the `hrrr_profiles` DB table, which is populated solely
by per-QSO enrichment within ±0.07° / ±1 h. Arbitrary paths off any
recently-enriched contact got nothing.

Switch to ProfilesFile.read_point as the primary source — the hourly
chain publishes full-CONUS f00..f18 cells there — and keep the
DB-table query as the fallback when the on-disk store is empty or
doesn't cover a point. Cell shape is flattened so the template and
build_scoring/6 stay shape-agnostic.
This commit is contained in:
Graham McIntire 2026-04-25 15:19:20 -05:00
parent 332308b9c3
commit 2e60e65e2f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.PathLive do
alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Radio.CallsignClient
@ -251,14 +252,24 @@ defmodule MicrowavepropWeb.PathLive do
# HRRR profiles along path — 9 evenly-spaced samples so mid-path
# ducts that endpoint-only queries miss show up in the duct count.
# Source preference: the on-disk grid profile store (full-CONUS
# coverage from the hourly chain) is checked first; the per-QSO
# `hrrr_profiles` DB table is only the fallback for points the
# current chain hasn't published yet.
now = DateTime.utc_now()
midlat = (src.lat + dst.lat) / 2
midlon = (src.lon + dst.lon) / 2
profile_valid_time = latest_profile_valid_time(now)
hrrr_points =
src
|> path_sample_points(dst, 9)
|> Task.async_stream(&label_hrrr_point(&1, now), max_concurrency: 9, timeout: 10_000, on_timeout: :kill_task)
|> Task.async_stream(&label_hrrr_point(&1, now, profile_valid_time),
max_concurrency: 9,
timeout: 10_000,
on_timeout: :kill_task
)
|> Enum.flat_map(fn
{:ok, nil} -> []
{:ok, point} -> [point]
@ -394,13 +405,50 @@ defmodule MicrowavepropWeb.PathLive do
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
end
defp label_hrrr_point({label, lat, lon}, now) do
case Weather.find_nearest_hrrr(lat, lon, now) do
nil -> nil
profile -> %{label: label, profile: profile}
defp label_hrrr_point({label, lat, lon}, now, profile_valid_time) do
case profile_valid_time && ProfilesFile.read_point(profile_valid_time, lat, lon) do
%{} = cell ->
%{label: label, profile: profile_from_cell(cell, profile_valid_time)}
_ ->
case Weather.find_nearest_hrrr(lat, lon, now) do
nil -> nil
profile -> %{label: label, profile: profile}
end
end
end
# The most recent on-disk profile file at or before `now`. Falls back
# to the earliest available file if everything cached is in the future
# (cold-start state during a missed chain). Returns `nil` when the
# store is empty — caller then drops to the DB-table fallback.
defp latest_profile_valid_time(now) do
case ProfilesFile.list_valid_times() do
[] -> nil
times -> pick_latest_at_or_before(times, now)
end
end
defp pick_latest_at_or_before(times, now) do
case Enum.filter(times, fn t -> DateTime.compare(t, now) != :gt end) do
[] -> List.first(times)
past -> Enum.max(past, DateTime)
end
end
# ProfilesFile cells nest the per-cell weather under `:profile`; the
# template + scoring code expect a flat HrrrProfile-shaped map. Lift
# the inner fields up and stamp `lat`/`lon`/`valid_time` so downstream
# code is shape-agnostic.
defp profile_from_cell(cell, valid_time) do
inner = Map.get(cell, :profile, %{}) || %{}
inner
|> Map.put_new(:lat, Map.get(cell, :lat))
|> Map.put_new(:lon, Map.get(cell, :lon))
|> Map.put_new(:valid_time, valid_time)
end
# Linear interpolation along the great-circle-approximate path. Good
# enough at the path-calculator's typical range (<2000 km); for longer
# paths the path deviates from a rhumb line but we're picking