prop/lib/microwaveprop/propagation/asos_nudge.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

230 lines
7.5 KiB
Elixir

defmodule Microwaveprop.Propagation.AsosNudge do
@moduledoc """
Blend live ASOS surface observations into the latest HRRR grid between
hourly runs. HRRR analysis is already ~55 minutes old by the time the
propagation worker scores it; fronts, humidity swings, and rain onset
happening after that lag otherwise sit invisible until the next top-of-hour
run. ASOS METARs publish every 5 minutes, so a 10-minute nudging cycle
catches them.
## Pipeline
1. `build_station_residuals/2` — for each ASOS observation, find the
nearest HRRR grid cell (within one 0.125° step, otherwise the station
is outside the grid domain and dropped). The residual is
`(asos_value - hrrr_value)` for temp, dewpoint, and pressure, plus the
raw ASOS rain rate.
2. `nudge_profile/2` — for a given HRRR profile, compute an
inverse-distance weighted (1/d²) blend of residuals from stations
within 250 km. Only the surface fields are touched; refractivity
gradient, PWAT, HPBL, duct metadata, and the full vertical profile
all pass through unchanged so the HRRR upper-air signal is preserved.
3. `compute/3` — run the pipeline over every HRRR profile that has at
least one station within range, call
`Microwaveprop.Propagation.score_grid_point/4` on the patched profile,
and return score rows ready for `Propagation.upsert_scores/1`. Grid
cells with no station in range are dropped from the output, leaving
the existing HRRR scores untouched.
The nudging module itself is pure — no Repo, no PubSub. The worker that
schedules this (`Microwaveprop.Workers.AsosAdjustmentWorker`) handles all
I/O.
"""
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.Grid
@radius_km 250
@min_station_weight_km 1.0
@earth_radius_km 6371.0
@type observation :: %{
required(:lat) => float(),
required(:lon) => float(),
optional(:temp_f) => number() | nil,
optional(:dewpoint_f) => number() | nil,
optional(:sea_level_pressure_mb) => number() | nil,
optional(:precip_1h_in) => number() | nil
}
@type residual :: %{
lat: float(),
lon: float(),
dtemp_c: float() | nil,
ddewpoint_c: float() | nil,
dpressure_mb: float() | nil,
asos_rain_mmhr: float()
}
@type hrrr_profile :: map()
@type grid_score :: %{
lat: float(),
lon: float(),
valid_time: DateTime.t(),
band_mhz: non_neg_integer(),
score: non_neg_integer(),
factors: map()
}
@doc """
Nudge every HRRR grid cell that has an ASOS station within 250 km and
re-score it. Cells outside that radius are dropped so the existing HRRR
scores stay in place.
"""
@spec compute([observation()], DateTime.t(), [hrrr_profile()]) :: [grid_score()]
def compute([], _valid_time, _profiles), do: []
def compute(_observations, _valid_time, []), do: []
def compute(observations, valid_time, profiles) do
residuals = build_station_residuals(observations, profiles)
profiles
|> Enum.filter(&any_station_within_radius?(&1, residuals))
|> Enum.flat_map(fn profile ->
profile
|> nudge_profile(residuals)
|> score_point(valid_time)
end)
end
@doc """
For each observation with a valid HRRR anchor cell, compute the
`(asos - hrrr)` residual at that cell. Used by `nudge_profile/2` to
spread the bias field across the grid.
"""
@spec build_station_residuals([observation()], [hrrr_profile()]) :: [residual()]
def build_station_residuals(observations, profiles) do
lookup = Map.new(profiles, fn p -> {snap_to_grid(p.lat, p.lon), p} end)
observations
|> Enum.map(&station_residual(&1, lookup))
|> Enum.reject(&is_nil/1)
end
@doc """
Return a patched HRRR profile with `surface_temp_c`, `surface_dewpoint_c`,
and `surface_pressure_mb` shifted by the IDW-weighted residuals from
stations within 250 km. If no station is close enough, the profile is
returned unchanged.
"""
@spec nudge_profile(hrrr_profile(), [residual()]) :: hrrr_profile()
def nudge_profile(profile, residuals) do
nearby =
residuals
|> Enum.map(fn r -> {r, distance_km(profile.lat, profile.lon, r.lat, r.lon)} end)
|> Enum.filter(fn {_r, d} -> d <= @radius_km end)
case nearby do
[] ->
profile
_ ->
profile
|> Map.put(:surface_temp_c, apply_idw(profile.surface_temp_c, nearby, :dtemp_c))
|> Map.put(:surface_dewpoint_c, apply_idw(profile.surface_dewpoint_c, nearby, :ddewpoint_c))
|> Map.put(:surface_pressure_mb, apply_idw(profile.surface_pressure_mb, nearby, :dpressure_mb))
end
end
# -- internals ---------------------------------------------------------
defp station_residual(obs, lookup) do
with {:ok, temp_c} <- f_to_c(obs[:temp_f]),
{:ok, dewpoint_c} <- f_to_c(obs[:dewpoint_f]),
snapped = snap_to_grid(obs.lat, obs.lon),
hrrr when not is_nil(hrrr) <- Map.get(lookup, snapped),
true <- within_one_grid_step?(obs, hrrr) do
%{
lat: obs.lat,
lon: obs.lon,
dtemp_c: delta(temp_c, hrrr.surface_temp_c),
ddewpoint_c: delta(dewpoint_c, hrrr.surface_dewpoint_c),
dpressure_mb: delta(obs[:sea_level_pressure_mb], hrrr.surface_pressure_mb),
asos_rain_mmhr: precip_to_mmhr(obs[:precip_1h_in])
}
else
_ -> nil
end
end
defp f_to_c(nil), do: :error
defp f_to_c(f) when is_number(f), do: {:ok, (f - 32) * 5 / 9}
defp f_to_c(_), do: :error
defp delta(nil, _), do: nil
defp delta(_, nil), do: nil
defp delta(a, b), do: a - b
defp precip_to_mmhr(nil), do: 0.0
defp precip_to_mmhr(inches) when inches <= 0, do: 0.0
defp precip_to_mmhr(inches), do: inches * 25.4
defp within_one_grid_step?(obs, hrrr) do
distance_km(obs.lat, obs.lon, hrrr.lat, hrrr.lon) <= Grid.step() * 111.0
end
defp any_station_within_radius?(profile, residuals) do
Enum.any?(residuals, fn r ->
distance_km(profile.lat, profile.lon, r.lat, r.lon) <= @radius_km
end)
end
defp apply_idw(base, _nearby, _key) when is_nil(base), do: nil
defp apply_idw(base, nearby, key) do
contributing =
Enum.filter(nearby, fn {residual, _d} -> not is_nil(Map.get(residual, key)) end)
case contributing do
[] ->
base
_ ->
{num, den} =
Enum.reduce(contributing, {0.0, 0.0}, fn {residual, d_km}, {num, den} ->
w = 1.0 / :math.pow(max(d_km, @min_station_weight_km), 2)
{num + w * Map.get(residual, key), den + w}
end)
base + num / den
end
end
defp score_point(profile, valid_time) do
profile
|> Propagation.score_grid_point(valid_time, profile.lat, profile.lon)
|> Enum.map(fn row ->
row
|> Map.put(:lat, profile.lat)
|> Map.put(:lon, profile.lon)
|> Map.put(:valid_time, valid_time)
end)
end
defp snap_to_grid(lat, lon) do
step = Grid.step()
{
Float.round(Float.round(lat / step) * step, 3),
Float.round(Float.round(lon / step) * step, 3)
}
end
defp distance_km(lat1, lon1, lat2, lon2) do
lat1_rad = lat1 * :math.pi() / 180
lat2_rad = lat2 * :math.pi() / 180
dlat = (lat2 - lat1) * :math.pi() / 180
dlon = (lon2 - lon1) * :math.pi() / 180
a =
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
:math.cos(lat1_rad) * :math.cos(lat2_rad) *
:math.sin(dlon / 2) * :math.sin(dlon / 2)
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
@earth_radius_km * c
end
end