prop/test/microwaveprop/radio/maidenhead_test.exs
Graham McIntire 6aa91e7656
fix: April 2026 codebase review — address 13 bugs across propagation chain
Each fix is covered by a regression test that fails on `main` and
passes on this commit.

Round 1 (initial review):

* propagation: thread `latitude` into the conditions map so
  `score_season/4` actually picks up regional multipliers
* hrrr_client / fetcher.rs: `nearest_hrrr_hour` rounds DOWN, never at
  a future cycle that NOAA hasn't published yet
* radio: spherical-vector great-circle midpoint replaces the
  arithmetic mean — anti-meridian paths no longer fold to Greenwich
* weather: `reconcile_weather_statuses` scales the longitude band by
  `1 / cos(lat)` so the bbox stays ~150 km wide at every latitude
* radio/maidenhead: clamp 90°/180° below the field-bucket overflow so
  `from_latlon` never emits invalid characters like 'S'
* prop_grid_rs/pipeline: merge HRRR + NEXRAD-derived rain rates and
  read `best_duct_freq_ghz` into `best_duct_band_ghz` so the Native
  Duct Boost actually fires
* propagation/region (Elixir + Rust): inclusive upper bounds so points
  exactly at lat_max get the regional multiplier
* weather/sounding_params (Elixir + Rust): drop the 10 m gradient
  floor so HRRR's thin near-surface layers stop hiding sharp ducts
* weather/sounding_params: when the profile ends inside a duct,
  finalize it with the highest sample as the top instead of throwing
  it away (Rust port already correct)

Round 2 (post-fix sweep):

* radio + commercial: single canonical haversine in Radio (atan2
  form); Commercial delegates instead of carrying a second copy that
  could disagree at threshold distances
* prop_grid_rs/profiles_file: `snap_coords` matches Elixir's
  step-aware snap (`round(coord/0.125) * 0.125`, then 3-dp round) so
  Rust-keyed and Elixir-keyed profile maps land on the same cell
* weather/grib2/wgrib2: `parse_lon_val_segment` uses `Float.parse`
  uniformly — wgrib2 dropping the trailing `.0` from a longitude no
  longer crashes the whole chain step
2026-04-25 10:52:51 -05:00

147 lines
4.6 KiB
Elixir

defmodule Microwaveprop.Radio.MaidenheadTest do
use ExUnit.Case, async: true
alias Microwaveprop.Radio.Maidenhead
describe "valid?/1" do
test "accepts valid 4-char grid" do
assert Maidenhead.valid?("EM12")
end
test "accepts valid 6-char grid" do
assert Maidenhead.valid?("EM12ab")
end
test "is case-insensitive" do
assert Maidenhead.valid?("em12")
assert Maidenhead.valid?("EM12AB")
assert Maidenhead.valid?("em12ab")
end
test "rejects field chars outside A-R" do
refute Maidenhead.valid?("SZ12")
end
test "rejects subsquare chars outside a-x" do
refute Maidenhead.valid?("EM12yz")
end
test "rejects wrong length" do
refute Maidenhead.valid?("EM1")
refute Maidenhead.valid?("EM123")
refute Maidenhead.valid?("EM12abc")
end
test "rejects non-digit in square positions" do
refute Maidenhead.valid?("EMAB")
end
test "rejects empty string" do
refute Maidenhead.valid?("")
end
test "rejects nil" do
refute Maidenhead.valid?(nil)
end
end
describe "to_latlon/1" do
test "returns center of 4-char grid FN31" do
# FN31: F=5, N=13 -> lon = 5*20 - 180 + 3*2 + 1 = -73, lat = 13*10 - 90 + 1*1 + 0.5 = 41.5
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("FN31")
assert_in_delta lat, 41.5, 0.01
assert_in_delta lon, -73.0, 0.01
end
test "returns center of 6-char grid FN31pr" do
# FN31pr: field F=5,N=13; square 3,1; subsquare p=15,r=17
# lon = 5*20 - 180 + 3*2 + 15*(5/60) + (5/120) = -100 + 6 + 1.25 + 0.0417 = -72.7083
# lat = 13*10 - 90 + 1*1 + 17*(2.5/60) + (2.5/120) = 40 + 1 + 0.7083 + 0.02083 = 41.7292
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("FN31pr")
assert_in_delta lat, 41.7292, 0.01
assert_in_delta lon, -72.7083, 0.01
end
test "is case-insensitive" do
assert {:ok, {lat1, lon1}} = Maidenhead.to_latlon("FN31")
assert {:ok, {lat2, lon2}} = Maidenhead.to_latlon("fn31")
assert lat1 == lat2
assert lon1 == lon2
end
test "returns :error for invalid grid" do
assert :error = Maidenhead.to_latlon("ZZ99")
end
test "returns :error for nil" do
assert :error = Maidenhead.to_latlon(nil)
end
test "returns :error for non-binary" do
assert :error = Maidenhead.to_latlon(123)
end
test "handles 8-char extended grid EM12kp37" do
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("EM12kp37")
assert is_float(lat)
assert is_float(lon)
# Should be within EM12kp subsquare
assert lat > 32.0 and lat < 33.0
assert lon > -98.0 and lon < -96.0
end
test "AA00 returns southwest corner center" do
# lon = 0*20 - 180 + 0*2 + 1 = -179, lat = 0*10 - 90 + 0*1 + 0.5 = -89.5
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("AA00")
assert_in_delta lat, -89.5, 0.01
assert_in_delta lon, -179.0, 0.01
end
test "RR99 returns northeast corner center" do
# lon = 17*20 - 180 + 9*2 + 1 = 179, lat = 17*10 - 90 + 9*1 + 0.5 = 89.5
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("RR99")
assert_in_delta lat, 89.5, 0.01
assert_in_delta lon, 179.0, 0.01
end
end
describe "from_latlon/3" do
test "round-trips 4-char grid FN31" do
{:ok, {lat, lon}} = Maidenhead.to_latlon("FN31")
assert Maidenhead.from_latlon(lat, lon, 4) == "FN31"
end
test "round-trips 6-char grid FN31pr" do
{:ok, {lat, lon}} = Maidenhead.to_latlon("FN31pr")
assert Maidenhead.from_latlon(lat, lon, 6) == "FN31pr"
end
test "defaults to precision 6" do
{:ok, {lat, lon}} = Maidenhead.to_latlon("EM12kp")
assert Maidenhead.from_latlon(lat, lon) == "EM12kp"
end
test "round-trips 8-char grid EM12kp37" do
{:ok, {lat, lon}} = Maidenhead.to_latlon("EM12kp37")
assert Maidenhead.from_latlon(lat, lon, 8) == "EM12kp37"
end
test "handles North Texas coordinates" do
# DFW area is approximately EM12/EM13
grid = Maidenhead.from_latlon(32.897, -97.038, 6)
assert String.starts_with?(grid, "EM12")
end
test "clamps the upper field boundary at 90 latitude / 180 longitude" do
# Without clamping, lat = 90 + 90 = 180 → trunc(180/10) = 18, which
# the field encoder turns into 'S' — outside the legal A-R range.
# The same overflow happens at lon = 180. Both must round into the
# last valid field instead of producing an invalid grid character.
grid = Maidenhead.from_latlon(90.0, 180.0, 4)
assert Maidenhead.valid?(grid)
<<f1, f2, _, _>> = grid
assert f1 in ?A..?R
assert f2 in ?A..?R
end
end
end