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:
parent
07ffcf52d7
commit
b984794571
4 changed files with 103 additions and 90 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 f00–f18, 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue