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.
80 lines
2.3 KiB
Elixir
80 lines
2.3 KiB
Elixir
defmodule Microwaveprop.Weather.MrmsCache do
|
|
@moduledoc """
|
|
Node-local ETS cache of the latest MRMS PrecipRate grid, regridded onto
|
|
the 0.125° propagation grid. Mirrors the pattern used by
|
|
`Microwaveprop.Propagation.ScoreCache` and
|
|
`Microwaveprop.Weather.GridCache` — a single GenServer owns the table,
|
|
callers read directly from ETS, and `broadcast_put/2` fans updates out
|
|
to peer nodes via PubSub.
|
|
|
|
Only one node runs `Microwaveprop.Workers.MrmsFetchWorker` per cron
|
|
tick (Oban Pro Smart engine picks a leader), so the other nodes get
|
|
the grid via the `"mrms:cache"` topic and stay in sync without each
|
|
one paying the 1 MB fetch + wgrib2 regrid cost.
|
|
"""
|
|
use GenServer
|
|
|
|
alias Phoenix.PubSub
|
|
|
|
@table :mrms_cache
|
|
@topic "mrms:cache"
|
|
@pubsub Microwaveprop.PubSub
|
|
|
|
@type rain_grid :: %{{float(), float()} => float()}
|
|
|
|
@spec start_link(keyword()) :: GenServer.on_start()
|
|
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
|
|
@doc "Return the cached `{valid_time, rain_grid}` or `:miss`."
|
|
@spec fetch() :: {:ok, DateTime.t(), rain_grid()} | :miss
|
|
def fetch do
|
|
case :ets.lookup(@table, :current) do
|
|
[{_, valid_time, grid}] -> {:ok, valid_time, grid}
|
|
[] -> :miss
|
|
end
|
|
end
|
|
|
|
@doc "Current MRMS valid_time if anything is cached."
|
|
@spec valid_time() :: DateTime.t() | nil
|
|
def valid_time do
|
|
case fetch() do
|
|
{:ok, vt, _} -> vt
|
|
:miss -> nil
|
|
end
|
|
end
|
|
|
|
@spec put(DateTime.t(), rain_grid()) :: :ok
|
|
def put(valid_time, grid) do
|
|
:ets.insert(@table, {:current, valid_time, grid})
|
|
:ok
|
|
end
|
|
|
|
@doc "Insert locally and broadcast to peer nodes."
|
|
@spec broadcast_put(DateTime.t(), rain_grid()) :: :ok
|
|
def broadcast_put(valid_time, grid) do
|
|
put(valid_time, grid)
|
|
PubSub.broadcast(@pubsub, @topic, {:mrms_cache_refresh, valid_time, grid})
|
|
:ok
|
|
end
|
|
|
|
@spec clear() :: :ok
|
|
def clear do
|
|
:ets.delete_all_objects(@table)
|
|
:ok
|
|
end
|
|
|
|
@impl true
|
|
def init(_opts) do
|
|
:ets.new(@table, [:set, :named_table, :public, read_concurrency: true])
|
|
PubSub.subscribe(@pubsub, @topic)
|
|
{:ok, %{}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:mrms_cache_refresh, valid_time, grid}, state) do
|
|
put(valid_time, grid)
|
|
{:noreply, state}
|
|
end
|
|
|
|
def handle_info(_msg, state), do: {:noreply, state}
|
|
end
|