From 2e60e65e2f3936ff8f9d3547668d547a3e91bba6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 15:19:20 -0500 Subject: [PATCH] fix(path): read HRRR points from on-disk profile store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop_web/live/path_live.ex | 58 ++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index eafc629b..acde7422 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -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