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.
172 lines
5.5 KiB
Elixir
172 lines
5.5 KiB
Elixir
defmodule Microwaveprop.Workers.AsosAdjustmentWorker do
|
|
@moduledoc """
|
|
Nudge the propagation score grid with fresh ASOS observations between
|
|
hourly HRRR runs.
|
|
|
|
On each cron tick (every 10 minutes) this worker:
|
|
|
|
1. Finds the most recent `valid_time` that actually has HRRR profiles
|
|
persisted.
|
|
2. Pulls current ASOS observations from all state networks via
|
|
`Microwaveprop.Weather.IemClient.fetch_current_asos/1`, keeps only
|
|
those within 30 minutes of now.
|
|
3. Loads every `hrrr_profiles` row on the 0.125° propagation grid for
|
|
that valid_time into memory as plain maps.
|
|
4. Hands both lists to `Microwaveprop.Propagation.AsosNudge.compute/3`
|
|
— pure function, IDW bias field, upper-air fields preserved.
|
|
5. Upserts the nudged scores onto `(lat, lon, valid_time, band_mhz)`,
|
|
overwriting the HRRR-only values for grid cells within 250 km of a
|
|
reporting station. Cells outside that radius are left alone.
|
|
6. Warms the `ScoreCache` and broadcasts `propagation:updated` so every
|
|
connected `/map` client refreshes.
|
|
|
|
This worker owns the I/O. `AsosNudge` stays pure so its tests don't need
|
|
Repo or network access.
|
|
"""
|
|
|
|
use Oban.Worker, queue: :propagation, max_attempts: 3
|
|
|
|
import Ecto.Query, only: [from: 2]
|
|
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.AsosNudge
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Weather.HrrrProfile
|
|
alias Microwaveprop.Weather.IemClient
|
|
alias Microwaveprop.Weather.MrmsCache
|
|
|
|
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
|
|
)
|
|
|
|
@max_obs_age_seconds 1800
|
|
|
|
@impl Oban.Worker
|
|
def perform(%Oban.Job{}) do
|
|
with {:ok, valid_time} <- latest_hrrr_valid_time(),
|
|
[_ | _] = profiles <- load_hrrr_profiles(valid_time) do
|
|
fresh = fetch_fresh_observations()
|
|
rain_grid = mrms_rain_grid()
|
|
|
|
if fresh == [] and map_size(rain_grid) == 0 do
|
|
Logger.info("AsosAdjustment: no fresh ASOS obs and no MRMS rain cached, skipping")
|
|
:ok
|
|
else
|
|
apply_nudge(fresh, profiles, valid_time, rain_grid)
|
|
end
|
|
else
|
|
:no_hrrr ->
|
|
Logger.info("AsosAdjustment: no HRRR profiles yet, skipping")
|
|
:ok
|
|
|
|
[] ->
|
|
Logger.info("AsosAdjustment: HRRR valid_time has no grid profiles, skipping")
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp fetch_fresh_observations do
|
|
# IemClient.fetch_current_asos/1 swallows its own per-network HTTP
|
|
# errors and always returns `{:ok, [...]}` (possibly empty). We just
|
|
# filter the list down to observations inside the freshness window.
|
|
{:ok, observations} = IemClient.fetch_current_asos(@state_networks)
|
|
filter_fresh(observations)
|
|
end
|
|
|
|
defp mrms_rain_grid do
|
|
case MrmsCache.fetch() do
|
|
{:ok, _valid_time, grid} -> grid
|
|
:miss -> %{}
|
|
end
|
|
end
|
|
|
|
defp apply_nudge(observations, profiles, valid_time, rain_grid) do
|
|
scores = AsosNudge.compute(observations, valid_time, profiles, rain_grid)
|
|
|
|
if scores == [] do
|
|
Logger.info(
|
|
"AsosAdjustment: #{length(observations)} fresh obs + #{map_size(rain_grid)} MRMS cells but no grid cells eligible"
|
|
)
|
|
|
|
:ok
|
|
else
|
|
case Propagation.upsert_scores(scores, prune: false) do
|
|
{:ok, count} ->
|
|
Logger.info(
|
|
"AsosAdjustment: nudged #{count} scores from #{length(observations)} obs + #{map_size(rain_grid)} MRMS cells at #{valid_time}"
|
|
)
|
|
|
|
warm_and_broadcast(valid_time)
|
|
:ok
|
|
|
|
error ->
|
|
Logger.error("AsosAdjustment: upsert failed — #{inspect(error)}")
|
|
error
|
|
end
|
|
end
|
|
end
|
|
|
|
defp warm_and_broadcast(valid_time) do
|
|
Enum.each(BandConfig.all_bands(), fn band ->
|
|
Propagation.warm_cache_and_broadcast(band.freq_mhz, valid_time)
|
|
end)
|
|
|
|
Phoenix.PubSub.broadcast(
|
|
Microwaveprop.PubSub,
|
|
"propagation:updated",
|
|
{:propagation_updated, valid_time}
|
|
)
|
|
end
|
|
|
|
defp latest_hrrr_valid_time do
|
|
case Repo.one(
|
|
from(h in HrrrProfile,
|
|
where: h.is_grid_point == true,
|
|
select: max(h.valid_time)
|
|
)
|
|
) do
|
|
nil -> :no_hrrr
|
|
vt -> {:ok, vt}
|
|
end
|
|
end
|
|
|
|
defp filter_fresh(observations) do
|
|
cutoff = DateTime.add(DateTime.utc_now(), -@max_obs_age_seconds, :second)
|
|
|
|
Enum.filter(observations, fn obs ->
|
|
obs.utc_valid && DateTime.after?(obs.utc_valid, cutoff)
|
|
end)
|
|
end
|
|
|
|
defp load_hrrr_profiles(valid_time) do
|
|
Repo.all(
|
|
from(h in HrrrProfile,
|
|
where: h.valid_time == ^valid_time and h.is_grid_point == true,
|
|
select: %{
|
|
lat: h.lat,
|
|
lon: h.lon,
|
|
valid_time: h.valid_time,
|
|
surface_temp_c: h.surface_temp_c,
|
|
surface_dewpoint_c: h.surface_dewpoint_c,
|
|
surface_pressure_mb: h.surface_pressure_mb,
|
|
surface_refractivity: h.surface_refractivity,
|
|
min_refractivity_gradient: h.min_refractivity_gradient,
|
|
hpbl_m: h.hpbl_m,
|
|
pwat_mm: h.pwat_mm,
|
|
ducting_detected: h.ducting_detected,
|
|
duct_characteristics: h.duct_characteristics,
|
|
profile: h.profile
|
|
}
|
|
)
|
|
)
|
|
end
|
|
end
|