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