prop/lib/microwaveprop/propagation/pipeline_status.ex
Graham McIntire 160439db31
feat(map): shareable URL params for view/band/time
The main map page now reads band, selected time, and map
center/zoom from URL params and updates the URL as the user pans,
zooms, picks a band, or scrubs the timeline. Pan/zoom use
push_patch replace-mode so browser history isn't spammed, while
band and time changes push fresh entries.

Also fix the 'Up to date · just now ago' pipeline chip — 'just
now' already reads as an instant so the trailing 'ago' is
redundant. Any older duration keeps it.
2026-04-19 08:53:07 -05:00

161 lines
5 KiB
Elixir

defmodule Microwaveprop.Propagation.PipelineStatus do
@moduledoc """
Aggregated status for the propagation update pipeline.
Two inputs drive the chip:
* **Running detection** — queries `oban_jobs` for executing
PropagationGridWorker / AsosAdjustmentWorker rows so the chip
knows when a chain step is in flight.
* **Freshness detection** — reads the newest valid_time from
`Microwaveprop.Propagation.ScoresFile` on disk. The on-disk
files ARE the propagation data now, so the chip's "Up to date ·
Nm ago" and the 2h stale threshold track actual data presence
instead of Oban's `completed_at` timestamp.
"""
import Ecto.Query
alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Repo
@grid_worker "Microwaveprop.Workers.PropagationGridWorker"
@asos_worker "Microwaveprop.Workers.AsosAdjustmentWorker"
@workers [@grid_worker, @asos_worker]
# Scores older than this flip the chip to :stale. Matches the
# FreshnessMonitor threshold — anything past 2h is already a gap
# the pipeline should have caught.
@stale_after_minutes 120
@type state :: :running | :idle | :stale | :unknown
@type worker_tag :: :grid | :asos
@type running_detail :: %{worker: worker_tag(), label: String.t()}
@type t :: %{
state: state(),
label: String.t(),
details: [running_detail()],
last_update_at: DateTime.t() | nil
}
@doc """
Format a forecast-progress payload (as broadcast by
`PropagationGridWorker`) into a short detail line for the running
chip ("through now", "through +3h", …).
The label expresses how far the pipeline has advanced relative to
*now*, not relative to the HRRR run time: the map can scrub the
timeline through this instant. This is the semantic the user expects
when watching the chip advance — HRRR lagging the wall clock by
~2 hours means f00's valid_time is actually "2h ago", and "+Nh" in
worker-frame coordinates is Nh-2 in user-frame coordinates.
Returns `nil` when the payload has no `valid_time` so callers can
fall back to the base detail (e.g. "HRRR run") from `current/0`.
"""
@spec running_detail(map() | nil) :: String.t() | nil
def running_detail(%{valid_time: %DateTime{} = valid_time}) do
diff_minutes = DateTime.diff(valid_time, DateTime.utc_now(), :minute)
"through " <> format_offset(diff_minutes)
end
def running_detail(_), do: nil
defp format_offset(diff) when diff >= -30 and diff <= 30, do: "now"
defp format_offset(diff) when diff > 30 do
hours = div(diff + 30, 60)
"+#{hours}h"
end
defp format_offset(diff) do
hours = div(-diff + 30, 60)
"#{hours}h ago"
end
@spec current() :: t()
def current do
case running_workers() do
[] ->
build_idle_or_stale(latest_data_at())
details ->
%{
state: :running,
label: "Updating propagation",
details: details,
last_update_at: latest_data_at()
}
end
end
# Returns every pipeline worker currently in executing state, in a
# stable grid-before-asos order so the chip sub-lines don't flicker
# as jobs come and go.
defp running_workers do
from(j in "oban_jobs",
where: j.state == "executing" and j.worker in ^@workers,
distinct: j.worker,
select: j.worker
)
|> Repo.all()
|> Enum.sort_by(&worker_sort_key/1)
|> Enum.map(&to_running_detail/1)
end
defp to_running_detail(@grid_worker), do: %{worker: :grid, label: "HRRR run"}
defp to_running_detail(@asos_worker), do: %{worker: :asos, label: "ASOS nudge"}
defp worker_sort_key(@grid_worker), do: 0
defp worker_sort_key(@asos_worker), do: 1
defp worker_sort_key(_), do: 99
# Freshness is derived from the newest ScoresFile on disk. That file
# is the thing the map actually renders from, so its valid_time is
# the most honest answer to "when was the data last updated?".
defp latest_data_at do
ScoresFile.latest_valid_time()
end
defp build_idle_or_stale(nil) do
%{state: :unknown, label: "Propagation status unknown", details: [], last_update_at: nil}
end
defp build_idle_or_stale(%DateTime{} = last) do
age_minutes = max(DateTime.diff(DateTime.utc_now(), last, :minute), 0)
if age_minutes > @stale_after_minutes do
%{
state: :stale,
label: "Propagation data stale · last update #{format_age_with_ago(age_minutes)}",
details: [],
last_update_at: last
}
else
%{
state: :idle,
label: "Up to date · #{format_age_with_ago(age_minutes)}",
details: [],
last_update_at: last
}
end
end
# "just now" reads fine on its own; anything older needs "ago" for
# the duration to make sense.
defp format_age_with_ago(0), do: "just now"
defp format_age_with_ago(n), do: "#{format_age(n)} ago"
defp format_age(1), do: "1m"
defp format_age(n) when n < 60, do: "#{n}m"
defp format_age(n) do
hours = div(n, 60)
mins = rem(n, 60)
if mins == 0, do: "#{hours}h", else: "#{hours}h #{mins}m"
end
end