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
119 lines
3.8 KiB
Elixir
119 lines
3.8 KiB
Elixir
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
|