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`.
230 lines
7.5 KiB
Elixir
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
|