- 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.
103 lines
2.9 KiB
Elixir
103 lines
2.9 KiB
Elixir
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 (4–10 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
|