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