defmodule Microwaveprop.Workers.PropagationGridWorker do @moduledoc """ Oban worker that downloads HRRR data and computes propagation scores across the CONUS grid for all bands, one forecast hour at a time. Each `perform/1` processes a single forecast hour (~8–10 min of wall time) and enqueues the next hour in the chain. A cron fire with empty args seeds the chain at f00; subsequent runs carry `forecast_hour` + `run_time` args. At f18 the chain stops and the pruner cleans up old scores. Splitting by forecast hour is a resilience play: a full sweep takes ~3 hours of wall time, longer than the typical pod-restart interval on this deployment. Under the old "one big perform" design, any deploy mid-sweep killed the whole run, and max_attempts would exhaust without recording an error. Per-hour jobs survive deploys because Lifeline only needs to rescue a single 10-minute step, and retries re-fetch just that forecast hour. """ use Oban.Worker, queue: :propagation, max_attempts: 3 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 require Logger # Hard ceiling for one forecast hour. A healthy step is ~8-10 min. # 20 min gives 2× headroom for a slow HRRR fetch or scoring batch. # Oban kills the executing process on timeout, which closes linked # ports and cascades SIGKILL to any child wgrib2 subprocess. @run_timeout_ms 20 * 60 * 1000 @impl Oban.Worker def timeout(_job), do: @run_timeout_ms @max_forecast_hour 18 @impl Oban.Worker def perform(%Oban.Job{args: %{"forecast_hour" => fh, "run_time" => run_time_iso}}) do {:ok, run_time, _} = DateTime.from_iso8601(run_time_iso) run_chain_step(run_time, fh) end def perform(%Oban.Job{args: args}) when args == %{} do seed_chain() end @doc """ Enqueue the next chain step after a successful forecast-hour run. Returns `{:ok, job}` when a new step is enqueued, or `:final` when `fh` is already `@max_forecast_hour` so the chain has no more work. Public so the chain entry point and tests can both exercise the same enqueue path. """ @spec enqueue_next_step(DateTime.t(), non_neg_integer()) :: {:ok, Oban.Job.t()} | :final def enqueue_next_step(_run_time, fh) when fh >= @max_forecast_hour, do: :final def enqueue_next_step(%DateTime{} = run_time, fh) when fh >= 0 do {:ok, _job} = %{"run_time" => DateTime.to_iso8601(run_time), "forecast_hour" => fh + 1} |> new() |> Oban.insert() end defp seed_chain do # 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) Logger.info("PropagationGrid: seeding chain run_time=#{run_time}, f00-f#{@max_forecast_hour}") {:ok, _job} = %{"run_time" => DateTime.to_iso8601(run_time), "forecast_hour" => 0} |> new() |> Oban.insert() :ok end defp run_chain_step(run_time, fh) do t_start = System.monotonic_time(:millisecond) points = Grid.conus_points() valid_time = DateTime.add(run_time, fh * 3600, :second) Logger.info("PropagationGrid: chain step run_time=#{run_time} fh=#{fh} (#{length(points)} points)") result = process_forecast_hour(points, run_time, fh, valid_time) :erlang.garbage_collect() case result do :ok -> Phoenix.PubSub.broadcast( Microwaveprop.PubSub, "propagation:updated", {:propagation_updated, [valid_time]} ) total_ms = System.monotonic_time(:millisecond) - t_start Logger.info("PropagationGrid: fh=#{fh} step finished in #{format_duration(total_ms)}") case enqueue_next_step(run_time, fh) do :final -> Weather.prune_old_grid_profiles() Propagation.prune_old_scores() Logger.info("PropagationGrid: chain complete for run_time=#{run_time}") {:ok, _job} -> :ok end :ok other -> other end 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} -> # HRRR profiles used to be persisted to the hrrr_profiles table # here for AsosAdjustmentWorker to re-score from. That was ~12 # minutes of JSONB inserts per chain (92k rows × 19 forecast # hours) with no user-visible benefit — the scores live in the # /data/scores files now, and AsosAdjustmentWorker is disabled. # Per-contact HRRR enrichment still uses HrrrFetchWorker, which # writes its own `is_grid_point: false` rows. # 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, forecast_hour) case Propagation.replace_scores(scores, valid_time) do {:ok, count} -> Logger.info("PropagationGrid: #{label} → #{count} scores for #{valid_time}") warm_cache(valid_time) :ok error -> Logger.error("PropagationGrid: #{label} replace 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 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, forecast_hour) do # Algorithm is the primary scorer. `factors` is only populated for # f00 (the analysis hour) — forecast hours skip the JSONB write so # the scoring+upsert phase can land in under a minute instead of # ~4-5 minutes. point_detail on forecast hours returns a nil # breakdown, which the UI tolerates. compute_scores_algorithm(grid_data, valid_time, forecast_hour == 0) end defp compute_scores_algorithm(grid_data, valid_time, include_factors?) do grid_data |> Task.async_stream( &score_one_point(&1, valid_time, include_factors?), max_concurrency: System.schedulers_online() * 2, timeout: 30_000 ) |> Stream.flat_map(fn {:ok, results} -> results {:exit, _reason} -> [] end) end defp score_one_point({{lat, lon}, profile}, valid_time, include_factors?) do 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: if(include_factors?, do: r.factors) } end) end end