Hoist commercial link query + tie pipeline chip to ScoresFile

PropagationGridWorker.merge_commercial_link_data/2 called
Commercial.link_degradation_at twice per grid cell (once to count,
once to merge), and each call re-ran enabled_links plus two sample
queries per in-range link. That was ~500k SQL queries per forecast
hour — fine for the DB but catastrophic for log volume in dev
iex sessions trying to watch a chain step run.

Split into two new functions:
  * build_link_lookup/2 does all the DB work up front — one
    enabled_links query + one link_degradation per enabled link
    (~10 queries total).
  * link_degradation_from_lookup/3 is pure: takes a (lat, lon)
    and the precomputed lookup, returns the aggregate or nil.

The worker now calls build_link_lookup once per forecast hour and
the per-cell path is haversine-only. Net: 500k queries → ~10.

PipelineStatus freshness detection moves off oban_jobs.completed_at
onto ScoresFile.latest_valid_time(). The on-disk files ARE the data
the map renders from, so the chip's "Up to date · Nm ago" and the
2h stale threshold now track actual data presence. Running-state
detection still comes from oban_jobs since the chain step is still
an Oban row. Tests updated to seed a ScoresFile instead of a
completed Oban row for the idle/stale cases.
This commit is contained in:
Graham McIntire 2026-04-14 15:01:12 -05:00
parent 07ffcf52d7
commit b984794571
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 103 additions and 90 deletions

View file

@ -45,43 +45,68 @@ defmodule Microwaveprop.Commercial do
""" """
@spec link_degradation_at({float(), float()}, DateTime.t(), keyword()) :: map() | nil @spec link_degradation_at({float(), float()}, DateTime.t(), keyword()) :: map() | nil
def link_degradation_at({lat, lon}, valid_time, opts \\ []) do def link_degradation_at({lat, lon}, valid_time, opts \\ []) do
radius_km = Keyword.get(opts, :radius_km, @default_radius_km) lookup = build_link_lookup(valid_time, opts)
link_degradation_from_lookup({lat, lon}, lookup, opts)
end
@doc """
Precompute every enabled link's degradation once so a bulk caller
(like PropagationGridWorker's per-cell merge) can reuse the same
`(link, endpoint, degradation)` list across all 95k grid points
instead of re-running `enabled_links/0` and the per-link sample
queries per call. Result is fed back into
`link_degradation_from_lookup/3`.
"""
@spec build_link_lookup(DateTime.t(), keyword()) :: [
{Link.t(), {float(), float()}, map() | nil}
]
def build_link_lookup(valid_time, opts \\ []) do
baseline_days = Keyword.get(opts, :baseline_days, @default_baseline_days) baseline_days = Keyword.get(opts, :baseline_days, @default_baseline_days)
current_window = Keyword.get(opts, :current_window_seconds, @default_current_window_seconds) current_window = Keyword.get(opts, :current_window_seconds, @default_current_window_seconds)
candidates =
enabled_links()
|> Enum.map(fn link -> {link, link_endpoint(link)} end)
|> Enum.reject(fn {_link, endpoint} -> is_nil(endpoint) end)
|> Enum.filter(fn {_link, {elat, elon}} ->
haversine_km(lat, lon, elat, elon) <= radius_km
end)
baseline_cutoff = DateTime.add(valid_time, -baseline_days * 24 * 3600, :second) baseline_cutoff = DateTime.add(valid_time, -baseline_days * 24 * 3600, :second)
current_cutoff = DateTime.add(valid_time, -current_window, :second) current_cutoff = DateTime.add(valid_time, -current_window, :second)
enabled_links()
|> Enum.map(fn link -> {link, link_endpoint(link)} end)
|> Enum.reject(fn {_link, endpoint} -> is_nil(endpoint) end)
|> Enum.map(fn {link, endpoint} ->
degradation = link_degradation(link.id, baseline_cutoff, current_cutoff, valid_time)
{link, endpoint, degradation}
end)
end
@doc """
Pure per-cell aggregator given a `(lat, lon)` point and a
precomputed lookup from `build_link_lookup/2`, returns the
aggregate degradation map or `nil` when no in-range link is
reporting usable data.
"""
@spec link_degradation_from_lookup({float(), float()}, [tuple()], keyword()) :: map() | nil
def link_degradation_from_lookup({lat, lon}, lookup, opts \\ []) do
radius_km = Keyword.get(opts, :radius_km, @default_radius_km)
results = results =
candidates for {_link, {elat, elon}, %{} = degradation} <- lookup,
|> Enum.map(fn {link, _endpoint} -> haversine_km(lat, lon, elat, elon) <= radius_km do
link_degradation(link.id, baseline_cutoff, current_cutoff, valid_time) degradation
end) end
|> Enum.reject(&is_nil/1)
case results do aggregate_degradation(results)
[] -> end
nil
list -> defp aggregate_degradation([]), do: nil
baseline_avg = average(Enum.map(list, & &1.baseline_dbm))
current_avg = average(Enum.map(list, & &1.current_dbm))
%{ defp aggregate_degradation(list) do
degradation_db: Float.round(baseline_avg - current_avg, 2), baseline_avg = average(Enum.map(list, & &1.baseline_dbm))
baseline_dbm: Float.round(baseline_avg, 2), current_avg = average(Enum.map(list, & &1.current_dbm))
current_dbm: Float.round(current_avg, 2),
n_links: length(list) %{
} degradation_db: Float.round(baseline_avg - current_avg, 2),
end baseline_dbm: Float.round(baseline_avg, 2),
current_dbm: Float.round(current_avg, 2),
n_links: length(list)
}
end end
defp link_degradation(link_id, baseline_cutoff, current_cutoff, valid_time) do defp link_degradation(link_id, baseline_cutoff, current_cutoff, valid_time) do

View file

@ -2,20 +2,21 @@ defmodule Microwaveprop.Propagation.PipelineStatus do
@moduledoc """ @moduledoc """
Aggregated status for the propagation update pipeline. Aggregated status for the propagation update pipeline.
The pipeline is driven by two Oban workers: Two inputs drive the chip:
* `PropagationGridWorker` hourly, fetches HRRR f00f18, scores * **Running detection** queries `oban_jobs` for executing
the CONUS grid, and broadcasts `propagation:updated`. PropagationGridWorker / AsosAdjustmentWorker rows so the chip
* `AsosAdjustmentWorker` every 10 minutes, nudges scores with knows when a chain step is in flight.
recent ASOS surface observations. * **Freshness detection** reads the newest valid_time from
`Microwaveprop.Propagation.ScoresFile` on disk. The on-disk
`current/0` returns one struct so the map page can render a single files ARE the propagation data now, so the chip's "Up to date ·
status chip ("Updating…", "Up to date · 8m ago", "Stale · 4h ago") Nm ago" and the 2h stale threshold track actual data presence
that reflects the whole pipeline rather than tailing a single job. instead of Oban's `completed_at` timestamp.
""" """
import Ecto.Query import Ecto.Query
alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Repo alias Microwaveprop.Repo
@grid_worker "Microwaveprop.Workers.PropagationGridWorker" @grid_worker "Microwaveprop.Workers.PropagationGridWorker"
@ -58,14 +59,14 @@ defmodule Microwaveprop.Propagation.PipelineStatus do
def current do def current do
case running_workers() do case running_workers() do
[] -> [] ->
build_idle_or_stale(latest_completed_at()) build_idle_or_stale(latest_data_at())
details -> details ->
%{ %{
state: :running, state: :running,
label: "Updating propagation", label: "Updating propagation",
details: details, details: details,
last_update_at: latest_completed_at() last_update_at: latest_data_at()
} }
end end
end end
@ -91,18 +92,11 @@ defmodule Microwaveprop.Propagation.PipelineStatus do
defp worker_sort_key(@asos_worker), do: 1 defp worker_sort_key(@asos_worker), do: 1
defp worker_sort_key(_), do: 99 defp worker_sort_key(_), do: 99
defp latest_completed_at do # Freshness is derived from the newest ScoresFile on disk. That file
case Repo.one( # is the thing the map actually renders from, so its valid_time is
from j in "oban_jobs", # the most honest answer to "when was the data last updated?".
where: j.state == "completed" and j.worker in ^@workers, defp latest_data_at do
order_by: [desc: j.completed_at], ScoresFile.latest_valid_time()
limit: 1,
select: j.completed_at
) do
nil -> nil
%NaiveDateTime{} = naive -> DateTime.from_naive!(naive, "Etc/UTC")
%DateTime{} = dt -> dt
end
end end
defp build_idle_or_stale(nil) do defp build_idle_or_stale(nil) do

View file

@ -254,25 +254,27 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
end end
defp merge_commercial_link_data(grid_data, valid_time) do defp merge_commercial_link_data(grid_data, valid_time) do
# Compute link degradation once per distinct link cluster and cache by # Precompute per-link degradation once (≤10 SQL queries total).
# point. Commercial links only cluster around DFW so most grid points see # Commercial links cluster around DFW so most grid cells see nil —
# nil — cheap no-op path dominates. # the per-cell path is now a pure haversine check, not a DB query.
boosted = lookup = Commercial.build_link_lookup(valid_time)
Enum.count(grid_data, fn {{lat, lon}, _profile} ->
degradation = Commercial.link_degradation_at({lat, lon}, valid_time) {merged, boosted} =
not is_nil(degradation) Enum.reduce(grid_data, {%{}, 0}, fn {{lat, lon} = point, profile}, {acc, count} ->
case Commercial.link_degradation_from_lookup({lat, lon}, lookup) do
nil ->
{Map.put(acc, point, profile), count}
degradation ->
{Map.put(acc, point, Map.put(profile, :commercial_link_degradation, degradation)), count + 1}
end
end) end)
if boosted > 0 do if boosted > 0 do
Logger.info("PropagationGrid: commercial-link degradation available for #{boosted} grid cells") Logger.info("PropagationGrid: commercial-link degradation available for #{boosted} grid cells")
end end
Map.new(grid_data, fn {{lat, lon} = point, profile} -> merged
case Commercial.link_degradation_at({lat, lon}, valid_time) do
nil -> {point, profile}
degradation -> {point, Map.put(profile, :commercial_link_degradation, degradation)}
end
end)
end end
defp merge_nexrad_data(grid_data, valid_time) do defp merge_nexrad_data(grid_data, valid_time) do

View file

@ -2,6 +2,7 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
use Microwaveprop.DataCase, async: false use Microwaveprop.DataCase, async: false
alias Microwaveprop.Propagation.PipelineStatus alias Microwaveprop.Propagation.PipelineStatus
alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Repo alias Microwaveprop.Repo
@grid_worker "Microwaveprop.Workers.PropagationGridWorker" @grid_worker "Microwaveprop.Workers.PropagationGridWorker"
@ -99,15 +100,12 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
assert asos.label =~ "ASOS" assert asos.label =~ "ASOS"
end end
test "returns :idle with Up to date label when last grid run completed recently" do test "returns :idle with Up to date label when a recent ScoresFile exists" do
completed = minutes_ago(15) ScoresFile.write!(
10_000,
insert_oban_job(%{ minutes_ago(15),
state: "completed", [%{lat: 25.0, lon: -125.0, score: 50}]
worker: @grid_worker, )
attempted_at: completed,
completed_at: completed
})
status = PipelineStatus.current() status = PipelineStatus.current()
@ -116,15 +114,12 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
assert status.label =~ "Up to date" assert status.label =~ "Up to date"
end end
test "returns :stale when last completed was more than 120 minutes ago" do test "returns :stale when the newest ScoresFile is more than 120 minutes old" do
stale = minutes_ago(180) ScoresFile.write!(
10_000,
insert_oban_job(%{ minutes_ago(180),
state: "completed", [%{lat: 25.0, lon: -125.0, score: 50}]
worker: @grid_worker, )
attempted_at: stale,
completed_at: stale
})
status = PipelineStatus.current() status = PipelineStatus.current()
@ -132,15 +127,12 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
assert status.label =~ "stale" assert status.label =~ "stale"
end end
test "prefers :running over :idle when a job is executing even if a recent job completed" do test "prefers :running over :idle when a job is executing even with a recent ScoresFile" do
recent = minutes_ago(5) ScoresFile.write!(
10_000,
insert_oban_job(%{ minutes_ago(5),
state: "completed", [%{lat: 25.0, lon: -125.0, score: 50}]
worker: @grid_worker, )
attempted_at: recent,
completed_at: recent
})
insert_oban_job(%{ insert_oban_job(%{
state: "executing", state: "executing",