Three signal sources we already collect but weren't using: * NEXRAD composite reflectivity → rain rate via Marshall-Palmer, taken as max of HRRR-derived and NEXRAD-derived rate so fast convective cells between HRRR hourly analyses can still trigger the rain penalty. Only active on f00 — forecast hours can't see future radar. New Scorer.dbz_to_rain_rate_mmhr/1 with 5 dBZ noise floor and 150 mm/hr hail-safe ceiling. * hrrr_native_profiles.best_duct_band_ghz → Scorer.score_refractivity/4 applies a 1.15× boost when the cell's native-resolution duct supports the target band's frequency. HRRR pressure-level gradients systematically under-read thin trapping layers the native profile can resolve. Sub-band ducts do NOT boost — they're evidence that the gradient we have is all there is at the target frequency. * Commercial LOS link rx_power fading → inverse tropo sensor. Commercial.link_degradation_at/3 computes the average 7-day-baseline vs current delta across enabled links within 75 km, ignoring links where link_state != 1. Scorer.commercial_link_boost/2 adds +2 to +25 to the composite score for 3+ dB of fading. ~150 km radius around DFW is the only zone this helps today, but it's the first *measured* signal in the algorithm vs the model-derived proxies. Also fix a latent test bug exposed by the earlier ERA5 poll-timeout bump: era5_batch_client_test's "uncached path returns error" tests hung for up to an hour when run with direnv's real CDS key. New describe-level setup explicitly unsets the env var so the tests stay hermetic. 1,359 tests, 0 failures.
321 lines
11 KiB
Elixir
321 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")}"
|
||
|
||
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
|