PropagationGridWorker now broadcasts a {:propagation_pipeline_progress,
%{forecast_hour:, valid_time:}} message on the propagation:pipeline
PubSub topic at the start of each process_forecast_hour/4 call.
MapLive subscribes on mount, stashes the last message in the new
:pipeline_progress assign, and clears it on the refresh tick whenever
PipelineStatus flips out of :running.
The chip component consults a new PipelineStatus.running_label/1
helper that formats the payload as "Updating propagation · now" for
f00 and "Updating propagation · +Nh" for f01-f18, falling back to
the base running label until the first progress message arrives.
Covered by unit tests on the formatter and an integration test in
MapLiveTest that seeds an executing grid job, broadcasts progress,
and asserts the rendered chip.
329 lines
11 KiB
Elixir
329 lines
11 KiB
Elixir
defmodule Microwaveprop.Workers.PropagationGridWorker do
|
||
@moduledoc """
|
||
Hourly Oban worker that downloads HRRR data (analysis + forecast hours)
|
||
and computes propagation scores across the CONUS grid for all bands.
|
||
Fetches f00-f18 to provide 18-hour forecast timeline.
|
||
"""
|
||
|
||
# `unique.period` matches the 3-hour cron so a retrying job can't be
|
||
# duplicated by the next cron firing.
|
||
use Oban.Worker,
|
||
queue: :propagation,
|
||
max_attempts: 3,
|
||
unique: [period: 10_800, states: [:available, :scheduled, :executing, :retryable]]
|
||
|
||
alias Microwaveprop.Commercial
|
||
alias Microwaveprop.Propagation
|
||
alias Microwaveprop.Propagation.BandConfig
|
||
alias Microwaveprop.Propagation.Grid
|
||
alias Microwaveprop.Propagation.ScoreCache
|
||
alias Microwaveprop.Weather
|
||
alias Microwaveprop.Weather.GridCache
|
||
alias Microwaveprop.Weather.HrrrClient
|
||
alias Microwaveprop.Weather.HrrrNativeClient
|
||
alias Microwaveprop.Weather.NexradClient
|
||
alias Microwaveprop.Weather.SoundingParams
|
||
|
||
require Logger
|
||
|
||
# Hard ceiling per run. A healthy run is ~60 min (19 forecast hours × ~3 min
|
||
# each). 90 min gives 1.5× headroom and is under the 3-hour cron cadence, so
|
||
# a stuck wgrib2/HTTP call can't block the queue indefinitely. Oban kills
|
||
# the executing process on timeout, which closes linked ports and cascades
|
||
# SIGKILL to any child wgrib2 subprocess.
|
||
@run_timeout_ms 90 * 60 * 1000
|
||
|
||
@impl Oban.Worker
|
||
def timeout(_job), do: @run_timeout_ms
|
||
|
||
@max_forecast_hour 18
|
||
|
||
@impl Oban.Worker
|
||
def perform(%Oban.Job{}) do
|
||
t_start = System.monotonic_time(:millisecond)
|
||
|
||
# HRRR takes ~45min to publish after the hour. Use 2 hours ago to ensure availability.
|
||
two_hours_ago = DateTime.add(DateTime.utc_now(), -2, :hour)
|
||
run_time = two_hours_ago |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
|
||
points = Grid.conus_points()
|
||
|
||
Logger.info("PropagationGrid: run_time=#{run_time}, #{length(points)} points, f00-f#{@max_forecast_hour}")
|
||
|
||
for_result =
|
||
for fh <- 0..@max_forecast_hour do
|
||
valid_time = DateTime.add(run_time, fh * 3600, :second)
|
||
result = process_forecast_hour(points, run_time, fh, valid_time)
|
||
# Reclaim grid_data/profiles from this forecast hour before starting the next
|
||
:erlang.garbage_collect()
|
||
|
||
case result do
|
||
:ok -> valid_time
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
valid_times = Enum.reject(for_result, &is_nil/1)
|
||
|
||
if valid_times != [] do
|
||
Phoenix.PubSub.broadcast(
|
||
Microwaveprop.PubSub,
|
||
"propagation:updated",
|
||
{:propagation_updated, valid_times}
|
||
)
|
||
end
|
||
|
||
total_ms = System.monotonic_time(:millisecond) - t_start
|
||
Logger.info("PropagationGrid: total time #{format_duration(total_ms)} (#{length(valid_times)} forecast hours)")
|
||
|
||
Weather.prune_old_grid_profiles()
|
||
Propagation.prune_old_scores()
|
||
|
||
:ok
|
||
end
|
||
|
||
defp process_forecast_hour(points, run_time, forecast_hour, valid_time) do
|
||
label = "f#{String.pad_leading(Integer.to_string(forecast_hour), 2, "0")}"
|
||
|
||
# Emit progress so the map page's pipeline status chip can show
|
||
# which forecast hour is currently being scored ("now", "+1h", …).
|
||
Phoenix.PubSub.broadcast(
|
||
Microwaveprop.PubSub,
|
||
"propagation:pipeline",
|
||
{:propagation_pipeline_progress, %{forecast_hour: forecast_hour, valid_time: valid_time}}
|
||
)
|
||
|
||
case timed(label, fn ->
|
||
HrrrClient.fetch_grid(points, run_time, forecast_hour: forecast_hour)
|
||
end) do
|
||
{:ok, grid_data} ->
|
||
store_hrrr_profiles(grid_data, valid_time)
|
||
:erlang.garbage_collect()
|
||
|
||
# Fetch native duct metrics and merge into grid_data for scoring
|
||
grid_data = merge_native_duct_data(grid_data, run_time, forecast_hour)
|
||
:erlang.garbage_collect()
|
||
|
||
# NEXRAD current-hour composite reflectivity catches fast-moving
|
||
# convective cells between HRRR hourly analyses. Only useful for
|
||
# f00 — forecast hours can't see the future radar image.
|
||
grid_data =
|
||
if forecast_hour == 0 do
|
||
merge_nexrad_data(grid_data, valid_time)
|
||
else
|
||
grid_data
|
||
end
|
||
|
||
# Commercial-link inverse sensor — only meaningful for f00 because
|
||
# the measurement is of the current atmospheric state, not a forecast.
|
||
grid_data =
|
||
if forecast_hour == 0 do
|
||
merge_commercial_link_data(grid_data, valid_time)
|
||
else
|
||
grid_data
|
||
end
|
||
|
||
:erlang.garbage_collect()
|
||
|
||
# Build weather cache rows from in-memory grid_data — avoids the ~20s
|
||
# JSONB round trip that was crashing the worker before f01–f18 could run.
|
||
rows = Weather.build_grid_cache_rows(grid_data, valid_time)
|
||
GridCache.broadcast_put(valid_time, rows)
|
||
|
||
Phoenix.PubSub.broadcast(
|
||
Microwaveprop.PubSub,
|
||
"weather:updated",
|
||
{:weather_updated, valid_time}
|
||
)
|
||
|
||
scores = compute_scores(grid_data, valid_time)
|
||
|
||
case Propagation.upsert_scores(scores, prune: false) do
|
||
{:ok, count} ->
|
||
Logger.info("PropagationGrid: #{label} → #{count} scores for #{valid_time}")
|
||
warm_cache(valid_time)
|
||
:ok
|
||
|
||
error ->
|
||
Logger.error("PropagationGrid: #{label} upsert failed: #{inspect(error)}")
|
||
error
|
||
end
|
||
|
||
error ->
|
||
Logger.warning("PropagationGrid: #{label} fetch failed: #{inspect(error)}")
|
||
error
|
||
end
|
||
end
|
||
|
||
defp warm_cache(valid_time) do
|
||
Enum.each(BandConfig.all_bands(), fn band ->
|
||
Propagation.warm_cache_and_broadcast(band.freq_mhz, valid_time)
|
||
end)
|
||
|
||
ScoreCache.prune_older_than(DateTime.add(DateTime.utc_now(), -2, :hour))
|
||
end
|
||
|
||
defp timed(label, fun) do
|
||
t0 = System.monotonic_time(:millisecond)
|
||
result = fun.()
|
||
elapsed = System.monotonic_time(:millisecond) - t0
|
||
Logger.info("PropagationGrid: #{label} took #{format_duration(elapsed)}")
|
||
result
|
||
end
|
||
|
||
defp format_duration(ms) when ms < 1000, do: "#{ms}ms"
|
||
defp format_duration(ms), do: "#{Float.round(ms / 1000, 1)}s"
|
||
|
||
defp store_hrrr_profiles(grid_data, valid_time) do
|
||
count =
|
||
grid_data
|
||
|> Stream.flat_map(fn {{lat, lon}, profile} ->
|
||
build_profile_attrs_for_grid(lat, lon, valid_time, profile)
|
||
end)
|
||
|> Stream.chunk_every(500)
|
||
|> Enum.reduce(0, fn chunk, acc ->
|
||
Weather.upsert_hrrr_profiles_batch(chunk)
|
||
acc + length(chunk)
|
||
end)
|
||
|
||
Logger.info("PropagationGrid: stored #{count} HRRR profiles")
|
||
end
|
||
|
||
defp build_profile_attrs_for_grid(lat, lon, valid_time, profile) do
|
||
temp = profile.surface_temp_c
|
||
|
||
if temp && temp > -80 && temp < 60 do
|
||
params =
|
||
if is_list(profile.profile) and length(profile.profile) >= 3,
|
||
do: SoundingParams.derive(profile.profile)
|
||
|
||
attrs = %{
|
||
valid_time: valid_time,
|
||
lat: lat,
|
||
lon: lon,
|
||
run_time: profile.run_time,
|
||
profile: profile.profile || [],
|
||
hpbl_m: profile.hpbl_m,
|
||
pwat_mm: profile.pwat_mm,
|
||
surface_temp_c: profile.surface_temp_c,
|
||
surface_dewpoint_c: profile.surface_dewpoint_c,
|
||
surface_pressure_mb: profile.surface_pressure_mb
|
||
}
|
||
|
||
attrs =
|
||
if params do
|
||
Map.merge(attrs, %{
|
||
surface_refractivity: params.surface_refractivity,
|
||
min_refractivity_gradient: params.min_refractivity_gradient,
|
||
ducting_detected: params.ducting_detected,
|
||
duct_characteristics: params.duct_characteristics
|
||
})
|
||
else
|
||
attrs
|
||
end
|
||
|
||
[attrs]
|
||
else
|
||
[]
|
||
end
|
||
end
|
||
|
||
defp merge_native_duct_data(grid_data, run_time, forecast_hour) do
|
||
hour_dt = HrrrClient.nearest_hrrr_hour(run_time)
|
||
date = DateTime.to_date(hour_dt)
|
||
hour = hour_dt.hour
|
||
grid_spec = Grid.wgrib2_grid_spec()
|
||
|
||
case timed("native", fn ->
|
||
HrrrNativeClient.fetch_native_duct_grid(date, hour, grid_spec, forecast_hour)
|
||
end) do
|
||
{:ok, duct_grid} ->
|
||
Logger.info("PropagationGrid: merged #{map_size(duct_grid)} native duct cells")
|
||
apply_duct_grid(grid_data, duct_grid)
|
||
|
||
{:error, reason} ->
|
||
Logger.warning("PropagationGrid: native duct fetch failed (continuing without): #{inspect(reason)}")
|
||
grid_data
|
||
end
|
||
end
|
||
|
||
defp apply_duct_grid(grid_data, duct_grid) do
|
||
Map.new(grid_data, fn {point, profile} ->
|
||
case Map.get(duct_grid, point) do
|
||
nil -> {point, profile}
|
||
duct -> {point, Map.merge(profile, duct)}
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp merge_commercial_link_data(grid_data, valid_time) do
|
||
# Compute link degradation once per distinct link cluster and cache by
|
||
# point. Commercial links only cluster around DFW so most grid points see
|
||
# nil — cheap no-op path dominates.
|
||
boosted =
|
||
Enum.count(grid_data, fn {{lat, lon}, _profile} ->
|
||
degradation = Commercial.link_degradation_at({lat, lon}, valid_time)
|
||
not is_nil(degradation)
|
||
end)
|
||
|
||
if boosted > 0 do
|
||
Logger.info("PropagationGrid: commercial-link degradation available for #{boosted} grid cells")
|
||
end
|
||
|
||
Map.new(grid_data, fn {{lat, lon} = point, profile} ->
|
||
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
|
||
|
||
defp merge_nexrad_data(grid_data, valid_time) do
|
||
points = Map.keys(grid_data)
|
||
|
||
case timed("nexrad", fn -> NexradClient.fetch_frame(valid_time, points) end) do
|
||
{:ok, observations} ->
|
||
apply_nexrad_observations(grid_data, observations)
|
||
|
||
{:error, reason} ->
|
||
Logger.warning("PropagationGrid: NEXRAD fetch failed (continuing without): #{inspect(reason)}")
|
||
grid_data
|
||
end
|
||
end
|
||
|
||
defp apply_nexrad_observations(grid_data, observations) do
|
||
index = Map.new(observations, fn obs -> {{obs.lat, obs.lon}, obs.max_reflectivity_dbz} end)
|
||
non_zero = Enum.count(index, fn {_pt, dbz} -> dbz > 0 end)
|
||
Logger.info("PropagationGrid: NEXRAD merged (#{non_zero} cells with precip)")
|
||
|
||
Map.new(grid_data, fn {point, profile} ->
|
||
case Map.get(index, point) do
|
||
nil -> {point, profile}
|
||
dbz -> {point, Map.put(profile, :nexrad_max_reflectivity_dbz, dbz)}
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp compute_scores(grid_data, valid_time) do
|
||
# Algorithm is the primary scorer. ML score stored in factors for comparison.
|
||
compute_scores_algorithm(grid_data, valid_time)
|
||
end
|
||
|
||
defp compute_scores_algorithm(grid_data, valid_time) do
|
||
grid_data
|
||
|> Task.async_stream(
|
||
fn {{lat, lon}, profile} ->
|
||
band_scores = Propagation.score_grid_point(profile, valid_time, lat, lon)
|
||
|
||
Enum.map(band_scores, fn r ->
|
||
%{lat: lat, lon: lon, valid_time: valid_time, band_mhz: r.band_mhz, score: r.score, factors: r.factors}
|
||
end)
|
||
end,
|
||
max_concurrency: System.schedulers_online() * 2,
|
||
timeout: 30_000
|
||
)
|
||
|> Stream.flat_map(fn
|
||
{:ok, results} -> results
|
||
{:exit, _reason} -> []
|
||
end)
|
||
end
|
||
end
|