prop/lib/microwaveprop/workers/asos_adjustment_worker.ex
Graham McIntire e0d9900608
Add real-time ASOS adjustments between hourly HRRR updates
New AsosAdjustmentWorker runs every 10 minutes:
- Fetches latest ASOS observations from all ~2900 US stations via
  IEM bulk currents API (parallel fetch across 51 state networks)
- For each grid point within 75km of a reporting station, re-scores
  using fresh ASOS data (temp, dewpoint, wind, sky, pressure, precip)
  with HRRR refractivity gradient from the last hourly computation
- Pushes updated scores to the map via PubSub

Also stores HRRR profiles in the database during grid computation
so the data persists for reference and ASOS blending.
2026-03-31 09:10:12 -05:00

220 lines
6.9 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)
# Get the last HRRR refractivity data for blending
hrrr_refractivity = load_hrrr_refractivity()
# 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, hrrr_refractivity)
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, hrrr_refractivity) 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
# Get HRRR refractivity for this grid point (from last hourly computation)
refractivity = Map.get(hrrr_refractivity, {lat, lon})
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,
pressure_mb: obs.sea_level_pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: rain_rate,
min_refractivity_gradient: refractivity,
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
# Load the refractivity gradient values from the last HRRR grid computation
defp load_hrrr_refractivity do
case Propagation.latest_valid_time() do
nil ->
%{}
_time ->
# Use the 10 GHz band (arbitrary — refractivity factor is the same for all bands)
10_000
|> Propagation.latest_scores()
|> Enum.reduce(%{}, fn %{lat: lat, lon: lon} = _score, acc ->
# We don't store refractivity separately, so use nil (neutral score)
Map.put(acc, {lat, lon}, nil)
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