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.
72 lines
2.3 KiB
Elixir
72 lines
2.3 KiB
Elixir
defmodule Microwaveprop.Weather.MrmsClientTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Weather.MrmsClient
|
|
|
|
describe "parse_listing/1" do
|
|
test "extracts all unique filenames from a listing fragment" do
|
|
html = """
|
|
<html><body>
|
|
<a href="MRMS_PrecipRate_00.00_20260412-192400.grib2.gz">...</a>
|
|
<a href="MRMS_PrecipRate_00.00_20260412-192600.grib2.gz">...</a>
|
|
<a href="MRMS_PrecipRate_00.00_20260412-192800.grib2.gz">...</a>
|
|
</body></html>
|
|
"""
|
|
|
|
entries =
|
|
html
|
|
|> MrmsClient.parse_listing()
|
|
|> Enum.sort_by(& &1.valid_time, DateTime)
|
|
|
|
assert [
|
|
%{valid_time: ~U[2026-04-12 19:24:00Z]},
|
|
%{valid_time: ~U[2026-04-12 19:26:00Z]},
|
|
%{valid_time: ~U[2026-04-12 19:28:00Z]}
|
|
] = entries
|
|
end
|
|
|
|
test "drops filenames with malformed timestamps" do
|
|
html = """
|
|
<a href="MRMS_PrecipRate_00.00_bogusstamp.grib2.gz">...</a>
|
|
<a href="MRMS_PrecipRate_00.00_20260412-193000.grib2.gz">...</a>
|
|
"""
|
|
|
|
assert [%{valid_time: ~U[2026-04-12 19:30:00Z]}] = MrmsClient.parse_listing(html)
|
|
end
|
|
|
|
test "deduplicates identical filenames (directory indexes often list twice)" do
|
|
html = """
|
|
<a href="MRMS_PrecipRate_00.00_20260412-193000.grib2.gz">file</a>
|
|
<a href="MRMS_PrecipRate_00.00_20260412-193000.grib2.gz">file</a>
|
|
"""
|
|
|
|
assert [%{}] = MrmsClient.parse_listing(html)
|
|
end
|
|
end
|
|
|
|
describe "flatten_rain_grid/1" do
|
|
test "keeps positive rain rates keyed by lat/lon" do
|
|
cells = %{
|
|
{32.0, -97.0} => %{"PrecipRate:0 m above mean sea level" => 5.4},
|
|
{32.125, -97.0} => %{"PrecipRate:0 m above mean sea level" => 0.0}
|
|
}
|
|
|
|
assert MrmsClient.flatten_rain_grid(cells) ==
|
|
%{{32.0, -97.0} => 5.4, {32.125, -97.0} => 0.0}
|
|
end
|
|
|
|
test "drops missing-value sentinels (-3 or less)" do
|
|
cells = %{
|
|
{32.0, -97.0} => %{"PrecipRate:0 m above mean sea level" => -3.0},
|
|
{32.125, -97.0} => %{"PrecipRate:0 m above mean sea level" => 12.5}
|
|
}
|
|
|
|
assert MrmsClient.flatten_rain_grid(cells) == %{{32.125, -97.0} => 12.5}
|
|
end
|
|
|
|
test "drops entries with no PrecipRate field at all" do
|
|
cells = %{{32.0, -97.0} => %{"OTHER:sfc" => 1.0}}
|
|
assert MrmsClient.flatten_rain_grid(cells) == %{}
|
|
end
|
|
end
|
|
end
|