prop/lib/microwaveprop_web/location_resolver.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

103 lines
2.9 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 MicrowavepropWeb.LocationResolver do
@moduledoc """
Parses user-supplied location input into a normalized lat/lon map.
Accepted formats:
* `"32.9, -96.8"` — decimal coordinates (lat, lon)
* `"EM12kx"` — Maidenhead grid square (410 chars)
* `"W5XYZ"` — amateur callsign (resolved via QRZ + cache)
"""
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
@type kind :: :coordinates | :grid | :callsign
@type resolved :: %{
required(:lat) => float(),
required(:lon) => float(),
required(:label) => String.t(),
required(:kind) => kind(),
optional(:grid) => String.t() | nil
}
@doc """
Resolves an input string. Returns `{:ok, resolved}`, `{:error, msg}`,
or `:empty` for nil/blank input — callers decide whether `:empty`
is an error or a no-op.
"""
@spec resolve(String.t() | nil) :: {:ok, resolved()} | {:error, String.t()} | :empty
def resolve(nil), do: :empty
def resolve(input) when is_binary(input) do
trimmed = String.trim(input)
cond do
trimmed == "" -> :empty
coordinate_pair?(trimmed) -> resolve_coordinates(trimmed)
maidenhead?(trimmed) -> resolve_grid(trimmed)
true -> resolve_callsign(trimmed)
end
end
@doc "True when `input` parses as a `lat, lon` decimal pair."
@spec coordinate_pair?(String.t()) :: boolean()
def coordinate_pair?(input) do
case String.split(input, ",", parts: 2) do
[a, b] ->
match?({_, _}, Float.parse(String.trim(a))) and
match?({_, _}, Float.parse(String.trim(b)))
_ ->
false
end
end
@doc "True when `input` looks like a Maidenhead grid square (4-10 chars, AA00 prefix)."
@spec maidenhead?(String.t()) :: boolean()
def maidenhead?(input) do
len = String.length(input)
len >= 4 and len <= 10 and Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
end
defp resolve_coordinates(input) do
[lat_s, lon_s] = String.split(input, ",", parts: 2)
{lat, _} = Float.parse(String.trim(lat_s))
{lon, _} = Float.parse(String.trim(lon_s))
{:ok,
%{
lat: lat,
lon: lon,
label: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}",
kind: :coordinates
}}
end
defp resolve_grid(input) do
case Maidenhead.to_latlon(input) do
{:ok, {lat, lon}} ->
{:ok, %{lat: lat, lon: lon, label: String.upcase(input), kind: :grid}}
:error ->
{:error, "Invalid grid square: #{input}"}
end
end
defp resolve_callsign(input) do
case CallsignClient.locate(input) do
{:ok, info} ->
{:ok,
%{
lat: info.lat,
lon: info.lon,
label: String.upcase(info.callsign),
grid: info.gridsquare,
kind: :callsign
}}
{:error, reason} ->
{:error, "Could not find #{input}: #{reason}"}
end
end
end