prop/lib/microwaveprop/workers/asos_adjustment_worker.ex
Graham McIntire c318c3a932
Nudge HRRR scores with live ASOS obs between runs
Adds Microwaveprop.Propagation.AsosNudge: a pure IDW bias-field module
that takes ASOS observations + HRRR profiles and returns re-scored grid
rows for every cell within 250km of a reporting station. Upper-air
fields (min_refractivity_gradient, pwat_mm, hpbl_m, profile, duct
metadata) pass through unchanged so HRRR's signal isn't clobbered.

The old AsosAdjustmentWorker was unwired and buggy — nil'd out ~22% of
the scoring weight and wrote orphan timestamps. Replaced with a slim
worker that queries the latest HRRR valid_time, fetches live ASOS
currents, calls AsosNudge.compute/3, and upserts onto
(lat, lon, valid_time, band_mhz) so nudged values overwrite the HRRR
hour cleanly instead of polluting available_valid_times. After each
upsert it warms ScoreCache and broadcasts propagation:updated so live
/map clients refresh.

Cron hooked up every 10 minutes in config.exs and dev.exs. Also cleaned
up the stale "dev has propagation disabled" note in CLAUDE.md.

13 new AsosNudge unit tests cover: residual computation (co-located,
out-of-grid, nil fields), IDW weighting (single station, far station,
two equidistant stations, nil component handling), upper-air
preservation, and the compute/3 entry point's shape and radius filter.

Drive-by Styler formatting touched a handful of unrelated files from
`mix format`.
2026-04-12 14:27:27 -05:00

150 lines
4.8 KiB
Elixir

defmodule Microwaveprop.Workers.AsosAdjustmentWorker do
@moduledoc """
Nudge the propagation score grid with fresh ASOS observations between
hourly HRRR runs.
On each cron tick (every 10 minutes) this worker:
1. Finds the most recent `valid_time` that actually has HRRR profiles
persisted.
2. Pulls current ASOS observations from all state networks via
`Microwaveprop.Weather.IemClient.fetch_current_asos/1`, keeps only
those within 30 minutes of now.
3. Loads every `hrrr_profiles` row on the 0.125° propagation grid for
that valid_time into memory as plain maps.
4. Hands both lists to `Microwaveprop.Propagation.AsosNudge.compute/3`
— pure function, IDW bias field, upper-air fields preserved.
5. Upserts the nudged scores onto `(lat, lon, valid_time, band_mhz)`,
overwriting the HRRR-only values for grid cells within 250 km of a
reporting station. Cells outside that radius are left alone.
6. Warms the `ScoreCache` and broadcasts `propagation:updated` so every
connected `/map` client refreshes.
This worker owns the I/O. `AsosNudge` stays pure so its tests don't need
Repo or network access.
"""
use Oban.Worker, queue: :propagation, max_attempts: 3
import Ecto.Query, only: [from: 2]
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.AsosNudge
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrProfile
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
)
@max_obs_age_seconds 1800
@impl Oban.Worker
def perform(%Oban.Job{}) do
with {:ok, valid_time} <- latest_hrrr_valid_time(),
{:ok, observations} <- IemClient.fetch_current_asos(@state_networks),
[_ | _] = fresh <- filter_fresh(observations),
[_ | _] = profiles <- load_hrrr_profiles(valid_time) do
apply_nudge(fresh, profiles, valid_time)
else
:no_hrrr ->
Logger.info("AsosAdjustment: no HRRR profiles yet, skipping")
:ok
[] ->
Logger.info("AsosAdjustment: no fresh ASOS data, skipping")
:ok
{:error, reason} ->
Logger.warning("AsosAdjustment: aborted — #{inspect(reason)}")
:ok
end
end
defp apply_nudge(observations, profiles, valid_time) do
scores = AsosNudge.compute(observations, valid_time, profiles)
if scores == [] do
Logger.info("AsosAdjustment: #{length(observations)} fresh obs but no grid cells within range")
:ok
else
case Propagation.upsert_scores(scores, prune: false) do
{:ok, count} ->
Logger.info("AsosAdjustment: nudged #{count} scores from #{length(observations)} obs at #{valid_time}")
warm_and_broadcast(valid_time)
:ok
error ->
Logger.error("AsosAdjustment: upsert failed — #{inspect(error)}")
error
end
end
end
defp warm_and_broadcast(valid_time) do
Enum.each(BandConfig.all_bands(), fn band ->
Propagation.warm_cache_and_broadcast(band.freq_mhz, valid_time)
end)
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"propagation:updated",
{:propagation_updated, valid_time}
)
end
defp latest_hrrr_valid_time do
case Repo.one(
from(h in HrrrProfile,
where: h.is_grid_point == true,
select: max(h.valid_time)
)
) do
nil -> :no_hrrr
vt -> {:ok, vt}
end
end
defp filter_fresh(observations) do
cutoff = DateTime.add(DateTime.utc_now(), -@max_obs_age_seconds, :second)
Enum.filter(observations, fn obs ->
obs.utc_valid && DateTime.after?(obs.utc_valid, cutoff)
end)
end
defp load_hrrr_profiles(valid_time) do
Repo.all(
from(h in HrrrProfile,
where: h.valid_time == ^valid_time and h.is_grid_point == true,
select: %{
lat: h.lat,
lon: h.lon,
valid_time: h.valid_time,
surface_temp_c: h.surface_temp_c,
surface_dewpoint_c: h.surface_dewpoint_c,
surface_pressure_mb: h.surface_pressure_mb,
surface_refractivity: h.surface_refractivity,
min_refractivity_gradient: h.min_refractivity_gradient,
hpbl_m: h.hpbl_m,
pwat_mm: h.pwat_mm,
ducting_detected: h.ducting_detected,
duct_characteristics: h.duct_characteristics,
profile: h.profile
}
)
)
end
end