prop/test/microwaveprop/beacons_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

430 lines
13 KiB
Elixir

defmodule Microwaveprop.BeaconsTest do
use Microwaveprop.DataCase
use ExUnitProperties
import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@invalid_attrs %{
frequency_mhz: nil,
callsign: nil,
grid: nil,
lat: nil,
lon: nil,
power_mw: nil,
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()
u2 = user_fixture()
{:ok, b1} = Beacons.create_beacon(u1, valid_beacon_attrs())
{:ok, b1} = Beacons.approve_beacon(b1)
_pending = beacon_fixture(u2, callsign: "W5TX")
assert [^b1] = Beacons.list_beacons()
end
end
describe "list_pending_beacons/0" do
test "returns only unapproved beacons" do
u1 = user_fixture()
u2 = user_fixture()
{:ok, approved} = Beacons.create_beacon(u1, valid_beacon_attrs())
{:ok, _approved} = Beacons.approve_beacon(approved)
pending = beacon_fixture(u2, callsign: "W5TX")
assert [^pending] = Beacons.list_pending_beacons()
end
end
describe "approve_beacon/1" do
test "marks the beacon as approved and broadcasts an update" do
user = user_fixture()
beacon = beacon_fixture(user)
refute beacon.approved
Beacons.subscribe_beacons()
assert {:ok, approved} = Beacons.approve_beacon(beacon)
assert approved.approved == true
assert_receive {:updated, %Beacon{approved: true}}
end
end
describe "get_beacon!/1" do
test "returns the beacon with given id" do
user = user_fixture()
beacon = beacon_fixture(user)
assert Beacons.get_beacon!(beacon.id).id == beacon.id
end
end
describe "create_beacon/2" do
test "with valid data creates a beacon and records the creator" do
user = user_fixture()
attrs = valid_beacon_attrs()
assert {:ok, %Beacon{} = beacon} = Beacons.create_beacon(user, attrs)
assert beacon.frequency_mhz == attrs.frequency_mhz
assert beacon.callsign == "W5HN"
assert beacon.lat == attrs.lat
assert beacon.lon == attrs.lon
assert beacon.power_mw == attrs.power_mw
assert beacon.height_ft == attrs.height_ft
assert beacon.user_id == user.id
end
test "anonymous submission creates a beacon with no user_id, unapproved" do
attrs = valid_beacon_attrs()
assert {:ok, %Beacon{} = beacon} = Beacons.create_beacon(nil, attrs)
assert beacon.user_id == nil
assert beacon.approved == false
end
test "new beacons default to unapproved" do
user = user_fixture()
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.approved == false
end
test "ignores client-supplied approved value" do
user = user_fixture()
attrs = Map.put(valid_beacon_attrs(), :approved, true)
assert {:ok, beacon} = Beacons.create_beacon(user, attrs)
assert beacon.approved == false
end
test "derives grid from lat/lon when grid is omitted" do
user = user_fixture()
# DFW is EM12
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(lat: 32.897, lon: -97.038))
assert String.starts_with?(beacon.grid, "EM12")
end
test "derives lat/lon from grid when lat/lon are omitted" do
user = user_fixture()
attrs =
[grid: "EM12kp"]
|> valid_beacon_attrs()
|> Map.drop([:lat, :lon])
assert {:ok, beacon} = Beacons.create_beacon(user, attrs)
assert beacon.grid == "EM12kp"
assert_in_delta beacon.lat, 32.6458, 0.01
assert_in_delta beacon.lon, -97.125, 0.01
end
test "keeps an explicitly provided valid grid" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(grid: "EM13"))
assert beacon.grid == "EM13"
end
test "rejects an invalid grid" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(grid: "ZZ99"))
assert "is not a valid Maidenhead grid" in errors_on(changeset).grid
end
test "defaults on_the_air to true" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.on_the_air == true
end
test "accepts on_the_air: false" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(on_the_air: false))
assert beacon.on_the_air == false
end
test "defaults keying to on_off" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.keying == "on_off"
end
test "accepts keying: fsk" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: "fsk"))
assert beacon.keying == "fsk"
end
test "rejects an unknown keying" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(keying: "cw"))
assert "is invalid" in errors_on(changeset).keying
end
test "accepts fm_voice keying" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: "fm_voice"))
assert beacon.keying == "fm_voice"
end
test "accepts wspr keying" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: "wspr"))
assert beacon.keying == "wspr"
end
test "accepts all Q65 keying variants" do
user = user_fixture()
variants =
for letter <- ~w(a b c d e), period <- ~w(15 30 60 120), do: "q65#{letter}_#{period}"
for keying <- variants do
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: keying))
assert beacon.keying == keying
end
end
test "defaults bearing to omni" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.bearing == "omni"
end
test "normalizes empty bearing to omni" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: ""))
assert beacon.bearing == "omni"
end
test "normalizes 'Omni' to 'omni'" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "Omni"))
assert beacon.bearing == "omni"
end
test "accepts a numeric bearing" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "90"))
assert beacon.bearing == "90"
end
test "accepts bearing of 0 and 360" do
user = user_fixture()
{:ok, b0} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "0"))
assert b0.bearing == "0"
{:ok, b360} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "360"))
assert b360.bearing == "360"
end
test "rejects a non-numeric non-omni bearing" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "north"))
assert errors_on(changeset).bearing != []
end
test "rejects a bearing greater than 360" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "400"))
assert errors_on(changeset).bearing != []
end
test "rejects a negative bearing" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "-10"))
assert errors_on(changeset).bearing != []
end
test "stores beamwidth_deg" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 30.0))
assert beacon.beamwidth_deg == 30.0
end
test "rejects a non-positive beamwidth" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 0))
assert errors_on(changeset).beamwidth_deg != []
end
test "rejects beamwidth > 360" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 400))
assert errors_on(changeset).beamwidth_deg != []
end
test "stores notes" do
user = user_fixture()
notes = "Antenna 20 ft AGL, horizontal polarization"
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(notes: notes))
assert beacon.notes == notes
end
test "upcases the callsign" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))
assert beacon.callsign == "W5HN"
end
test "rejects non-positive frequency" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(frequency_mhz: 0))
assert "must be greater than 0" in errors_on(changeset).frequency_mhz
end
test "with invalid data returns error changeset" do
user = user_fixture()
assert {:error, %Ecto.Changeset{}} = Beacons.create_beacon(user, @invalid_attrs)
end
end
describe "update_beacon/2" do
test "with valid data updates the beacon" do
user = user_fixture()
beacon = beacon_fixture(user)
update_attrs = %{frequency_mhz: 24_192.1, power_mw: 5.0}
assert {:ok, %Beacon{} = beacon} = Beacons.update_beacon(beacon, update_attrs)
assert beacon.frequency_mhz == 24_192.1
assert beacon.power_mw == 5.0
end
test "with invalid data returns error changeset" do
user = user_fixture()
beacon = beacon_fixture(user)
assert {:error, %Ecto.Changeset{}} = Beacons.update_beacon(beacon, @invalid_attrs)
assert Beacons.get_beacon!(beacon.id).callsign == beacon.callsign
end
end
describe "delete_beacon/1" do
test "deletes the beacon" do
user = user_fixture()
beacon = beacon_fixture(user)
assert {:ok, %Beacon{}} = Beacons.delete_beacon(beacon)
assert_raise Ecto.NoResultsError, fn -> Beacons.get_beacon!(beacon.id) end
end
end
describe "change_beacon/1" do
test "returns a beacon changeset" do
user = user_fixture()
beacon = beacon_fixture(user)
assert %Ecto.Changeset{} = Beacons.change_beacon(beacon)
end
end
end