prop/lib/microwaveprop/workers/asos_adjustment_worker.ex
Graham McIntire 9abbb83469 Fix credo warnings: struct specs, length/1, and test patterns
- Replace %Struct{} with Struct.t() in all @spec annotations
- Replace length(x) > 0 with x != [] in test assertions
- Fix multi-line spec struct references in weather.ex
2026-04-12 10:26:53 -05:00

202 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)
apply_asos_scores(fresh, valid_time)
end
end
end
defp apply_asos_scores(fresh, valid_time) do
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
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