MRMS
----
Layer the NOAA MRMS PrecipRate product onto the score grid so rain fade
updates every 2 minutes instead of every hour alongside HRRR. New modules:
- Microwaveprop.Weather.MrmsClient: fetches the latest .grib2.gz off the
NCEP mirror (Req auto-decompresses so no gunzip step), writes the raw
GRIB2 to a temp file, and calls the existing wgrib2 wrapper with the
0.125 propagation grid spec to get interpolated cells. Returns a
%{{lat, lon} => mm_per_hour} map with missing-value sentinels dropped.
- Microwaveprop.Weather.MrmsCache: ETS-backed GenServer mirroring
ScoreCache/GridCache. Caches a single "current" entry keyed by
valid_time with PubSub broadcast so peer nodes stay in sync and only
the Oban leader pays the fetch + regrid cost.
- Microwaveprop.Workers.MrmsFetchWorker: cron every 2 minutes, short-
circuits when the cached valid_time already matches the newest file.
Microwaveprop.Propagation.AsosNudge.compute/4 now takes an optional
rain_grid. When a cell has MRMS rain >= 0.1 mm/hr it gets patched onto
the HRRR profile's `precip_mm` field (the scorer already reads it there)
and the cell is re-scored even with no ASOS station nearby. Cells with
MRMS rain below the threshold aren't touched so dry cells keep their
raw HRRR scores (which have the wind/sky/native-gradient signal that
isn't persisted on HrrrProfile rows and would otherwise be lost).
AsosAdjustmentWorker pulls MrmsCache on every tick and passes the grid
through to AsosNudge.compute/4. Also skips the IemClient error branch
that can never happen and handles the ASOS-empty + MRMS-empty case
explicitly. MrmsCache wired into the supervision tree; MrmsFetchWorker
cron entry added to config.exs and dev.exs.
Four new AsosNudge cases cover MRMS-only re-scoring, threshold gating,
and the wet/dry score delta.
Beacons 500
-----------
Beacon.format_freq/1 and format_mw/1 crashed on whole-number floats
(e.g. 24192.0) because `frac == 0.0` could become false under float
rounding while `trim_trailing_zeros/1` stripped the decimal point,
leaving a 1-element list that couldn't be destructured as [_, frac].
Shared format_number/1 helper handles integer input directly and
pattern-matches both the "int-only" and "int + frac" shapes.
Added stream_data property tests covering the whole microwave range for
both integers and floats to catch this class of bug before prod.
UTC clock flash
---------------
The /weather and /map UTC clocks were empty until the JS hook mounted
post-WebSocket, producing a several-second blank spot on initial load
and a clobber risk on sidebar re-renders. Mount now computes a
server-rendered `initial_utc_clock` string and the template seeds the
element with that plus `phx-update="ignore"` so LiveView morphdom won't
overwrite what the hook writes.
269 lines
9.1 KiB
Elixir
269 lines
9.1 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
|
|
|
|
# Below this mm/hr threshold MRMS doesn't tell us anything useful — the
|
|
# wet→dry direction of re-scoring would *lose* the wind/sky/native
|
|
# gradient signal that lives in the original PropagationGridWorker run
|
|
# but isn't persisted on HrrrProfile rows, so we'd be trading wet accuracy
|
|
# for strictly worse dry accuracy at that cell.
|
|
@min_mrms_rain_mmhr 0.1
|
|
|
|
@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 rain_grid :: %{{float(), float()} => float()}
|
|
|
|
@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 *or*
|
|
a meaningful rain signal from MRMS, then re-score. Cells that fail both
|
|
filters are dropped so the existing HRRR scores stay in place.
|
|
|
|
The MRMS rain grid is optional — pass an empty map to nudge on ASOS
|
|
alone. `rain_grid` is a `%{{snapped_lat, snapped_lon} => mm_per_hour}`
|
|
keyed on the 0.125° propagation grid.
|
|
"""
|
|
@spec compute([observation()], DateTime.t(), [hrrr_profile()], rain_grid()) :: [grid_score()]
|
|
def compute(observations, valid_time, profiles, rain_grid \\ %{})
|
|
def compute(_observations, _valid_time, [], _rain_grid), do: []
|
|
|
|
def compute([], _valid_time, _profiles, rain_grid) when map_size(rain_grid) == 0, do: []
|
|
|
|
def compute(observations, valid_time, profiles, rain_grid) do
|
|
residuals = build_station_residuals(observations, profiles)
|
|
|
|
profiles
|
|
|> Enum.filter(fn profile ->
|
|
any_station_within_radius?(profile, residuals) or
|
|
significant_mrms_rain?(profile, rain_grid)
|
|
end)
|
|
|> Enum.flat_map(fn profile ->
|
|
profile
|
|
|> nudge_profile(residuals)
|
|
|> patch_mrms_rain(rain_grid)
|
|
|> 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 significant_mrms_rain?(profile, rain_grid) do
|
|
case Map.get(rain_grid, {profile.lat, profile.lon}) do
|
|
rate when is_number(rate) and rate >= @min_mrms_rain_mmhr -> true
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
defp patch_mrms_rain(profile, rain_grid) do
|
|
case Map.get(rain_grid, {profile.lat, profile.lon}) do
|
|
rate when is_number(rate) and rate >= @min_mrms_rain_mmhr ->
|
|
# Scorer reads `hrrr_profile[:precip_mm]` and treats the value as
|
|
# mm/hr directly (see Scorer.precip_to_rate_mmhr/1), so storing the
|
|
# MRMS rate on that key Just Works without a units hack.
|
|
Map.put(profile, :precip_mm, rate)
|
|
|
|
_ ->
|
|
profile
|
|
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
|