From 70e65ba034e7b540e8a86505cf5702a4acac6cf7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 19 Apr 2026 08:17:40 -0500 Subject: [PATCH] refactor(map): drop the 7-day outlook strip, fix missing Analysis, trim timeline - Remove the 7-day outlook strip and daily_outlook_at/3. It was derived from point_forecast, which is capped at HRRR's 18-hour horizon, so it never had 7 days of data to show. - factors_for: fall back to the nearest persisted analysis profile at or before the requested time. Only f00 hours persist profiles, so clicking any other hour used to yield an empty Analysis and factor table. After the recent cursor fix lands 'Now' on a forecast hour more often, this regressed further. - available_valid_times/1: cap the forward horizon to 18h from now. Leftover valid_times from prior cycles used to pile on the timeline without adding information. --- .../prom_ex/instrument_plugin.ex | 6 -- lib/microwaveprop/propagation.ex | 101 +++++++++--------- lib/microwaveprop_web/live/map_live.ex | 61 ----------- .../propagation/daily_outlook_test.exs | 59 ---------- test/microwaveprop/propagation_test.exs | 55 ++++++++++ test/microwaveprop_web/live/map_live_test.exs | 12 +-- 6 files changed, 111 insertions(+), 183 deletions(-) delete mode 100644 test/microwaveprop/propagation/daily_outlook_test.exs diff --git a/lib/microwaveprop/prom_ex/instrument_plugin.ex b/lib/microwaveprop/prom_ex/instrument_plugin.ex index 45cd56e4..ef73b3c3 100644 --- a/lib/microwaveprop/prom_ex/instrument_plugin.ex +++ b/lib/microwaveprop/prom_ex/instrument_plugin.ex @@ -254,12 +254,6 @@ defmodule Microwaveprop.PromEx.InstrumentPlugin do measurement: :duration, unit: {:native, :millisecond}, reporter_options: [buckets: default_buckets()] - ), - distribution("microwaveprop.propagation.daily_outlook_at.duration.milliseconds", - event_name: [:microwaveprop, :propagation, :daily_outlook_at, :stop], - measurement: :duration, - unit: {:native, :millisecond}, - reporter_options: [buckets: default_buckets()] ) ] ) diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 1f7f92b3..10a12169 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -221,18 +221,28 @@ defmodule Microwaveprop.Propagation do the past, but always includes the most recent valid_time so there's always data to display. """ + # HRRR forecast horizon: f00..f18 covers the next 18 hours from cycle + # time. Anything beyond that in the score store is a leftover from a + # stale cycle and clutters the timeline without adding information. + @hrrr_forecast_horizon_hours 18 + @spec available_valid_times(non_neg_integer()) :: [DateTime.t()] def available_valid_times(band_mhz) do - cutoff = DateTime.add(DateTime.utc_now(), -3600, :second) + now = DateTime.utc_now() + past_cutoff = DateTime.add(now, -3600, :second) + future_cutoff = DateTime.add(now, @hrrr_forecast_horizon_hours * 3600, :second) case ScoresFile.list_valid_times(band_mhz) do [] -> [] - times -> filter_or_latest(times, cutoff) + times -> filter_or_latest(times, past_cutoff, future_cutoff) end end - defp filter_or_latest(times, cutoff) do - fresh = Enum.filter(times, fn t -> DateTime.compare(t, cutoff) != :lt end) + defp filter_or_latest(times, past_cutoff, future_cutoff) do + fresh = + Enum.filter(times, fn t -> + DateTime.compare(t, past_cutoff) != :lt and DateTime.compare(t, future_cutoff) != :gt + end) if fresh == [] do [Enum.max(times, DateTime)] @@ -331,38 +341,6 @@ defmodule Microwaveprop.Propagation do end end - @doc """ - Returns the per-day peak score at `(lat, lon)` for the next `:days` - UTC days on `:band_mhz`, in ascending date order. Days with no - available forecast valid_times are omitted. - - Backs the 7-day outlook strip on the map — turns the hourly forecast - sparkline into a coarse "is Saturday any good" verdict. - """ - @spec daily_outlook_at(float(), float(), keyword()) :: - [%{date: Date.t(), peak_score: non_neg_integer()}] - def daily_outlook_at(lat, lon, opts \\ []) do - Microwaveprop.Instrument.span([:propagation, :daily_outlook_at], %{}, fn -> - band_mhz = Keyword.get(opts, :band_mhz, 10_000) - days = Keyword.get(opts, :days, 7) - today = DateTime.to_date(DateTime.utc_now()) - horizon = Date.add(today, days - 1) - - band_mhz - |> point_forecast(lat, lon) - |> Enum.group_by(fn entry -> - DateTime.to_date(entry.valid_time) - end) - |> Enum.filter(fn {date, _} -> - Date.compare(date, today) != :lt and Date.compare(date, horizon) != :gt - end) - |> Enum.map(fn {date, entries} -> - %{date: date, peak_score: entries |> Enum.map(& &1.score) |> Enum.max()} - end) - |> Enum.sort_by(& &1.date, Date) - end) - end - @doc "Get scores across all forecast hours for a single grid point (for sparkline)." @spec point_forecast(non_neg_integer(), float(), float()) :: [%{valid_time: DateTime.t(), score: non_neg_integer()}] @@ -415,8 +393,9 @@ defmodule Microwaveprop.Propagation do defp forecast_window([], _now), do: [] defp forecast_window(times, now) do - cutoff = DateTime.add(now, -3600, :second) - filter_or_latest(times, cutoff) + past_cutoff = DateTime.add(now, -3600, :second) + future_cutoff = DateTime.add(now, @hrrr_forecast_horizon_hours * 3600, :second) + filter_or_latest(times, past_cutoff, future_cutoff) end defp snap_to_grid(lat, lon) do @@ -466,26 +445,50 @@ defmodule Microwaveprop.Propagation do end # Rebuild the factor breakdown for a clicked grid cell by rescoring - # the persisted HRRR profile. Forecast hours don't persist profiles - # (see PropagationGridWorker.process_forecast_hour) so they just get - # an empty map, which the JS popup tolerates by omitting the - # breakdown block. + # the persisted HRRR profile. Only analysis hours (f00) persist + # profiles, so forecast hours fall back to the most recent analysis + # profile at or before the requested time — the atmospheric breakdown + # still explains what's driving the score even if sampled an hour or + # two earlier. defp factors_for(band_mhz, valid_time, lat, lon) do case ProfilesFile.read_point(valid_time, lat, lon) do + nil -> factors_from_fallback_profile(band_mhz, valid_time, lat, lon) + profile -> factors_from_profile(band_mhz, valid_time, profile, lat, lon) + end + end + + defp factors_from_fallback_profile(band_mhz, valid_time, lat, lon) do + case latest_profile_time_at_or_before(valid_time) do nil -> %{} - profile -> - profile - |> score_grid_point(valid_time, lat, lon) - |> Enum.find(fn r -> r.band_mhz == band_mhz end) - |> case do - %{factors: factors} when is_map(factors) -> factors - _ -> %{} + fallback_time -> + case ProfilesFile.read_point(fallback_time, lat, lon) do + nil -> %{} + profile -> factors_from_profile(band_mhz, fallback_time, profile, lat, lon) end end end + defp factors_from_profile(band_mhz, valid_time, profile, lat, lon) do + profile + |> score_grid_point(valid_time, lat, lon) + |> Enum.find(fn r -> r.band_mhz == band_mhz end) + |> case do + %{factors: factors} when is_map(factors) -> factors + _ -> %{} + end + end + + defp latest_profile_time_at_or_before(%DateTime{} = valid_time) do + ProfilesFile.list_valid_times() + |> Enum.filter(&(DateTime.compare(&1, valid_time) != :gt)) + |> case do + [] -> nil + past -> Enum.max(past, DateTime) + end + end + @doc "Get the latest valid_time across all bands." @spec latest_valid_time() :: DateTime.t() | nil def latest_valid_time do diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 80aa283c..9c8b548a 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -87,7 +87,6 @@ defmodule MicrowavepropWeb.MapLive do outside_conus: outside_conus?(visitor), grid_visible: false, radar_visible: false, - daily_outlook: Propagation.daily_outlook_at(center.lat, center.lon, band_mhz: selected_band, days: 7), deploy_iso: Calendar.strftime(build_ts, "%Y-%m-%d %H:%M UTC"), deploy_ago: format_deploy_ago(build_ts) )} @@ -160,15 +159,6 @@ defmodule MicrowavepropWeb.MapLive do |> LiveStash.stash_assigns([:selected_band, :selected_time]) |> push_event("update_scores", %{scores: scores}) |> push_event("update_band_info", %{band_info: band_info(band)}) - |> assign( - :daily_outlook, - Propagation.daily_outlook_at( - socket.assigns.bounds["south"] + (socket.assigns.bounds["north"] - socket.assigns.bounds["south"]) / 2, - socket.assigns.bounds["west"] + (socket.assigns.bounds["east"] - socket.assigns.bounds["west"]) / 2, - band_mhz: band, - days: 7 - ) - ) |> push_timeline() {:noreply, socket} @@ -475,53 +465,6 @@ defmodule MicrowavepropWeb.MapLive do end end - # Outlook helpers. Day label is the 3-letter weekday ("Sat", "Sun"); - # badge class paints the card background based on the peak score so - # the strip reads as a rough heatmap at a glance. - defp outlook_day_label(%Date{} = date), do: Calendar.strftime(date, "%a") - - defp outlook_bg_class(score) when is_integer(score) do - cond do - score >= 80 -> "bg-emerald-500/30 border-emerald-400/50" - score >= 65 -> "bg-lime-500/25 border-lime-400/50" - score >= 50 -> "bg-amber-500/25 border-amber-400/50" - score >= 33 -> "bg-orange-500/25 border-orange-400/50" - true -> "bg-rose-500/25 border-rose-400/50" - end - end - - defp outlook_bg_class(_), do: "bg-base-300/30 border-base-300/50" - - attr :id, :string, required: true - attr :outlook, :list, required: true - - defp daily_outlook_strip(assigns) do - ~H""" -
- <%= if @outlook == [] do %> -
- Extended outlook not yet available. -
- <% else %> -
7-day outlook
-
-
- {outlook_day_label(entry.date)} - {entry.peak_score} -
-
- <% end %> -
- """ - end - attr :id, :string, required: true attr :status, :map, required: true attr :progress, :any, default: nil @@ -700,8 +643,6 @@ defmodule MicrowavepropWeb.MapLive do - <.daily_outlook_strip id="daily-outlook-mobile" outlook={@daily_outlook} /> - - <.daily_outlook_strip id="daily-outlook-desktop" outlook={@daily_outlook} /> - <%!-- Grid toggle --%>