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 def haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1) dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1) dlon = deg_to_rad(lon2 - lon1)
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
a = a =
:math.sin(dlat / 2) ** 2 + :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 end
@doc "Initial bearing from point 1 to point 2 in degrees (0-360)." @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) Enum.min_by(@stations, fn s -> haversine_km(lat, lon, s.lat, s.lon) end)
end end
# Standard haversine distance in km. Good enough for station-pick defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
# 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
end 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) Enum.map(all_bands(), fn band -> {band.label, to_string(band.freq_mhz)} end)
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." @doc "Returns the default scoring weights map. All 10 values sum to 1.0."
@spec weights() :: map() @spec weights() :: map()
def weights, do: @weights 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. Distances are spherical-great-circle (haversine) at mean Earth radius.
""" """
@earth_radius_km 6371.0
@km_per_deg_lat 111.32 @km_per_deg_lat 111.32
@type latlon :: {float(), float()} @type latlon :: {float(), float()}
@ -78,20 +77,5 @@ defmodule Microwaveprop.Propagation.CommonVolume do
end end
end end
@doc "Great-circle distance (km) between two lat/lon points." defp haversine_km({lat1, lon1}, {lat2, lon2}), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
@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
end end

View file

@ -15,8 +15,6 @@ defmodule Microwaveprop.Propagation.RainScatter do
volume, and R1/R2 are distances from each endpoint to the cell. volume, and R1/R2 are distances from each endpoint to the cell.
""" """
@earth_radius_km 6371.0
# Minimum reflectivity to consider for scatter (dBZ) # Minimum reflectivity to consider for scatter (dBZ)
@min_dbz 25.0 @min_dbz 25.0
@ -132,20 +130,7 @@ defmodule Microwaveprop.Propagation.RainScatter do
baseline + z_term + freq_factor - range_loss + volume_term baseline + z_term + freq_factor - range_loss + volume_term
end end
defp haversine_km(lat1, lon1, lat2, lon2) do defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
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 bearing_deg(lat1, lon1, lat2, lon2) do defp bearing_deg(lat1, lon1, lat2, lon2) do
rlat1 = :math.pi() * lat1 / 180.0 rlat1 = :math.pi() * lat1 / 180.0

View file

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

View file

@ -444,9 +444,7 @@ defmodule Microwaveprop.Propagation.ScoresFile do
end end
end end
defp clamp(n, lo, _hi) when n < lo, do: lo defp clamp(n, lo, hi), do: n |> max(lo) |> min(hi)
defp clamp(n, _lo, hi) when n > hi, do: hi
defp clamp(n, _lo, _hi), do: n
defp max_datetime([]), do: nil defp max_datetime([]), do: nil
defp max_datetime(times), do: Enum.max(times, DateTime) 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) when lon < -180.0, do: lon + 360.0
defp normalize_lon(lon), do: lon defp normalize_lon(lon), do: lon
@earth_radius_km 6371.0
@spec haversine_km(number(), number(), number(), number()) :: float() @spec haversine_km(number(), number(), number(), number()) :: float()
def haversine_km(lat1, lon1, lat2, lon2) do defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
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
@spec backfill_distances([Contact.t()]) :: Postgrex.Result.t() | nil @spec backfill_distances([Contact.t()]) :: Postgrex.Result.t() | nil
def backfill_distances(contacts) do def backfill_distances(contacts) do

View file

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

View file

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

View file

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

View file

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

View file

@ -18,11 +18,11 @@ defmodule MicrowavepropWeb.EmeLive do
""" """
use MicrowavepropWeb, :live_view use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
alias Microwaveprop.Moon alias Microwaveprop.Moon
alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Eme alias Microwaveprop.Propagation.Eme
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
@band_options BandConfig.band_options() @band_options BandConfig.band_options()
@tick_ms 10_000 @tick_ms 10_000
@ -187,87 +187,7 @@ defmodule MicrowavepropWeb.EmeLive do
# --- Input parsing -------------------------------------------------- # --- Input parsing --------------------------------------------------
defp resolve_source(""), do: :empty defp resolve_source(input), do: MicrowavepropWeb.LocationResolver.resolve(input)
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
# --- Render helpers ------------------------------------------------- # --- Render helpers -------------------------------------------------
# #

View file

@ -784,7 +784,7 @@ defmodule MicrowavepropWeb.MapLive do
typical_range_km: config.typical_range_km, typical_range_km: config.typical_range_km,
extended_range_km: config.extended_range_km, extended_range_km: config.extended_range_km,
exceptional_range_km: config.exceptional_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 freq_mhz: config.freq_mhz
} }
end end

View file

@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.PathLive do
use MicrowavepropWeb, :live_view use MicrowavepropWeb, :live_view
import MicrowavepropWeb.Components.SkewTChart import MicrowavepropWeb.Components.SkewTChart
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2]
alias Microwaveprop.Buildings.Index, as: BuildingsIndex alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
@ -13,8 +14,6 @@ defmodule MicrowavepropWeb.PathLive do
alias Microwaveprop.Propagation.ProfilesFile alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.SporadicE alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis alias Microwaveprop.Terrain.TerrainAnalysis
@ -203,7 +202,7 @@ defmodule MicrowavepropWeb.PathLive do
score: score, score: score,
factors: factors, factors: factors,
band_label: band_config.label, band_label: band_config.label,
humidity_effect: humidity_effect_string(band_config) humidity_effect: BandConfig.humidity_effect_label(band_config)
} }
_ -> _ ->
@ -428,55 +427,12 @@ defmodule MicrowavepropWeb.PathLive do
defp build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil defp build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil
defp resolve_location(input) do defp resolve_location(input) do
input = String.trim(input) case MicrowavepropWeb.LocationResolver.resolve(input) do
:empty -> {:error, "Location is required"}
cond do other -> other
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}"}
end
end 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 # The grid is the single decoded output of ProfilesFile.read/1 — keys are
# `{snapped_lat, snapped_lon}` tuples produced by ProfilesFile.snap/2. # `{snapped_lat, snapped_lon}` tuples produced by ProfilesFile.snap/2.
# We mirror the same snapping locally instead of round-tripping through # We mirror the same snapping locally instead of round-tripping through
@ -719,16 +675,6 @@ defmodule MicrowavepropWeb.PathLive do
} }
end 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 haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
defdelegate bearing_deg(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 use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [parse_int: 2]
alias Microwaveprop.Accounts.User alias Microwaveprop.Accounts.User
alias Microwaveprop.Buildings.Index, as: BuildingsIndex alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Geocoder alias Microwaveprop.Geocoder
@ -900,13 +902,6 @@ defmodule MicrowavepropWeb.RoverLive do
# ── Helpers ───────────────────────────────────────────────────────── # ── 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 clamp(v, lo, hi), do: v |> max(lo) |> min(hi)
defp band_label(band) do 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). # Maidenhead.to_latlon's strict validator (subsquare "ZZ" isn't A-X).
{:ok, _view, html} = live(conn, ~p"/eme?source=EM12ZZ") {:ok, _view, html} = live(conn, ~p"/eme?source=EM12ZZ")
assert html =~ "Invalid Maidenhead grid" assert html =~ "Invalid grid square"
refute html =~ "Antenna aim" refute html =~ "Antenna aim"
end end
@ -170,7 +170,7 @@ defmodule MicrowavepropWeb.EmeLiveTest do
# Either the loose maidenhead regex catches it (invalid grid # Either the loose maidenhead regex catches it (invalid grid
# error) or it falls through to callsign lookup (not found). # 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", invalid_html =~ "Could not find",
""" """
Expected an invalid input to produce a validation error. 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