prop/test/microwaveprop/propagation/asos_nudge_test.exs
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

219 lines
7.8 KiB
Elixir

defmodule Microwaveprop.Propagation.AsosNudgeTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.AsosNudge
@valid_time ~U[2026-04-12 18:00:00Z]
defp profile(lat, lon, overrides \\ []) do
Map.merge(
%{
valid_time: @valid_time,
lat: lat,
lon: lon,
surface_temp_c: 20.0,
surface_dewpoint_c: 10.0,
surface_pressure_mb: 1013.0,
hpbl_m: 1000.0,
pwat_mm: 25.0,
min_refractivity_gradient: -40.0,
surface_refractivity: 320.0,
profile: []
},
Map.new(overrides)
)
end
defp station(lat, lon, overrides \\ []) do
Map.merge(
%{
lat: lat,
lon: lon,
temp_f: 68.0,
dewpoint_f: 50.0,
sea_level_pressure_mb: 1013.0,
precip_1h_in: 0.0
},
Map.new(overrides)
)
end
describe "build_station_residuals/2" do
test "station co-located with a HRRR grid cell yields residuals against that cell" do
station_obs = station(32.0, -97.0, temp_f: 77.0, dewpoint_f: 59.0, sea_level_pressure_mb: 1020.0)
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0)
[residual] = AsosNudge.build_station_residuals([station_obs], [hrrr])
assert_in_delta residual.dtemp_c, 5.0, 0.1
assert_in_delta residual.ddewpoint_c, 5.0, 0.1
assert_in_delta residual.dpressure_mb, 7.0, 0.1
assert residual.lat == 32.0
assert residual.lon == -97.0
end
test "station with no HRRR cell within 1 grid step is dropped" do
obs = station(32.0, -97.0, temp_f: 77.0)
far_hrrr = profile(40.0, -85.0, surface_temp_c: 20.0)
assert AsosNudge.build_station_residuals([obs], [far_hrrr]) == []
end
test "station with nil temp is dropped" do
obs = station(32.0, -97.0, temp_f: nil)
hrrr = profile(32.0, -97.0)
assert AsosNudge.build_station_residuals([obs], [hrrr]) == []
end
test "station with nil pressure still contributes temp and dewpoint deltas" do
obs = station(32.0, -97.0, temp_f: 77.0, dewpoint_f: 59.0, sea_level_pressure_mb: nil)
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0)
[residual] = AsosNudge.build_station_residuals([obs], [hrrr])
assert_in_delta residual.dtemp_c, 5.0, 0.1
assert_in_delta residual.ddewpoint_c, 5.0, 0.1
assert residual.dpressure_mb == nil
end
end
describe "nudge_profile/2" do
test "single station at the same grid cell applies (almost) the full residual" do
residuals = [
%{lat: 32.0, lon: -97.0, dtemp_c: 5.0, ddewpoint_c: 3.0, dpressure_mb: 2.0, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert_in_delta nudged.surface_temp_c, 25.0, 0.1
assert_in_delta nudged.surface_dewpoint_c, 13.0, 0.1
assert_in_delta nudged.surface_pressure_mb, 1015.0, 0.1
end
test "station farther than 250 km contributes nothing" do
residuals = [
%{lat: 40.0, lon: -97.0, dtemp_c: 10.0, ddewpoint_c: 10.0, dpressure_mb: 5.0, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert nudged.surface_temp_c == 20.0
assert nudged.surface_dewpoint_c == 10.0
assert nudged.surface_pressure_mb == 1013.0
end
test "two equidistant stations with opposite residuals average to near zero" do
residuals = [
%{lat: 32.0, lon: -95.93, dtemp_c: 5.0, ddewpoint_c: 0.0, dpressure_mb: 0.0, asos_rain_mmhr: 0.0},
%{lat: 32.0, lon: -98.07, dtemp_c: -5.0, ddewpoint_c: 0.0, dpressure_mb: 0.0, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert_in_delta nudged.surface_temp_c, 20.0, 0.1
end
test "nil residual components (missing pressure) leave the HRRR field unchanged" do
residuals = [
%{lat: 32.0, lon: -97.0, dtemp_c: 5.0, ddewpoint_c: 3.0, dpressure_mb: nil, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert_in_delta nudged.surface_temp_c, 25.0, 0.1
assert_in_delta nudged.surface_dewpoint_c, 13.0, 0.1
assert nudged.surface_pressure_mb == 1013.0
end
test "upper-air fields pass through unchanged regardless of residuals" do
residuals = [
%{lat: 32.0, lon: -97.0, dtemp_c: 10.0, ddewpoint_c: 10.0, dpressure_mb: 20.0, asos_rain_mmhr: 0.0}
]
hrrr = profile(32.0, -97.0, min_refractivity_gradient: -250.0, pwat_mm: 35.0, hpbl_m: 500.0)
nudged = AsosNudge.nudge_profile(hrrr, residuals)
assert nudged.min_refractivity_gradient == -250.0
assert nudged.pwat_mm == 35.0
assert nudged.hpbl_m == 500.0
end
end
describe "compute/3" do
test "returns empty list when observations list is empty" do
assert AsosNudge.compute([], @valid_time, [profile(32.0, -97.0)]) == []
end
test "returns empty list when no HRRR profiles provided" do
assert AsosNudge.compute([station(32.0, -97.0)], @valid_time, []) == []
end
test "returns grid score maps for nudged profiles with band_mhz, score, factors" do
hrrr = profile(32.0, -97.0, surface_temp_c: 20.0)
obs = station(32.0, -97.0, temp_f: 77.0)
results = AsosNudge.compute([obs], @valid_time, [hrrr])
assert length(results) > 0
for result <- results do
assert result.lat == 32.0
assert result.lon == -97.0
assert result.valid_time == @valid_time
assert is_integer(result.band_mhz)
assert is_number(result.score)
assert is_map(result.factors)
end
end
test "drops grid points with no station within 250 km from the output" do
nudged_cell = profile(32.0, -97.0, surface_temp_c: 20.0)
far_cell = profile(40.0, -85.0, surface_temp_c: 20.0)
obs = station(32.0, -97.0, temp_f: 77.0)
results = AsosNudge.compute([obs], @valid_time, [nudged_cell, far_cell])
lat_lons = results |> Enum.map(&{&1.lat, &1.lon}) |> Enum.uniq()
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