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