prop/lib/microwaveprop/propagation/asos_nudge.ex
Graham McIntire ed67efb256
Add MRMS rain mosaic, fix beacons crash, fix UTC clock flash
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.
2026-04-12 14:49:20 -05:00

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