prop/lib/microwaveprop/radio/maidenhead.ex
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

119 lines
3.8 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Radio.Maidenhead do
@moduledoc false
# Maidenhead grid: Field (2 letters A-R) + Square (2 digits) + optional
# repeating pairs of Subsquare (2 letters) and Extended Square (2 digits).
# Minimum 4 chars, always even length. E.g. EM12, EM12kp, EM12kp37, EM12kp37ab
@spec valid?(any()) :: boolean()
def valid?(nil), do: false
def valid?(grid) when is_binary(grid) do
len = byte_size(grid)
len >= 4 and rem(len, 2) == 0 and String.match?(grid, grid_regex())
end
def valid?(_), do: false
# Field: [A-Ra-r]{2}, Square: [0-9]{2}, then alternating letter/digit pairs
defp grid_regex do
~r/^[A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2}(?:[0-9]{2}(?:[A-Xa-x]{2}(?:[0-9]{2})*)*)*)*$/
end
@spec to_latlon(any()) :: {:ok, {float(), float()}} | :error
def to_latlon(nil), do: :error
def to_latlon(grid) when is_binary(grid) do
grid = String.upcase(grid)
if valid?(grid) do
<<f1, f2, s1, s2>> <> rest = grid
# Field: 18 divisions (A-R), 20° lon / 10° lat each
lon = (f1 - ?A) * 20.0 - 180.0
lat = (f2 - ?A) * 10.0 - 90.0
# Square: 10 divisions (0-9), 2° lon / 1° lat each
lon = lon + (s1 - ?0) * 2.0
lat = lat + (s2 - ?0) * 1.0
chars = String.to_charlist(rest)
{lat, lon, lat_size, lon_size} = decode_pairs(chars, lat, lon, 1.0, 2.0, :letter)
{:ok, {lat + lat_size / 2, lon + lon_size / 2}}
else
:error
end
end
def to_latlon(_), do: :error
@doc """
Converts a lat/lon pair into a Maidenhead grid at the requested
precision (must be an even number between 4 and 8). Default precision
is 6 (subsquare level, ~5'×2.5', ~7 km × 4.6 km at the equator).
"""
@spec from_latlon(number(), number(), pos_integer()) :: String.t()
def from_latlon(lat, lon, precision \\ 6) when is_number(lat) and is_number(lon) and precision in [4, 6, 8] do
# Clamp into the open ranges the field encoder expects. Exactly
# `90.0` / `180.0` would otherwise overflow the trunc into 'S'
# (one past 'R'), producing grids outside the A..R alphabet.
lat = min(max(lat + 90.0, 0.0), 180.0 - 1.0e-9)
lon = min(max(lon + 180.0, 0.0), 360.0 - 1.0e-9)
# Field (A-R), 20° lon / 10° lat
f1 = trunc(lon / 20.0)
f2 = trunc(lat / 10.0)
lon = lon - f1 * 20.0
lat = lat - f2 * 10.0
# Square (0-9), 2° lon / 1° lat
s1 = trunc(lon / 2.0)
s2 = trunc(lat / 1.0)
lon = lon - s1 * 2.0
lat = lat - s2 * 1.0
grid = <<?A + f1, ?A + f2, ?0 + s1, ?0 + s2>>
cond do
precision == 4 ->
grid
precision >= 6 ->
# Subsquare (a-x), 5'/60 lon, 2.5'/60 lat
ss1 = trunc(lon * 60.0 / 5.0)
ss2 = trunc(lat * 60.0 / 2.5)
lon = lon - ss1 * (5.0 / 60.0)
lat = lat - ss2 * (2.5 / 60.0)
grid = grid <> <<?a + ss1, ?a + ss2>>
if precision == 8 do
# Extended square (0-9), (5/60)/10 lon, (2.5/60)/10 lat
e1 = trunc(lon * 60.0 / 0.5)
e2 = trunc(lat * 60.0 / 0.25)
grid <> <<?0 + e1, ?0 + e2>>
else
grid
end
end
end
# Decode remaining alternating letter(24)/digit(10) pairs after field+square
defp decode_pairs([c1, c2 | rest], lat, lon, lat_size, lon_size, :letter) do
cell_lon = lon_size / 24
cell_lat = lat_size / 24
lon = lon + (c1 - ?A) * cell_lon
lat = lat + (c2 - ?A) * cell_lat
decode_pairs(rest, lat, lon, cell_lat, cell_lon, :digit)
end
defp decode_pairs([c1, c2 | rest], lat, lon, lat_size, lon_size, :digit) do
cell_lon = lon_size / 10
cell_lat = lat_size / 10
lon = lon + (c1 - ?0) * cell_lon
lat = lat + (c2 - ?0) * cell_lat
decode_pairs(rest, lat, lon, cell_lat, cell_lon, :letter)
end
defp decode_pairs([], lat, lon, lat_size, lon_size, _type) do
{lat, lon, lat_size, lon_size}
end
end