diff --git a/lib/microwaveprop/geo.ex b/lib/microwaveprop/geo.ex index b153fbf6..df20d854 100644 --- a/lib/microwaveprop/geo.ex +++ b/lib/microwaveprop/geo.ex @@ -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, √(1−a))` 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)." diff --git a/lib/microwaveprop/ionosphere.ex b/lib/microwaveprop/ionosphere.ex index 6bc8dedd..9939a406 100644 --- a/lib/microwaveprop/ionosphere.ex +++ b/lib/microwaveprop/ionosphere.ex @@ -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 diff --git a/lib/microwaveprop/propagation/band_config.ex b/lib/microwaveprop/propagation/band_config.ex index b74204c7..545d21ee 100644 --- a/lib/microwaveprop/propagation/band_config.ex +++ b/lib/microwaveprop/propagation/band_config.ex @@ -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 diff --git a/lib/microwaveprop/propagation/common_volume.ex b/lib/microwaveprop/propagation/common_volume.ex index 006a6d37..599e6336 100644 --- a/lib/microwaveprop/propagation/common_volume.ex +++ b/lib/microwaveprop/propagation/common_volume.ex @@ -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 diff --git a/lib/microwaveprop/propagation/rain_scatter.ex b/lib/microwaveprop/propagation/rain_scatter.ex index 9d31f836..bb4c1195 100644 --- a/lib/microwaveprop/propagation/rain_scatter.ex +++ b/lib/microwaveprop/propagation/rain_scatter.ex @@ -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 diff --git a/lib/microwaveprop/propagation/scorer.ex b/lib/microwaveprop/propagation/scorer.ex index ed4dda0c..2979132f 100644 --- a/lib/microwaveprop/propagation/scorer.ex +++ b/lib/microwaveprop/propagation/scorer.ex @@ -95,17 +95,15 @@ defmodule Microwaveprop.Propagation.Scorer do end def score_humidity(abs_humidity, %{humidity_effect: :harmful, humidity_penalty: penalty}) do - r = 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 + score_humidity_harmful(abs_humidity * penalty) 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 ───────────────────────────────────────── @doc """ @@ -150,29 +148,25 @@ 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 - - cond do - dep > 22 -> 96 - dep > 14 -> 80 - dep > 8 -> 60 - dep > 4 -> 38 - true -> 18 - end + score_td_harmful(temp_f - dewpoint_f) 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 ─────────────────────────────────────── @doc """ @@ -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,18 +353,16 @@ 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) - - 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 + score_rain_gamma(k * :math.pow(rain_rate_mmhr, alpha)) 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 ────────────────────────────────── @doc """ @@ -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 diff --git a/lib/microwaveprop/propagation/scores_file.ex b/lib/microwaveprop/propagation/scores_file.ex index 083735b8..9d5062df 100644 --- a/lib/microwaveprop/propagation/scores_file.ex +++ b/lib/microwaveprop/propagation/scores_file.ex @@ -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) diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index f49fba75..01197a51 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -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, √(1−a))` 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 diff --git a/lib/microwaveprop/rover/candidate_detail.ex b/lib/microwaveprop/rover/candidate_detail.ex index b51c62b4..b7f66a8a 100644 --- a/lib/microwaveprop/rover/candidate_detail.ex +++ b/lib/microwaveprop/rover/candidate_detail.ex @@ -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 diff --git a/lib/microwaveprop/rover/path_terrain.ex b/lib/microwaveprop/rover/path_terrain.ex index 9a618261..50e6df77 100644 --- a/lib/microwaveprop/rover/path_terrain.ex +++ b/lib/microwaveprop/rover/path_terrain.ex @@ -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 diff --git a/lib/microwaveprop/terrain/elevation_client.ex b/lib/microwaveprop/terrain/elevation_client.ex index 018cd58a..04b27996 100644 --- a/lib/microwaveprop/terrain/elevation_client.ex +++ b/lib/microwaveprop/terrain/elevation_client.ex @@ -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] diff --git a/lib/microwaveprop/terrain/srtm.ex b/lib/microwaveprop/terrain/srtm.ex index fc4fd518..7c696c07 100644 --- a/lib/microwaveprop/terrain/srtm.ex +++ b/lib/microwaveprop/terrain/srtm.ex @@ -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 diff --git a/lib/microwaveprop/weather/weather_layers.ex b/lib/microwaveprop/weather/weather_layers.ex index b9628cba..c224d2e8 100644 --- a/lib/microwaveprop/weather/weather_layers.ex +++ b/lib/microwaveprop/weather/weather_layers.ex @@ -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 diff --git a/lib/microwaveprop_web/live/eme_live.ex b/lib/microwaveprop_web/live/eme_live.ex index f9052d0c..66e284f3 100644 --- a/lib/microwaveprop_web/live/eme_live.ex +++ b/lib/microwaveprop_web/live/eme_live.ex @@ -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 ------------------------------------------------- # diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 579bbd49..da5704df 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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 diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index 30b52c46..f03ca3a5 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -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,55 +427,12 @@ 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}"} - end + case MicrowavepropWeb.LocationResolver.resolve(input) do + :empty -> {:error, "Location is required"} + other -> other 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. # We mirror the same snapping locally instead of round-tripping through @@ -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 diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index 01eb28dd..2a05a27e 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -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 diff --git a/lib/microwaveprop_web/live_helpers.ex b/lib/microwaveprop_web/live_helpers.ex new file mode 100644 index 00000000..72e00c8b --- /dev/null +++ b/lib/microwaveprop_web/live_helpers.ex @@ -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 diff --git a/lib/microwaveprop_web/location_resolver.ex b/lib/microwaveprop_web/location_resolver.ex new file mode 100644 index 00000000..15e049e6 --- /dev/null +++ b/lib/microwaveprop_web/location_resolver.ex @@ -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 (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 diff --git a/test/microwaveprop_web/live/eme_live_test.exs b/test/microwaveprop_web/live/eme_live_test.exs index d7eedaf0..5840eb63 100644 --- a/test/microwaveprop_web/live/eme_live_test.exs +++ b/test/microwaveprop_web/live/eme_live_test.exs @@ -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. diff --git a/test/microwaveprop_web/live_helpers_test.exs b/test/microwaveprop_web/live_helpers_test.exs new file mode 100644 index 00000000..dc6447d7 --- /dev/null +++ b/test/microwaveprop_web/live_helpers_test.exs @@ -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 diff --git a/test/microwaveprop_web/location_resolver_test.exs b/test/microwaveprop_web/location_resolver_test.exs new file mode 100644 index 00000000..aa51087e --- /dev/null +++ b/test/microwaveprop_web/location_resolver_test.exs @@ -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