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.
This commit is contained in:
Graham McIntire 2026-04-19 08:17:40 -05:00
parent 240bc30df8
commit 70e65ba034
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 111 additions and 183 deletions

View file

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

View file

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

View file

@ -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"""
<div id={@id} class="px-1">
<%= if @outlook == [] do %>
<div class="text-[11px] opacity-60 italic leading-tight">
Extended outlook not yet available.
</div>
<% else %>
<div class="text-[11px] opacity-70 mb-1 leading-tight">7-day outlook</div>
<div class="grid grid-cols-7 gap-1">
<div
:for={entry <- @outlook}
class={[
"flex flex-col items-center justify-center rounded border py-1 text-xs leading-tight",
outlook_bg_class(entry.peak_score)
]}
title={"#{entry.date} peak score: #{entry.peak_score}"}
>
<span class="opacity-80">{outlook_day_label(entry.date)}</span>
<span class="font-semibold tabular-nums">{entry.peak_score}</span>
</div>
</div>
<% end %>
</div>
"""
end
attr :id, :string, required: true
attr :status, :map, required: true
attr :progress, :any, default: nil
@ -700,8 +643,6 @@ defmodule MicrowavepropWeb.MapLive do
</ul>
</div>
<.daily_outlook_strip id="daily-outlook-mobile" outlook={@daily_outlook} />
<div id="panel-extras" class="hidden flex-col gap-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
@ -842,8 +783,6 @@ defmodule MicrowavepropWeb.MapLive do
</ul>
</div>
<.daily_outlook_strip id="daily-outlook-desktop" outlook={@daily_outlook} />
<%!-- Grid toggle --%>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input

View file

@ -1,59 +0,0 @@
defmodule Microwaveprop.Propagation.DailyOutlookTest do
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.ScoreCache
setup do
ScoreCache.clear()
on_exit(fn -> ScoreCache.clear() end)
:ok
end
describe "daily_outlook_at/3" do
test "returns an empty list when no scores have been written" do
assert Propagation.daily_outlook_at(32.9, -97.0, days: 3) == []
end
test "groups scores by UTC date and picks the peak per day" do
# Use a lat on the grid: snap(32.9) = 32.875 (step 0.125). Write at
# the snapped coords so point_forecast's internal snap-to-grid finds
# the scores on disk.
lat = 32.875
lon = -97.0
band = 10_000
# Day A: 5Z @ 40, 17Z @ 80 → peak 80
# Day B: 5Z @ 60 → peak 60
day_a_1 = ~U[2026-04-20 05:00:00Z]
day_a_2 = ~U[2026-04-20 17:00:00Z]
day_b_1 = ~U[2026-04-21 05:00:00Z]
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: day_a_1, band_mhz: band, score: 40, factors: nil}],
day_a_1
)
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: day_a_2, band_mhz: band, score: 80, factors: nil}],
day_a_2
)
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: day_b_1, band_mhz: band, score: 60, factors: nil}],
day_b_1
)
outlook = Propagation.daily_outlook_at(lat, lon, band_mhz: band, days: 30)
# The helper returns entries in ascending date order.
assert length(outlook) >= 2
day_a = Enum.find(outlook, &(&1.date == ~D[2026-04-20]))
day_b = Enum.find(outlook, &(&1.date == ~D[2026-04-21]))
assert day_a.peak_score == 80
assert day_b.peak_score == 60
end
end
end

View file

@ -245,6 +245,44 @@ defmodule Microwaveprop.PropagationTest do
test "returns nil when the file doesn't exist for the requested point" do
assert Propagation.point_detail(10_000, 25.0, -125.0, ~U[2026-07-15 13:00:00Z]) == nil
end
test "falls back to the latest persisted profile when the requested hour is a forecast hour" do
analysis_time = ~U[2026-07-15 13:00:00Z]
forecast_time = ~U[2026-07-15 15:00:00Z]
lat = 32.75
lon = -97.125
profile = %{
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
wind_u: 3.0,
wind_v: 2.0,
cloud_cover_pct: 15.0,
precip_mm: 0.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
%{"pres" => 975.0, "tmpc" => 22.0, "dwpc" => 15.0, "hght" => 350.0},
%{"pres" => 950.0, "tmpc" => 19.0, "dwpc" => 10.0, "hght" => 600.0}
]
}
# Profile persisted only for the analysis hour (f00); forecast
# hours never persist profiles, so the click detail would
# otherwise come back with an empty factors map.
ProfilesFile.write!(analysis_time, %{{lat, lon} => profile})
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: forecast_time, band_mhz: 10_000, score: 64, factors: nil}],
forecast_time
)
detail = Propagation.point_detail(10_000, lat, lon, forecast_time)
assert detail.score == 64
assert is_map(detail.factors)
assert Map.has_key?(detail.factors, :humidity)
end
end
describe "latest_scores/1" do
@ -422,6 +460,23 @@ defmodule Microwaveprop.PropagationTest do
assert Propagation.available_valid_times(10_000) == [valid_time]
end
test "caps the forward horizon to 18 hours so stale cycles don't clutter the timeline" do
now = DateTime.truncate(DateTime.utc_now(), :second)
in_range = DateTime.add(now, 3600, :second)
past_hrrr_horizon = DateTime.add(now, 25 * 3600, :second)
Enum.each([{in_range, 70}, {past_hrrr_horizon, 50}], fn {t, score} ->
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}],
t
)
end)
times = Propagation.available_valid_times(10_000)
assert in_range in times
refute past_hrrr_horizon in times
end
test "filters valid_times older than the 1-hour cutoff" do
now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -7200, :second)

View file

@ -110,15 +110,11 @@ defmodule MicrowavepropWeb.MapLiveTest do
end
describe "daily outlook strip" do
test "renders the strip in both mobile and desktop sidebars", %{conn: conn} do
test "no longer renders on the main map", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ ~s(id="daily-outlook-mobile")
assert html =~ ~s(id="daily-outlook-desktop")
end
test "shows an empty-state message when no outlook data exists", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "Extended outlook not yet available"
refute html =~ "daily-outlook"
refute html =~ "7-day outlook"
refute html =~ "Extended outlook"
end
end