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.
This commit is contained in:
Graham McIntire 2026-04-28 14:14:34 -05:00
parent a6b89961f6
commit 2defa3db7a
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
22 changed files with 389 additions and 386 deletions

View file

@ -10,12 +10,17 @@ defmodule Microwaveprop.Geo do
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(deg_to_rad(lat1)) * :math.cos(deg_to_rad(lat2)) * :math.sin(dlon / 2) ** 2
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
2 * @earth_radius_km * :math.asin(:math.sqrt(a))
# `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)."

View file

@ -99,21 +99,5 @@ defmodule Microwaveprop.Ionosphere do
Enum.min_by(@stations, fn s -> haversine_km(lat, lon, s.lat, s.lon) end)
end
# Standard haversine distance in km. Good enough for station-pick
# ranking — we're not computing hop geometry here.
defp haversine_km(lat1, lon1, lat2, lon2) do
r = 6371.0
dlat = :math.pi() * (lat2 - lat1) / 180
dlon = :math.pi() * (lon2 - lon1) / 180
lat1_rad = :math.pi() * lat1 / 180
lat2_rad = :math.pi() * lat2 / 180
a =
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
:math.cos(lat1_rad) * :math.cos(lat2_rad) *
:math.sin(dlon / 2) * :math.sin(dlon / 2)
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
r * c
end
defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
end

View file

@ -855,6 +855,17 @@ defmodule Microwaveprop.Propagation.BandConfig do
Enum.map(all_bands(), fn band -> {band.label, to_string(band.freq_mhz)} end)
end
@doc """
Human-readable label for a band's `:humidity_effect`. Accepts the
band config map or any map with a `:humidity_effect` key; falls back
to `"neutral"` for missing/unrecognized values so templates can
render unconditionally.
"""
@spec humidity_effect_label(map() | nil) :: String.t()
def humidity_effect_label(%{humidity_effect: :beneficial}), do: "beneficial"
def humidity_effect_label(%{humidity_effect: :harmful}), do: "harmful"
def humidity_effect_label(_), do: "neutral"
@doc "Returns the default scoring weights map. All 10 values sum to 1.0."
@spec weights() :: map()
def weights, do: @weights

View file

@ -14,7 +14,6 @@ defmodule Microwaveprop.Propagation.CommonVolume do
Distances are spherical-great-circle (haversine) at mean Earth radius.
"""
@earth_radius_km 6371.0
@km_per_deg_lat 111.32
@type latlon :: {float(), float()}
@ -78,20 +77,5 @@ defmodule Microwaveprop.Propagation.CommonVolume do
end
end
@doc "Great-circle distance (km) between two lat/lon points."
@spec haversine_km(latlon(), latlon()) :: float()
def haversine_km({lat1, lon1}, {lat2, lon2}) do
rlat1 = lat1 * :math.pi() / 180.0
rlat2 = lat2 * :math.pi() / 180.0
dlat = (lat2 - lat1) * :math.pi() / 180.0
dlon = (lon2 - lon1) * :math.pi() / 180.0
a =
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
:math.cos(rlat1) * :math.cos(rlat2) *
:math.sin(dlon / 2) * :math.sin(dlon / 2)
c = 2.0 * :math.atan2(:math.sqrt(a), :math.sqrt(1.0 - a))
@earth_radius_km * c
end
defp haversine_km({lat1, lon1}, {lat2, lon2}), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
end

View file

@ -15,8 +15,6 @@ defmodule Microwaveprop.Propagation.RainScatter do
volume, and R1/R2 are distances from each endpoint to the cell.
"""
@earth_radius_km 6371.0
# Minimum reflectivity to consider for scatter (dBZ)
@min_dbz 25.0
@ -132,20 +130,7 @@ defmodule Microwaveprop.Propagation.RainScatter do
baseline + z_term + freq_factor - range_loss + volume_term
end
defp haversine_km(lat1, lon1, lat2, lon2) do
dlat = :math.pi() * (lat2 - lat1) / 180.0
dlon = :math.pi() * (lon2 - lon1) / 180.0
rlat1 = :math.pi() * lat1 / 180.0
rlat2 = :math.pi() * lat2 / 180.0
a =
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
:math.cos(rlat1) * :math.cos(rlat2) *
:math.sin(dlon / 2) * :math.sin(dlon / 2)
c = 2.0 * :math.atan2(:math.sqrt(a), :math.sqrt(1.0 - a))
@earth_radius_km * c
end
defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
defp bearing_deg(lat1, lon1, lat2, lon2) do
rlat1 = :math.pi() * lat1 / 180.0

View file

@ -95,16 +95,14 @@ defmodule Microwaveprop.Propagation.Scorer do
end
def score_humidity(abs_humidity, %{humidity_effect: :harmful, humidity_penalty: penalty}) do
r = abs_humidity * penalty
score_humidity_harmful(abs_humidity * penalty)
end
cond do
r <= 6 -> 100
r <= 9 -> round(95 - (r - 6) / 3 * 20)
r <= 13 -> round(75 - (r - 9) / 4 * 30)
r <= 18 -> round(45 - (r - 13) / 5 * 35)
true -> max(0, round(10 - (r - 18) * 2))
end
end
defp score_humidity_harmful(r) when r <= 6, do: 100
defp score_humidity_harmful(r) when r <= 9, do: round(95 - (r - 6) / 3 * 20)
defp score_humidity_harmful(r) when r <= 13, do: round(75 - (r - 9) / 4 * 30)
defp score_humidity_harmful(r) when r <= 18, do: round(45 - (r - 13) / 5 * 35)
defp score_humidity_harmful(r), do: max(0, round(10 - (r - 18) * 2))
# ── Factor 2: Time of day ─────────────────────────────────────────
@ -150,28 +148,24 @@ defmodule Microwaveprop.Propagation.Scorer do
"""
@spec score_td_depression(number(), number(), map()) :: integer()
def score_td_depression(temp_f, dewpoint_f, %{humidity_effect: :beneficial}) do
dep = temp_f - dewpoint_f
cond do
dep < 3 -> 40
dep < 8 -> 75
dep < 14 -> 85
dep < 22 -> 70
true -> 55
end
score_td_beneficial(temp_f - dewpoint_f)
end
def score_td_depression(temp_f, dewpoint_f, %{humidity_effect: :harmful}) do
dep = temp_f - dewpoint_f
score_td_harmful(temp_f - dewpoint_f)
end
cond do
dep > 22 -> 96
dep > 14 -> 80
dep > 8 -> 60
dep > 4 -> 38
true -> 18
end
end
defp score_td_beneficial(dep) when dep < 3, do: 40
defp score_td_beneficial(dep) when dep < 8, do: 75
defp score_td_beneficial(dep) when dep < 14, do: 85
defp score_td_beneficial(dep) when dep < 22, do: 70
defp score_td_beneficial(_dep), do: 55
defp score_td_harmful(dep) when dep > 22, do: 96
defp score_td_harmful(dep) when dep > 14, do: 80
defp score_td_harmful(dep) when dep > 8, do: 60
defp score_td_harmful(dep) when dep > 4, do: 38
defp score_td_harmful(_dep), do: 18
# ── Factor 4: Refractivity ───────────────────────────────────────
@ -302,16 +296,11 @@ defmodule Microwaveprop.Propagation.Scorer do
@doc "Scores sky cover percentage (0 = clear, 100 = overcast). Nil returns 50."
@spec score_sky(number() | nil) :: integer()
def score_sky(nil), do: 50
def score_sky(pct) do
cond do
pct <= 6 -> 100
pct <= 25 -> 88
pct <= 50 -> 60
pct <= 87 -> 25
true -> 5
end
end
def score_sky(pct) when pct <= 6, do: 100
def score_sky(pct) when pct <= 25, do: 88
def score_sky(pct) when pct <= 50, do: 60
def score_sky(pct) when pct <= 87, do: 25
def score_sky(_pct), do: 5
# ── Factor 6: Season ─────────────────────────────────────────────
@ -345,17 +334,12 @@ defmodule Microwaveprop.Propagation.Scorer do
@doc "Scores wind speed in knots. Nil returns 50."
@spec score_wind(number() | nil) :: integer()
def score_wind(nil), do: 50
def score_wind(speed_kts) do
cond do
speed_kts < 5 -> 100
speed_kts < 10 -> 90
speed_kts < 15 -> 75
speed_kts < 20 -> 55
speed_kts < 25 -> 35
true -> 15
end
end
def score_wind(speed_kts) when speed_kts < 5, do: 100
def score_wind(speed_kts) when speed_kts < 10, do: 90
def score_wind(speed_kts) when speed_kts < 15, do: 75
def score_wind(speed_kts) when speed_kts < 20, do: 55
def score_wind(speed_kts) when speed_kts < 25, do: 35
def score_wind(_speed_kts), do: 15
# ── Factor 8: Rain attenuation ───────────────────────────────────
@ -369,17 +353,15 @@ defmodule Microwaveprop.Propagation.Scorer do
def score_rain(rate, _band_config) when rate == 0, do: 100
def score_rain(rain_rate_mmhr, %{rain_k: k, rain_alpha: alpha}) do
gamma = k * :math.pow(rain_rate_mmhr, alpha)
score_rain_gamma(k * :math.pow(rain_rate_mmhr, alpha))
end
cond do
gamma < 0.1 -> 95
gamma < 0.5 -> 75
gamma < 1.0 -> 50
gamma < 2.0 -> 25
gamma < 5.0 -> 10
true -> 0
end
end
defp score_rain_gamma(gamma) when gamma < 0.1, do: 95
defp score_rain_gamma(gamma) when gamma < 0.5, do: 75
defp score_rain_gamma(gamma) when gamma < 1.0, do: 50
defp score_rain_gamma(gamma) when gamma < 2.0, do: 25
defp score_rain_gamma(gamma) when gamma < 5.0, do: 10
defp score_rain_gamma(_gamma), do: 0
# ── Factor 9: Precipitable water ──────────────────────────────────
@ -392,25 +374,20 @@ defmodule Microwaveprop.Propagation.Scorer do
@spec score_pwat(number() | nil, map()) :: integer()
def score_pwat(nil, _band_config), do: 60
def score_pwat(pwat_mm, %{humidity_effect: :beneficial}) do
cond do
pwat_mm < 10 -> 55
pwat_mm < 20 -> 75
pwat_mm < 30 -> 90
pwat_mm < 40 -> 70
true -> 50
end
end
def score_pwat(pwat_mm, %{humidity_effect: :beneficial}), do: score_pwat_beneficial(pwat_mm)
def score_pwat(pwat_mm, %{humidity_effect: :harmful}), do: score_pwat_harmful(pwat_mm)
def score_pwat(pwat_mm, %{humidity_effect: :harmful}) do
cond do
pwat_mm < 10 -> 95
pwat_mm < 20 -> 80
pwat_mm < 30 -> 60
pwat_mm < 40 -> 35
true -> 15
end
end
defp score_pwat_beneficial(pwat) when pwat < 10, do: 55
defp score_pwat_beneficial(pwat) when pwat < 20, do: 75
defp score_pwat_beneficial(pwat) when pwat < 30, do: 90
defp score_pwat_beneficial(pwat) when pwat < 40, do: 70
defp score_pwat_beneficial(_pwat), do: 50
defp score_pwat_harmful(pwat) when pwat < 10, do: 95
defp score_pwat_harmful(pwat) when pwat < 20, do: 80
defp score_pwat_harmful(pwat) when pwat < 30, do: 60
defp score_pwat_harmful(pwat) when pwat < 40, do: 35
defp score_pwat_harmful(_pwat), do: 15
# ── Factor 10: Pressure trend ────────────────────────────────────
@ -422,29 +399,21 @@ defmodule Microwaveprop.Propagation.Scorer do
"""
@spec score_pressure(number() | nil, number() | nil) :: integer()
def score_pressure(nil, _previous_mb), do: 50
def score_pressure(current_mb, nil), do: score_pressure_absolute(current_mb)
def score_pressure(current_mb, previous_mb), do: score_pressure_delta(current_mb - previous_mb)
def score_pressure(current_mb, nil) do
cond do
current_mb < 980 -> 88
current_mb < 990 -> 82
current_mb < 1000 -> 70
current_mb < 1010 -> 55
current_mb < 1020 -> 40
true -> 30
end
end
defp score_pressure_absolute(mb) when mb < 980, do: 88
defp score_pressure_absolute(mb) when mb < 990, do: 82
defp score_pressure_absolute(mb) when mb < 1000, do: 70
defp score_pressure_absolute(mb) when mb < 1010, do: 55
defp score_pressure_absolute(mb) when mb < 1020, do: 40
defp score_pressure_absolute(_mb), do: 30
def score_pressure(current_mb, previous_mb) do
delta = current_mb - previous_mb
cond do
delta > 2.5 -> 80
delta > 0.8 -> 70
delta > -0.5 -> 60
delta > -2.0 -> 65
true -> 45
end
end
defp score_pressure_delta(delta) when delta > 2.5, do: 80
defp score_pressure_delta(delta) when delta > 0.8, do: 70
defp score_pressure_delta(delta) when delta > -0.5, do: 60
defp score_pressure_delta(delta) when delta > -2.0, do: 65
defp score_pressure_delta(_delta), do: 45
# ── Composite score ──────────────────────────────────────────────
@ -541,26 +510,33 @@ defmodule Microwaveprop.Propagation.Scorer do
build_path_conditions(extracted, contact)
end
@path_fields [
{:temps, :surface_temp_c},
{:dewpoints, :surface_dewpoint_c},
{:pressures, :surface_pressure_mb},
{:gradients, :min_refractivity_gradient},
{:bl_depths, :hpbl_m},
{:pwats, :pwat_mm}
]
defp extract_profile_fields(profiles) do
Enum.reduce(profiles, {[], [], [], [], [], []}, fn p, {t, d, pr, g, b, pw} ->
{
if(is_nil(p.surface_temp_c), do: t, else: [p.surface_temp_c | t]),
if(is_nil(p.surface_dewpoint_c), do: d, else: [p.surface_dewpoint_c | d]),
if(is_nil(p.surface_pressure_mb), do: pr, else: [p.surface_pressure_mb | pr]),
if(is_nil(p.min_refractivity_gradient), do: g, else: [p.min_refractivity_gradient | g]),
if(is_nil(p.hpbl_m), do: b, else: [p.hpbl_m | b]),
if(is_nil(p.pwat_mm), do: pw, else: [p.pwat_mm | pw])
}
empty = Map.new(@path_fields, fn {bucket, _} -> {bucket, []} end)
Enum.reduce(profiles, empty, &accumulate_profile/2)
end
defp accumulate_profile(profile, acc) do
Enum.reduce(@path_fields, acc, fn {bucket, key}, acc ->
maybe_prepend(acc, bucket, Map.get(profile, key))
end)
end
defp build_path_conditions({temps, _dewpoints, _pressures, _gradients, _bl_depths, _pwats}, _contact) when temps == [],
do: nil
defp maybe_prepend(acc, _bucket, nil), do: acc
defp maybe_prepend(acc, bucket, value), do: Map.update!(acc, bucket, &[value | &1])
defp build_path_conditions({_temps, dewpoints, _pressures, _gradients, _bl_depths, _pwats}, _contact)
when dewpoints == [], do: nil
defp build_path_conditions(%{temps: []}, _contact), do: nil
defp build_path_conditions(%{dewpoints: []}, _contact), do: nil
defp build_path_conditions({temps, dewpoints, pressures, gradients, bl_depths, pwats}, contact) do
defp build_path_conditions(%{temps: temps, dewpoints: dewpoints} = buckets, contact) do
lon = contact.pos1["lon"] || -97.0
avg_temp_c = Enum.sum(temps) / length(temps)
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
@ -575,18 +551,15 @@ defmodule Microwaveprop.Propagation.Scorer do
utc_minute: contact.qso_timestamp.minute,
month: contact.qso_timestamp.month,
longitude: lon,
pressure_mb: safe_min(pressures),
pressure_mb: Enum.min(buckets.pressures, fn -> nil end),
prev_pressure_mb: nil,
rain_rate_mmhr: 0.0,
min_refractivity_gradient: safe_min(gradients),
bl_depth_m: safe_avg(bl_depths),
pwat_mm: safe_avg(pwats)
min_refractivity_gradient: Enum.min(buckets.gradients, fn -> nil end),
bl_depth_m: safe_avg(buckets.bl_depths),
pwat_mm: safe_avg(buckets.pwats)
}
end
defp safe_min([]), do: nil
defp safe_min(list), do: Enum.min(list)
defp safe_avg([]), do: nil
defp safe_avg(list), do: Enum.sum(list) / length(list)
end

View file

@ -444,9 +444,7 @@ defmodule Microwaveprop.Propagation.ScoresFile do
end
end
defp clamp(n, lo, _hi) when n < lo, do: lo
defp clamp(n, _lo, hi) when n > hi, do: hi
defp clamp(n, _lo, _hi), do: n
defp clamp(n, lo, hi), do: n |> max(lo) |> min(hi)
defp max_datetime([]), do: nil
defp max_datetime(times), do: Enum.max(times, DateTime)

View file

@ -405,24 +405,8 @@ defmodule Microwaveprop.Radio do
defp normalize_lon(lon) when lon < -180.0, do: lon + 360.0
defp normalize_lon(lon), do: lon
@earth_radius_km 6371.0
@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
defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
@spec backfill_distances([Contact.t()]) :: Postgrex.Result.t() | nil
def backfill_distances(contacts) do

View file

@ -51,7 +51,7 @@ defmodule Microwaveprop.Rover.CandidateDetail do
profile
|> middle_samples()
|> Enum.map(fn pt -> pt.elev + round(max(pt.building_m, pt.canopy_m)) end)
|> safe_max()
|> Enum.max(fn -> nil end)
clearance =
if is_integer(rover_elev) and is_integer(max_obstacle),
@ -124,7 +124,4 @@ defmodule Microwaveprop.Rover.CandidateDetail do
defp middle_samples([]), do: []
defp middle_samples([_]), do: []
defp middle_samples([_ | rest]), do: Enum.drop(rest, -1)
defp safe_max([]), do: nil
defp safe_max(xs), do: Enum.max(xs)
end

View file

@ -67,7 +67,7 @@ defmodule Microwaveprop.Rover.PathTerrain do
|> intermediate_samples(station_pt)
|> Enum.map(&obstacle_top(&1, elev, buildings_lookup, canopy_lookup))
|> Enum.reject(&is_nil/1)
|> safe_max()
|> Enum.max(fn -> nil end)
if path_max, do: rover_elev - path_max
end
@ -101,7 +101,4 @@ defmodule Microwaveprop.Rover.PathTerrain do
{lat1 + f * (lat2 - lat1), lon1 + f * (lon2 - lon1)}
end
end
defp safe_max([]), do: nil
defp safe_max(xs), do: Enum.max(xs)
end

View file

@ -109,20 +109,7 @@ defmodule Microwaveprop.Terrain.ElevationClient do
end
end
defp 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
2 * 6371.0 * :math.asin(:math.sqrt(a))
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
defp req_options do
defaults = [retry: &retry?/2, max_retries: 5, retry_delay: &retry_delay/1]

View file

@ -173,18 +173,5 @@ defmodule Microwaveprop.Terrain.Srtm do
end
end
defp 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
2 * 6371.0 * :math.asin(:math.sqrt(a))
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
end

View file

@ -159,19 +159,13 @@ defmodule Microwaveprop.Weather.WeatherLayers do
ducts
|> Enum.map(fn d -> d["base"] || d[:base_m] || d["base_m"] end)
|> Enum.reject(&is_nil/1)
|> safe_min()
|> Enum.min(fn -> nil end)
end
defp duct_field(ducts, "strength") do
ducts
|> Enum.map(fn d -> d["strength"] || d[:thickness_m] || d["thickness_m"] end)
|> Enum.reject(&is_nil/1)
|> safe_max()
|> Enum.max(fn -> nil end)
end
defp safe_min([]), do: nil
defp safe_min(xs), do: Enum.min(xs)
defp safe_max([]), do: nil
defp safe_max(xs), do: Enum.max(xs)
end

View file

@ -18,11 +18,11 @@ defmodule MicrowavepropWeb.EmeLive do
"""
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
alias Microwaveprop.Moon
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Eme
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
@band_options BandConfig.band_options()
@tick_ms 10_000
@ -187,87 +187,7 @@ defmodule MicrowavepropWeb.EmeLive do
# --- Input parsing --------------------------------------------------
defp resolve_source(""), do: :empty
defp resolve_source(raw) do
input = String.trim(raw)
cond do
input == "" ->
:empty
coordinate_pair?(input) ->
[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
}}
maidenhead?(input) ->
case Maidenhead.to_latlon(input) do
{:ok, {lat, lon}} ->
{:ok, %{lat: lat, lon: lon, label: String.upcase(input), kind: :grid}}
:error ->
{:error, "Invalid Maidenhead grid: #{input}"}
end
true ->
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
defp 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
defp maidenhead?(input) do
String.length(input) >= 4 and String.length(input) <= 10 and
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
end
defp parse_int(s, default) do
case Integer.parse(to_string(s)) do
{n, _} -> n
:error -> default
end
end
defp parse_float(nil, default), do: default
defp parse_float("", default), do: default
defp parse_float(s, default) do
case Float.parse(to_string(s)) do
{n, _} -> n
:error -> default
end
end
defp resolve_source(input), do: MicrowavepropWeb.LocationResolver.resolve(input)
# --- Render helpers -------------------------------------------------
#

View file

@ -784,7 +784,7 @@ defmodule MicrowavepropWeb.MapLive do
typical_range_km: config.typical_range_km,
extended_range_km: config.extended_range_km,
exceptional_range_km: config.exceptional_range_km,
humidity_effect: to_string(config.humidity_effect),
humidity_effect: BandConfig.humidity_effect_label(config),
freq_mhz: config.freq_mhz
}
end

View file

@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.PathLive do
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.Components.SkewTChart
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2]
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
@ -13,8 +14,6 @@ defmodule MicrowavepropWeb.PathLive do
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
@ -203,7 +202,7 @@ defmodule MicrowavepropWeb.PathLive do
score: score,
factors: factors,
band_label: band_config.label,
humidity_effect: humidity_effect_string(band_config)
humidity_effect: BandConfig.humidity_effect_label(band_config)
}
_ ->
@ -428,54 +427,11 @@ defmodule MicrowavepropWeb.PathLive do
defp build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil
defp resolve_location(input) do
input = String.trim(input)
cond do
input == "" ->
{:error, "Location is required"}
coordinate_pair?(input) ->
[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)}", type: :coordinates}}
maidenhead?(input) ->
case Maidenhead.to_latlon(input) do
{:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, label: String.upcase(input), type: :grid}}
:error -> {:error, "Invalid grid square: #{input}"}
end
true ->
case CallsignClient.locate(input) do
{:ok, info} ->
{:ok,
%{lat: info.lat, lon: info.lon, label: String.upcase(info.callsign), grid: info.gridsquare, type: :callsign}}
{:error, reason} ->
{:error, "Could not find #{input}: #{reason}"}
case MicrowavepropWeb.LocationResolver.resolve(input) do
:empty -> {:error, "Location is required"}
other -> other
end
end
end
defp 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
defp maidenhead?(input) do
String.length(input) >= 4 and String.length(input) <= 10 and
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
end
defp humidity_effect_string(%{humidity_effect: :beneficial}), do: "beneficial"
defp humidity_effect_string(%{humidity_effect: :harmful}), do: "harmful"
defp humidity_effect_string(_), do: "neutral"
# The grid is the single decoded output of ProfilesFile.read/1 — keys are
# `{snapped_lat, snapped_lon}` tuples produced by ProfilesFile.snap/2.
@ -719,16 +675,6 @@ defmodule MicrowavepropWeb.PathLive do
}
end
defp parse_float(nil, default), do: default
defp parse_float("", default), do: default
defp parse_float(str, default) do
case Float.parse(str) do
{val, _} -> val
:error -> default
end
end
defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
defdelegate bearing_deg(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo

View file

@ -8,6 +8,8 @@ defmodule MicrowavepropWeb.RoverLive do
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [parse_int: 2]
alias Microwaveprop.Accounts.User
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Geocoder
@ -900,13 +902,6 @@ defmodule MicrowavepropWeb.RoverLive do
# ── Helpers ─────────────────────────────────────────────────────────
defp parse_int(v, default) do
case Integer.parse("#{v}") do
{n, _} -> n
:error -> default
end
end
defp clamp(v, lo, hi), do: v |> max(lo) |> min(hi)
defp band_label(band) do

View file

@ -0,0 +1,37 @@
defmodule MicrowavepropWeb.LiveHelpers do
@moduledoc """
Small helpers shared across LiveView modules. Use sparingly most
view-specific logic belongs in its own LiveView.
"""
@doc """
Parses a value into a float, returning `default` for nil, empty, or
unparseable input. Accepts strings, numbers, or any value with a
`String.Chars` impl.
"""
@spec parse_float(term(), float()) :: float()
def parse_float(nil, default), do: default
def parse_float("", default), do: default
def parse_float(value, default) do
case value |> to_string() |> Float.parse() do
{n, _} -> n
:error -> default
end
end
@doc """
Parses a value into an integer, returning `default` for nil, empty,
or unparseable input.
"""
@spec parse_int(term(), integer()) :: integer()
def parse_int(nil, default), do: default
def parse_int("", default), do: default
def parse_int(value, default) do
case value |> to_string() |> Integer.parse() do
{n, _} -> n
:error -> default
end
end
end

View file

@ -0,0 +1,103 @@
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

View file

@ -75,7 +75,7 @@ defmodule MicrowavepropWeb.EmeLiveTest do
# Maidenhead.to_latlon's strict validator (subsquare "ZZ" isn't A-X).
{:ok, _view, html} = live(conn, ~p"/eme?source=EM12ZZ")
assert html =~ "Invalid Maidenhead grid"
assert html =~ "Invalid grid square"
refute html =~ "Antenna aim"
end
@ -170,7 +170,7 @@ defmodule MicrowavepropWeb.EmeLiveTest do
# Either the loose maidenhead regex catches it (invalid grid
# error) or it falls through to callsign lookup (not found).
assert invalid_html =~ "Invalid Maidenhead grid" or
assert invalid_html =~ "Invalid grid square" or
invalid_html =~ "Could not find",
"""
Expected an invalid input to produce a validation error.

View file

@ -0,0 +1,49 @@
defmodule MicrowavepropWeb.LiveHelpersTest do
use ExUnit.Case, async: true
alias MicrowavepropWeb.LiveHelpers
describe "parse_float/2" do
test "returns default for nil and empty string" do
assert LiveHelpers.parse_float(nil, 1.5) == 1.5
assert LiveHelpers.parse_float("", 1.5) == 1.5
end
test "parses well-formed float strings" do
assert LiveHelpers.parse_float("3.14", 0.0) == 3.14
assert LiveHelpers.parse_float("-2.5", 0.0) == -2.5
end
test "parses integer strings to a float" do
assert LiveHelpers.parse_float("42", 0.0) == 42.0
end
test "returns default on garbage input" do
assert LiveHelpers.parse_float("nope", 7.0) == 7.0
end
test "accepts numbers via to_string conversion" do
assert LiveHelpers.parse_float(2.5, 0.0) == 2.5
end
end
describe "parse_int/2" do
test "returns default for nil and empty string" do
assert LiveHelpers.parse_int(nil, 5) == 5
assert LiveHelpers.parse_int("", 5) == 5
end
test "parses integer strings" do
assert LiveHelpers.parse_int("42", 0) == 42
assert LiveHelpers.parse_int("-7", 0) == -7
end
test "returns default on garbage input" do
assert LiveHelpers.parse_int("nope", 9) == 9
end
test "accepts integers via to_string conversion" do
assert LiveHelpers.parse_int(42, 0) == 42
end
end
end

View file

@ -0,0 +1,67 @@
defmodule MicrowavepropWeb.LocationResolverTest do
use ExUnit.Case, async: true
alias MicrowavepropWeb.LocationResolver
describe "resolve/1" do
test "returns :empty for nil and blank input" do
assert LocationResolver.resolve(nil) == :empty
assert LocationResolver.resolve("") == :empty
assert LocationResolver.resolve(" ") == :empty
end
test "parses comma-separated decimal coordinates" do
assert {:ok, resolved} = LocationResolver.resolve("32.9, -96.8")
assert resolved.kind == :coordinates
assert resolved.lat == 32.9
assert resolved.lon == -96.8
assert resolved.label == "32.9, -96.8"
end
test "tolerates extra whitespace around coordinates" do
assert {:ok, %{lat: 32.9, lon: -96.8}} = LocationResolver.resolve(" 32.9 , -96.8 ")
end
test "parses Maidenhead grid squares (uppercase)" do
assert {:ok, resolved} = LocationResolver.resolve("EM12kx")
assert resolved.kind == :grid
assert resolved.label == "EM12KX"
assert is_float(resolved.lat)
assert is_float(resolved.lon)
end
test "uppercases callsign-shaped grid squares for the label" do
assert {:ok, %{label: "FN42"}} = LocationResolver.resolve("fn42")
end
end
describe "coordinate_pair?/1" do
test "true for two comma-separated floats" do
assert LocationResolver.coordinate_pair?("32.9, -96.8")
assert LocationResolver.coordinate_pair?("0,0")
assert LocationResolver.coordinate_pair?("-89.9999, 179.9999")
end
test "false for malformed input" do
refute LocationResolver.coordinate_pair?("EM12kx")
refute LocationResolver.coordinate_pair?("32.9")
refute LocationResolver.coordinate_pair?("a, b")
refute LocationResolver.coordinate_pair?("")
end
end
describe "maidenhead?/1" do
test "true for valid grid squares" do
assert LocationResolver.maidenhead?("EM12")
assert LocationResolver.maidenhead?("EM12kx")
assert LocationResolver.maidenhead?("em12kx99")
end
test "false for non-grid input" do
refute LocationResolver.maidenhead?("32.9, -96.8")
refute LocationResolver.maidenhead?("W5XYZ")
refute LocationResolver.maidenhead?("EM")
refute LocationResolver.maidenhead?("")
end
end
end