prop/lib/microwaveprop/workers/asos_adjustment_worker.ex
Graham McIntire 16883591b4
Database performance fixes and async backfill enqueue
- Fix score_pressure crash on nil pressure_mb (coastal HRRR points)
- Set 10-min timeout on grid score upsert transaction (was :infinity)
- Single DELETE for prune_old_scores instead of N queries in a loop
- Remove dead load_hrrr_refractivity that loaded 95k rows into nil map
- Pass selected_time to point_detail to skip latest_valid_time sub-query
- Batch station existence checks (1 query per path point, not per station)
- Batch solar index upserts via insert_all in chunks of 500
- Batch backfill_distances via single UPDATE FROM VALUES statement
- Add is_grid_point boolean + partial index to hrrr_profiles (replaces
  non-sargable modular arithmetic filter on every weather map query)
- Add partial index on contacts(qso_timestamp) WHERE pos1 IS NOT NULL
- Move backfill enqueue to Oban worker so UI returns immediately
2026-04-04 19:19:18 -05:00

198 lines
6.1 KiB
Elixir

defmodule Microwaveprop.Workers.AsosAdjustmentWorker do
@moduledoc """
Runs every 10 minutes to fetch the latest ASOS observations and re-score
grid points near reporting stations. This provides fresher data between
the hourly HRRR grid computations.
For each grid point, finds the nearest ASOS station within 75 km.
If the station reported within the last 30 minutes, re-scores using
the ASOS observation for temp/dewpoint/wind/sky/pressure/precip and
the HRRR refractivity gradient from the last grid computation.
"""
use Oban.Worker, queue: :propagation, max_attempts: 3
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Radio
alias Microwaveprop.Weather.IemClient
require Logger
@state_networks ~w(
AL_ASOS AK_ASOS AZ_ASOS AR_ASOS CA_ASOS CO_ASOS CT_ASOS DE_ASOS
FL_ASOS GA_ASOS HI_ASOS ID_ASOS IL_ASOS IN_ASOS IA_ASOS KS_ASOS
KY_ASOS LA_ASOS ME_ASOS MD_ASOS MA_ASOS MI_ASOS MN_ASOS MS_ASOS
MO_ASOS MT_ASOS NE_ASOS NV_ASOS NH_ASOS NJ_ASOS NM_ASOS NY_ASOS
NC_ASOS ND_ASOS OH_ASOS OK_ASOS OR_ASOS PA_ASOS RI_ASOS SC_ASOS
SD_ASOS TN_ASOS TX_ASOS UT_ASOS VT_ASOS VA_ASOS WA_ASOS WV_ASOS
WI_ASOS WY_ASOS DC_ASOS
)
# Only adjust grid points within this distance of an ASOS station
@max_station_distance_km 75
# Only use observations newer than this
@max_obs_age_seconds 1800
@impl Oban.Worker
def perform(%Oban.Job{}) do
now = DateTime.utc_now()
cutoff = DateTime.add(now, -@max_obs_age_seconds, :second)
Logger.info("AsosAdjustment: fetching current ASOS observations")
with {:ok, observations} <- IemClient.fetch_current_asos(@state_networks) do
# Filter to recent observations
fresh =
Enum.filter(observations, fn obs ->
obs.utc_valid && DateTime.after?(obs.utc_valid, cutoff)
end)
Logger.info("AsosAdjustment: #{length(fresh)} fresh observations from #{length(observations)} total")
if fresh == [] do
:ok
else
valid_time = DateTime.truncate(now, :second)
scores = score_grid_from_asos(fresh, valid_time)
Logger.info("AsosAdjustment: computed #{length(scores)} adjusted scores")
case Propagation.upsert_scores(scores) do
{:ok, count} ->
Logger.info("AsosAdjustment: upserted #{count} scores")
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"propagation:updated",
{:propagation_updated, valid_time}
)
:ok
error ->
Logger.error("AsosAdjustment: upsert failed: #{inspect(error)}")
error
end
end
end
end
defp score_grid_from_asos(observations, valid_time) do
# Build a spatial index of observations
obs_list =
Enum.map(observations, fn obs ->
%{
lat: obs.lat,
lon: obs.lon,
temp_f: obs.temp_f,
dewpoint_f: obs.dewpoint_f,
wind_speed_kts: obs.wind_speed_kts,
sky_condition: obs.sky_condition,
sea_level_pressure_mb: pressure_from_obs(obs),
precip_1h_in: obs.precip_1h_in
}
end)
# For each grid point, find nearest station and score
Grid.conus_points()
|> Task.async_stream(
fn {lat, lon} = point ->
nearest = find_nearest_station(obs_list, lat, lon)
if nearest do
score_point_from_obs(point, nearest, valid_time)
end
end,
max_concurrency: System.schedulers_online() * 2,
timeout: 10_000
)
|> Enum.flat_map(fn
{:ok, nil} -> []
{:ok, scores} -> scores
{:exit, _} -> []
end)
end
defp find_nearest_station(obs_list, lat, lon) do
obs_list
|> Enum.map(fn obs ->
dist = Radio.haversine_km(lat, lon, obs.lat, obs.lon)
{dist, obs}
end)
|> Enum.filter(fn {dist, _} -> dist <= @max_station_distance_km end)
|> Enum.min_by(fn {dist, _} -> dist end, fn -> nil end)
|> case do
nil -> nil
{_dist, obs} -> obs
end
end
defp score_point_from_obs({lat, lon}, obs, valid_time) do
temp_c = Scorer.f_to_c(obs.temp_f)
dewpoint_c = Scorer.f_to_c(obs.dewpoint_f)
if is_nil(temp_c) or is_nil(dewpoint_c) do
[]
else
sky_pct = sky_condition_to_pct(obs.sky_condition)
rain_rate = precip_to_rate(obs.precip_1h_in)
conditions = %{
abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c),
temp_f: obs.temp_f,
dewpoint_f: obs.dewpoint_f,
wind_speed_kts: obs.wind_speed_kts,
sky_cover_pct: sky_pct,
utc_hour: valid_time.hour,
utc_minute: valid_time.minute,
month: valid_time.month,
longitude: lon,
pressure_mb: obs.sea_level_pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: rain_rate,
min_refractivity_gradient: nil,
bl_depth_m: nil
}
Enum.map(BandConfig.all_bands(), fn band ->
result = Scorer.composite_score(conditions, band)
%{
lat: lat,
lon: lon,
valid_time: valid_time,
band_mhz: band.freq_mhz,
score: result.score,
factors: result.factors
}
end)
end
end
defp sky_condition_to_pct(nil), do: nil
defp sky_condition_to_pct("CLR"), do: 0.0
defp sky_condition_to_pct("SKC"), do: 0.0
defp sky_condition_to_pct("FEW"), do: 18.0
defp sky_condition_to_pct("SCT"), do: 40.0
defp sky_condition_to_pct("BKN"), do: 75.0
defp sky_condition_to_pct("OVC"), do: 100.0
defp sky_condition_to_pct("VV"), do: 100.0
defp sky_condition_to_pct(_), do: nil
defp pressure_from_obs(%{sea_level_pressure_mb: mslp}) when is_number(mslp), do: mslp
defp pressure_from_obs(%{altimeter_setting: alti}) when is_number(alti) do
# Convert altimeter setting (inHg) to millibars
alti * 33.8639
end
defp pressure_from_obs(_), do: nil
# Convert hourly precip accumulation (inches) to rain rate (mm/hr)
defp precip_to_rate(nil), do: 0.0
defp precip_to_rate(inches) when inches <= 0, do: 0.0
defp precip_to_rate(inches), do: inches * 25.4
end