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:
parent
240bc30df8
commit
70e65ba034
6 changed files with 111 additions and 183 deletions
|
|
@ -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()]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue