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.
This commit is contained in:
Graham McIntire 2026-04-12 14:49:20 -05:00
parent be47877c06
commit ed67efb256
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
14 changed files with 616 additions and 45 deletions

View file

@ -65,6 +65,7 @@ config :microwaveprop, Oban,
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"*/2 * * * *", Microwaveprop.Workers.MrmsFetchWorker},
{"*/10 * * * *", Microwaveprop.Workers.AsosAdjustmentWorker},
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker}
]}

View file

@ -88,6 +88,7 @@ config :microwaveprop, Oban,
{Oban.Plugins.Cron,
crontab: [
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"*/2 * * * *", Microwaveprop.Workers.MrmsFetchWorker},
{"*/10 * * * *", Microwaveprop.Workers.AsosAdjustmentWorker},
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker},
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker}

View file

@ -17,6 +17,7 @@ defmodule Microwaveprop.Application do
Microwaveprop.Cache,
Microwaveprop.Propagation.ScoreCache,
Microwaveprop.Weather.GridCache,
Microwaveprop.Weather.MrmsCache,
Microwaveprop.Weather.NexradCache,
{Oban, Application.fetch_env!(:microwaveprop, Oban)},
Microwaveprop.RepoListener,

View file

@ -56,18 +56,7 @@ defmodule Microwaveprop.Beacons.Beacon do
def format_mw(nil), do: ""
def format_mw(mw) when is_number(mw) do
int_part = trunc(mw)
frac = mw - int_part
formatted_int = add_commas(int_part)
if frac == 0.0 do
formatted_int
else
decimal = mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
[_int, frac_part] = String.split(decimal, ".")
"#{formatted_int}.#{frac_part}"
end
format_number(mw)
end
def format_mw(mw), do: to_string(mw)
@ -77,22 +66,29 @@ defmodule Microwaveprop.Beacons.Beacon do
def format_freq(nil), do: ""
def format_freq(mhz) when is_number(mhz) do
int_part = trunc(mhz)
frac = mhz - int_part
formatted_int = add_commas(int_part)
if frac == 0.0 do
formatted_int
else
decimal = mhz |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
[_int, frac_part] = String.split(decimal, ".")
"#{formatted_int}.#{frac_part}"
end
format_number(mhz)
end
def format_freq(mhz), do: to_string(mhz)
# Shared formatter for format_mw/1 and format_freq/1. Unconditionally
# renders to 3 decimal places, trims trailing zeros, and handles the
# edge case where `trim_trailing_zeros/1` strips the decimal point
# entirely (e.g. 24192.0 → "24192.000" → "24192", which previously
# crashed the `[_int, frac_part] = ...` pattern match when
# `frac != 0.0` due to float rounding error).
defp format_number(n) when is_integer(n), do: add_commas(n)
defp format_number(n) when is_float(n) do
formatted_int = n |> trunc() |> add_commas()
decimal = n |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
case String.split(decimal, ".") do
[_int_only] -> formatted_int
[_int, frac_part] -> "#{formatted_int}.#{frac_part}"
end
end
defp add_commas(int) do
int
|> Integer.to_string()

View file

@ -40,6 +40,13 @@ defmodule Microwaveprop.Propagation.AsosNudge do
@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(),
@ -60,6 +67,8 @@ defmodule Microwaveprop.Propagation.AsosNudge do
@type hrrr_profile :: map()
@type rain_grid :: %{{float(), float()} => float()}
@type grid_score :: %{
lat: float(),
lon: float(),
@ -70,22 +79,32 @@ defmodule Microwaveprop.Propagation.AsosNudge do
}
@doc """
Nudge every HRRR grid cell that has an ASOS station within 250 km and
re-score it. Cells outside that radius are dropped so the existing HRRR
scores stay in place.
"""
@spec compute([observation()], DateTime.t(), [hrrr_profile()]) :: [grid_score()]
def compute([], _valid_time, _profiles), do: []
def compute(_observations, _valid_time, []), do: []
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.
def compute(observations, valid_time, profiles) do
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(&any_station_within_radius?(&1, residuals))
|> 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
@ -172,6 +191,26 @@ defmodule Microwaveprop.Propagation.AsosNudge do
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

View file

@ -0,0 +1,80 @@
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

View file

@ -0,0 +1,181 @@
defmodule Microwaveprop.Weather.MrmsClient do
@moduledoc """
Fetch NOAA MRMS `PrecipRate` surface rain-rate mosaics and interpolate
them onto the same 0.125° propagation grid we score against. MRMS is
~1 km resolution and updates every 2 minutes, so between hourly HRRR runs
it's the freshest rain signal we can get over CONUS.
The module is pure transport + decode: fetch, gunzip, run wgrib2 to
regrid, return `%{{lat, lon} => rain_rate_mmhr}`. Caching and cron
scheduling live in `Microwaveprop.Weather.MrmsCache` and
`Microwaveprop.Workers.MrmsFetchWorker`.
"""
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Weather.Grib2.Wgrib2
require Logger
@base_url "https://mrms.ncep.noaa.gov/2D/PrecipRate/"
@match_pattern ":PrecipRate:"
@missing_sentinel -3.0
@type rain_grid :: %{{float(), float()} => float()}
@type listing_entry :: %{filename: String.t(), valid_time: DateTime.t()}
@doc """
List the most recent MRMS PrecipRate files published on the NCEP index.
Returns entries sorted newest-first so callers can just take the head.
"""
@spec list_latest(non_neg_integer()) :: {:ok, [listing_entry()]} | {:error, term()}
def list_latest(limit \\ 10) do
case Req.get(@base_url, req_options()) do
{:ok, %{status: 200, body: body}} ->
entries =
body
|> parse_listing()
|> Enum.sort_by(& &1.valid_time, {:desc, DateTime})
|> Enum.take(limit)
{:ok, entries}
{:ok, %{status: status}} ->
{:error, {:http_status, status}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Fetch a single MRMS file by filename, decompress, regrid to the 0.125°
propagation grid, and return `{valid_time, %{{lat, lon} => rain_rate_mmhr}}`.
Missing-data sentinels (-3) are dropped from the returned grid so callers
can test membership directly.
"""
@spec fetch_and_decode(String.t()) :: {:ok, DateTime.t(), rain_grid()} | {:error, term()}
def fetch_and_decode(filename) do
with {:ok, valid_time} <- parse_filename(filename),
{:ok, grib_bytes} <- download(filename),
{:ok, tmp_path} <- write_temp(grib_bytes),
{:ok, grid} <- regrid(tmp_path) do
File.rm(tmp_path)
{:ok, valid_time, grid}
end
end
@doc """
Convenience: list the latest files, grab the newest that isn't already
cached, and return the decoded grid. `known_valid_time` is whatever the
caller already has if the latest file matches it we short-circuit.
"""
@spec fetch_latest(DateTime.t() | nil) ::
{:ok, DateTime.t(), rain_grid()} | {:up_to_date, DateTime.t()} | {:error, term()}
def fetch_latest(known_valid_time \\ nil) do
case list_latest(1) do
{:ok, [latest | _]} ->
if known_valid_time && DateTime.compare(latest.valid_time, known_valid_time) != :gt do
{:up_to_date, known_valid_time}
else
fetch_and_decode(latest.filename)
end
{:ok, []} ->
{:error, :no_files_listed}
other ->
other
end
end
# -- internals (public-but-undocumented for tests) --------------------
@doc false
def parse_listing(html) do
~r/MRMS_PrecipRate_00\.00_(\d{8}-\d{6})\.grib2\.gz/
|> Regex.scan(html)
|> Enum.uniq()
|> Enum.flat_map(fn [full, stamp] ->
case parse_stamp(stamp) do
{:ok, dt} -> [%{filename: full, valid_time: dt}]
_ -> []
end
end)
end
defp parse_filename(filename) do
case Regex.run(~r/MRMS_PrecipRate_00\.00_(\d{8}-\d{6})\.grib2\.gz/, filename) do
[_, stamp] -> parse_stamp(stamp)
_ -> {:error, :bad_filename}
end
end
defp parse_stamp(stamp) do
with <<y::binary-4, m::binary-2, d::binary-2, "-", h::binary-2, mi::binary-2, s::binary-2>> <-
stamp,
{:ok, naive} <-
NaiveDateTime.new(si(y), si(m), si(d), si(h), si(mi), si(s)) do
{:ok, DateTime.from_naive!(naive, "Etc/UTC")}
else
_ -> {:error, :bad_stamp}
end
end
defp si(s), do: String.to_integer(s)
defp download(filename) do
# Req auto-decompresses gzip responses, so the body we receive is
# already the raw GRIB2 binary (starts with "GRIB" magic) even though
# the URL ends in .gz. No gunzip step needed.
url = @base_url <> filename
case Req.get(url, req_options()) do
{:ok, %{status: 200, body: body}} when is_binary(body) ->
{:ok, body}
{:ok, %{status: status}} ->
{:error, {:http_status, status}}
{:error, reason} ->
{:error, reason}
end
end
defp write_temp(grib_bytes) do
path = Path.join(System.tmp_dir!(), "mrms_preciprate_#{System.unique_integer([:positive])}.grib2")
File.write!(path, grib_bytes)
{:ok, path}
end
defp regrid(tmp_path) do
case Wgrib2.extract_grid_from_file(tmp_path, @match_pattern, Grid.wgrib2_grid_spec()) do
{:ok, cells} -> {:ok, flatten_rain_grid(cells)}
error -> error
end
end
@doc false
def flatten_rain_grid(cells) do
Enum.reduce(cells, %{}, fn {{lat, lon}, fields}, acc ->
case extract_preciprate(fields) do
nil -> acc
rate -> Map.put(acc, {lat, lon}, rate)
end
end)
end
defp extract_preciprate(fields) do
Enum.find_value(fields, fn
{"PrecipRate" <> _, value} when is_number(value) and value > @missing_sentinel and value >= 0 ->
value
_ ->
nil
end)
end
defp req_options do
[receive_timeout: 120_000, retry: :safe_transient, max_retries: 3]
end
end

View file

@ -34,6 +34,7 @@ defmodule Microwaveprop.Workers.AsosAdjustmentWorker do
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Weather.MrmsCache
require Logger
@ -52,36 +53,57 @@ defmodule Microwaveprop.Workers.AsosAdjustmentWorker do
@impl Oban.Worker
def perform(%Oban.Job{}) do
with {:ok, valid_time} <- latest_hrrr_valid_time(),
{:ok, observations} <- IemClient.fetch_current_asos(@state_networks),
[_ | _] = fresh <- filter_fresh(observations),
[_ | _] = profiles <- load_hrrr_profiles(valid_time) do
apply_nudge(fresh, profiles, valid_time)
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: no fresh ASOS data, skipping")
:ok
{:error, reason} ->
Logger.warning("AsosAdjustment: aborted — #{inspect(reason)}")
Logger.info("AsosAdjustment: HRRR valid_time has no grid profiles, skipping")
:ok
end
end
defp apply_nudge(observations, profiles, valid_time) do
scores = AsosNudge.compute(observations, valid_time, profiles)
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 but no grid cells within range")
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 at #{valid_time}")
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

View file

@ -0,0 +1,41 @@
defmodule Microwaveprop.Workers.MrmsFetchWorker do
@moduledoc """
Every ~2 minutes, pull the newest NOAA MRMS PrecipRate grid and cache
the regridded version (0.125° CONUS) in `Microwaveprop.Weather.MrmsCache`.
`Microwaveprop.Workers.AsosAdjustmentWorker` reads that cache on its
own tick to overlay real radar rain rates onto the score grid.
Runs on the `propagation` queue so it shares the scoring node's crontab
and isn't competing with the heavier HRRR pipeline for slots.
"""
use Oban.Worker, queue: :propagation, max_attempts: 2
alias Microwaveprop.Weather.MrmsCache
alias Microwaveprop.Weather.MrmsClient
require Logger
@impl Oban.Worker
def perform(%Oban.Job{}) do
case MrmsClient.fetch_latest(MrmsCache.valid_time()) do
{:ok, valid_time, grid} ->
MrmsCache.broadcast_put(valid_time, grid)
Logger.info("MrmsFetch: cached #{map_size(grid)} cells at #{valid_time} (#{count_rainy(grid)} with rain)")
:ok
{:up_to_date, valid_time} ->
Logger.debug("MrmsFetch: cache already has #{valid_time}")
:ok
{:error, reason} ->
Logger.warning("MrmsFetch: skipped — #{inspect(reason)}")
:ok
end
end
defp count_rainy(grid) do
Enum.count(grid, fn {_, v} -> v > 0 end)
end
end

View file

@ -20,6 +20,8 @@ defmodule MicrowavepropWeb.MapLive do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
socket = assign(socket, :initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"))
case LiveStash.recover_state(socket) do
{:recovered, socket} ->
{:ok, socket}
@ -398,8 +400,10 @@ defmodule MicrowavepropWeb.MapLive do
<div
id="utc-clock"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="mobile-panel-toggle"
@ -538,8 +542,10 @@ defmodule MicrowavepropWeb.MapLive do
<div
id="utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="sidebar-collapse"

View file

@ -152,6 +152,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
selected_layer: "refractivity_gradient",
initial_data_json: Jason.encode!(data),
valid_time: valid_time,
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
bounds: @initial_bounds
)}
end
@ -253,8 +254,10 @@ defmodule MicrowavepropWeb.WeatherMapLive do
<div
id="weather-utc-clock-mobile"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-mobile-panel-toggle"
@ -369,8 +372,10 @@ defmodule MicrowavepropWeb.WeatherMapLive do
<div
id="weather-utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-sidebar-collapse"

View file

@ -1,5 +1,6 @@
defmodule Microwaveprop.BeaconsTest do
use Microwaveprop.DataCase
use ExUnitProperties
import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures
@ -17,6 +18,98 @@ defmodule Microwaveprop.BeaconsTest do
height_ft: nil
}
describe "Beacon.format_freq/1" do
test "renders integer frequencies with comma separators" do
assert Beacon.format_freq(10_368) == "10,368"
end
test "renders whole-number floats cleanly (regression for 24192.0 crash)" do
# 24192.0 used to crash format_freq because float rounding made
# `frac == 0.0` sometimes false, and trim_trailing_zeros then stripped
# the decimal point, leaving a single-element split result that
# couldn't be destructured as `[_int, frac_part]`.
assert Beacon.format_freq(24_192.0) == "24,192"
assert Beacon.format_freq(10_368.0) == "10,368"
end
test "renders fractional frequencies with trimmed trailing zeros" do
assert Beacon.format_freq(10_368.1) == "10,368.1"
assert Beacon.format_freq(10_368.123) == "10,368.123"
end
test "handles nil" do
assert Beacon.format_freq(nil) == ""
end
end
describe "Beacon.format_mw/1" do
test "handles whole-number floats cleanly (regression for the 24192 crash)" do
assert Beacon.format_mw(100.0) == "100"
assert Beacon.format_mw(1000.0) == "1,000"
end
test "renders integer power with commas" do
assert Beacon.format_mw(5000) == "5,000"
end
test "renders fractional power with trimmed trailing zeros" do
assert Beacon.format_mw(12.5) == "12.5"
assert Beacon.format_mw(0.125) == "0.125"
end
test "handles nil" do
assert Beacon.format_mw(nil) == ""
end
end
describe "Beacon.format_freq/1 property tests" do
# Ranges cover the realistic microwave band set (902 MHz to 241 GHz)
# plus some float edge cases that trigger rounding quirks.
property "never crashes on any integer in the microwave range" do
check all(n <- integer(1..300_000)) do
result = Beacon.format_freq(n)
assert is_binary(result)
assert result != ""
end
end
property "never crashes on any float in the microwave range" do
check all(n <- float(min: 1.0, max: 300_000.0)) do
result = Beacon.format_freq(n)
assert is_binary(result)
assert result != ""
end
end
property "never crashes on any float including whole numbers and edge floats" do
check all(
n <-
one_of([
float(min: 0.0, max: 300_000.0),
map(integer(1..300_000), &(&1 * 1.0)),
constant(24_192.0),
constant(10_368.0),
constant(47_088.0)
])
) do
assert is_binary(Beacon.format_freq(n))
end
end
property "format_mw never crashes on any number" do
check all(
n <-
one_of([
integer(0..1_000_000),
float(min: 0.0, max: 1_000_000.0),
map(integer(0..1000), &(&1 * 1.0))
])
) do
assert is_binary(Beacon.format_mw(n))
end
end
end
describe "list_beacons/0" do
test "returns only approved beacons" do
u1 = user_fixture()

View file

@ -182,5 +182,38 @@ defmodule Microwaveprop.Propagation.AsosNudgeTest do
assert {32.0, -97.0} in lat_lons
refute {40.0, -85.0} in lat_lons
end
test "re-scores grid cells with MRMS rain even when no ASOS station is nearby" do
far_cell = profile(40.0, -85.0, surface_temp_c: 20.0)
rain_grid = %{{40.0, -85.0} => 12.5}
results = AsosNudge.compute([], @valid_time, [far_cell], rain_grid)
assert length(results) > 0
lat_lons = results |> Enum.map(&{&1.lat, &1.lon}) |> Enum.uniq()
assert {40.0, -85.0} in lat_lons
end
test "ignores MRMS rain below the 0.1 mm/hr threshold so dry cells keep raw HRRR scores" do
cell = profile(40.0, -85.0, surface_temp_c: 20.0)
rain_grid = %{{40.0, -85.0} => 0.05}
assert AsosNudge.compute([], @valid_time, [cell], rain_grid) == []
end
test "MRMS rain is applied through to the scored profile when above threshold" do
cell = profile(40.0, -85.0, surface_temp_c: 20.0)
rain_grid_dry = %{}
rain_grid_wet = %{{40.0, -85.0} => 15.0}
obs = station(40.0, -85.0, temp_f: 68.0)
[dry_result | _] = AsosNudge.compute([obs], @valid_time, [cell], rain_grid_dry)
[wet_result | _] = AsosNudge.compute([obs], @valid_time, [cell], rain_grid_wet)
# Heavy rain should drop the score meaningfully vs the same cell with
# no rain. (Exact delta depends on the band weights but the 0→15 mm/hr
# transition always reduces the rain factor score.)
assert wet_result.factors.rain < dry_result.factors.rain
end
end
end

View file

@ -0,0 +1,72 @@
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