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