prop/lib/microwaveprop/geo.ex
Graham McIntire 2defa3db7a
refactor: deduplicate helpers and simplify Scorer
- Unify haversine_km / deg_to_rad: Geo is the canonical home (atan2
  form for antipodal stability); Radio, common_volume, rain_scatter,
  ionosphere, terrain/srtm, terrain/elevation_client now delegate.
- Drop safe_min/safe_max wrappers in favor of Enum.min/max defaults.
- Move parse_float / parse_int to MicrowavepropWeb.LiveHelpers; eme,
  path, and rover LiveViews import from there.
- Extract MicrowavepropWeb.LocationResolver from the eme/path
  resolve_source / resolve_location duplicate. Standardize the kind
  key and "Invalid grid square" error message.
- Convert Scorer cond chains (humidity, td_depression, sky, wind,
  rain, pwat, pressure) to multi-clause guarded defs, matching the
  existing classify_time_period style.
- extract_profile_fields uses a named map accumulator instead of a
  6-tuple; build_path_conditions guards on %{temps: []} / %{dewpoints: []}.
- Collapse scores_file clamp/3 to max |> min.
- humidity_effect_label lives in BandConfig; map_live and path_live
  both use it.
2026-04-28 14:14:34 -05:00

59 lines
2.1 KiB
Elixir
Raw Permalink 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.Geo do
@moduledoc "Geodetic helper functions: distances, bearings, coordinate math."
alias Microwaveprop.Radio.Maidenhead
@earth_radius_km 6371.0
@doc "Great-circle distance between two points in km."
@spec haversine_km(number(), number(), number(), number()) :: float()
def haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1)
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
a =
:math.sin(dlat / 2) ** 2 +
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
# `atan2(√a, √(1a))` is the numerically stable form: when `a`
# rounds to slightly above 1 the asin form returns NaN, while
# atan2 stays finite.
2 * @earth_radius_km * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
end
@doc "Initial bearing from point 1 to point 2 in degrees (0-360)."
@spec bearing_deg(number(), number(), number(), number()) :: float()
def bearing_deg(lat1, lon1, lat2, lon2) do
lat1r = deg_to_rad(lat1)
lat2r = deg_to_rad(lat2)
dlonr = deg_to_rad(lon2 - lon1)
y = :math.sin(dlonr) * :math.cos(lat2r)
x = :math.cos(lat1r) * :math.sin(lat2r) - :math.sin(lat1r) * :math.cos(lat2r) * :math.cos(dlonr)
b = :math.atan2(y, x) * 180 / :math.pi()
Float.round(:math.fmod(b + 360, 360), 1)
end
@doc "Center of a 4-character Maidenhead grid square."
@spec maidenhead_center(String.t()) :: {float(), float()} | nil
def maidenhead_center(grid) when is_binary(grid) and byte_size(grid) >= 4 do
case Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} -> {lat, lon}
:error -> nil
end
end
@doc "4-character Maidenhead grid for a lat/lon."
@spec latlon_to_grid4(number(), number()) :: String.t()
def latlon_to_grid4(lat, lon) do
lon_field = trunc((lon + 180) / 20)
lat_field = trunc((lat + 90) / 10)
lon_sq = trunc(rem(trunc(lon + 180), 20) / 2)
lat_sq = trunc(rem(trunc(lat + 90), 10) / 1)
<<?A + lon_field, ?A + lat_field, ?0 + lon_sq, ?0 + lat_sq>>
end
defp deg_to_rad(d), do: d * :math.pi() / 180
end