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:
parent
a6b89961f6
commit
2defa3db7a
22 changed files with 389 additions and 386 deletions
|
|
@ -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)."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 -------------------------------------------------
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
37
lib/microwaveprop_web/live_helpers.ex
Normal file
37
lib/microwaveprop_web/live_helpers.ex
Normal 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
|
||||
103
lib/microwaveprop_web/location_resolver.ex
Normal file
103
lib/microwaveprop_web/location_resolver.ex
Normal 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 (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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
49
test/microwaveprop_web/live_helpers_test.exs
Normal file
49
test/microwaveprop_web/live_helpers_test.exs
Normal 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
|
||||
67
test/microwaveprop_web/location_resolver_test.exs
Normal file
67
test/microwaveprop_web/location_resolver_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue