From 25d07c0d4219eaa67e6ca85bf327b06ee8f60edc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 16:15:22 -0500 Subject: [PATCH] feat(rover): pure scoring math (link margin, aggregator, drive time) --- lib/microwaveprop/rover/aggregator.ex | 20 +++++++ lib/microwaveprop/rover/drive_time.ex | 53 +++++++++++++++++++ lib/microwaveprop/rover/link_margin.ex | 45 ++++++++++++++++ test/microwaveprop/rover/aggregator_test.exs | 23 ++++++++ test/microwaveprop/rover/drive_time_test.exs | 31 +++++++++++ test/microwaveprop/rover/link_margin_test.exs | 37 +++++++++++++ 6 files changed, 209 insertions(+) create mode 100644 lib/microwaveprop/rover/aggregator.ex create mode 100644 lib/microwaveprop/rover/drive_time.ex create mode 100644 lib/microwaveprop/rover/link_margin.ex create mode 100644 test/microwaveprop/rover/aggregator_test.exs create mode 100644 test/microwaveprop/rover/drive_time_test.exs create mode 100644 test/microwaveprop/rover/link_margin_test.exs diff --git a/lib/microwaveprop/rover/aggregator.ex b/lib/microwaveprop/rover/aggregator.ex new file mode 100644 index 00000000..ab8e0e79 --- /dev/null +++ b/lib/microwaveprop/rover/aggregator.ex @@ -0,0 +1,20 @@ +defmodule Microwaveprop.Rover.Aggregator do + @moduledoc """ + Combine per-station link margins into a single per-cell quality + number using the rover formula `0.7·max + 0.3·mean`. Nil entries + are dropped before aggregation; an empty (or fully-nil) list + returns nil. + """ + + @spec cell_margin_db([float() | nil]) :: float() | nil + def cell_margin_db([]), do: nil + + def cell_margin_db(margins) when is_list(margins) do + valid = Enum.reject(margins, &is_nil/1) + + case valid do + [] -> nil + values -> 0.7 * Enum.max(values) + 0.3 * (Enum.sum(values) / length(values)) + end + end +end diff --git a/lib/microwaveprop/rover/drive_time.ex b/lib/microwaveprop/rover/drive_time.ex new file mode 100644 index 00000000..7478b3c0 --- /dev/null +++ b/lib/microwaveprop/rover/drive_time.ex @@ -0,0 +1,53 @@ +defmodule Microwaveprop.Rover.DriveTime do + @moduledoc """ + Distance, drive-time, and compass-bearing helpers for the rover + planner. All inputs are degrees / kilometres; no I/O. + """ + + @earth_radius_km 6371.0 + @avg_speed_kmh 65.0 + + @spec haversine_km({float(), float()}, {float(), float()}) :: float() + def haversine_km({lat1, lon1}, {lat2, lon2}) do + rlat1 = deg_to_rad(lat1) + rlat2 = deg_to_rad(lat2) + dlat = deg_to_rad(lat2 - lat1) + dlon = deg_to_rad(lon2 - lon1) + + a = + :math.sin(dlat / 2) ** 2 + + :math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2 + + 2 * @earth_radius_km * :math.asin(:math.sqrt(a)) + end + + @spec drive_min(float()) :: float() + def drive_min(dist_km) when is_number(dist_km), do: dist_km / @avg_speed_kmh * 60.0 + + @spec bearing_compass({float(), float()}, {float(), float()}) :: String.t() + def bearing_compass({lat1, lon1}, {lat2, lon2}) do + rlat1 = deg_to_rad(lat1) + rlat2 = deg_to_rad(lat2) + dlon = deg_to_rad(lon2 - lon1) + + y = :math.sin(dlon) * :math.cos(rlat2) + x = :math.cos(rlat1) * :math.sin(rlat2) - :math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon) + + bearing = y |> :math.atan2(x) |> rad_to_deg() |> normalize_deg() + + compass_for(bearing) + end + + @compass_points {"N", "NE", "E", "SE", "S", "SW", "W", "NW"} + + defp compass_for(bearing) do + index = bearing |> Kernel.+(22.5) |> div_floor(45.0) |> rem(8) + elem(@compass_points, index) + end + + defp div_floor(a, b), do: trunc(a / b) + + defp deg_to_rad(deg), do: deg * :math.pi() / 180.0 + defp rad_to_deg(rad), do: rad * 180.0 / :math.pi() + defp normalize_deg(deg), do: deg |> Kernel.+(360.0) |> :math.fmod(360.0) +end diff --git a/lib/microwaveprop/rover/link_margin.ex b/lib/microwaveprop/rover/link_margin.ex new file mode 100644 index 00000000..38dfface --- /dev/null +++ b/lib/microwaveprop/rover/link_margin.ex @@ -0,0 +1,45 @@ +defmodule Microwaveprop.Rover.LinkMargin do + @moduledoc """ + Per-link SNR-margin math for the rover planner. + + Maps a propagation grid score (0-100) into a predicted SNR using the + linear calibration `(score − 50) × 0.5`, then subtracts the mode's + required SNR threshold to yield "dB above decode". + """ + + alias Microwaveprop.Propagation.ModeThresholds + alias Microwaveprop.Propagation.ScoresFile + + @spec score_to_db(integer() | nil) :: float() | nil + def score_to_db(nil), do: nil + def score_to_db(score) when is_integer(score), do: (score - 50) * 0.5 + + @doc """ + Pure helper: compute the link margin from an already-resolved + grid score and a mode atom. Used by tests and `Rover.Compute`, + which fetches the score itself. + """ + @spec link_margin_from_score(integer() | nil, ModeThresholds.mode()) :: float() | nil + def link_margin_from_score(nil, _mode), do: nil + + def link_margin_from_score(score, mode) when is_integer(score) do + score_to_db(score) - ModeThresholds.required_snr_db(mode) + end + + @doc """ + Look up the score for the candidate cell and return the link margin. + """ + @spec link_margin_db( + non_neg_integer(), + DateTime.t(), + {float(), float()}, + {float(), float()}, + ModeThresholds.mode() + ) :: float() | nil + def link_margin_db(band_mhz, %DateTime{} = valid_time, {c_lat, c_lon}, {_s_lat, _s_lon}, mode) do + case ScoresFile.read_point(band_mhz, valid_time, c_lat, c_lon) do + nil -> nil + score -> link_margin_from_score(score, mode) + end + end +end diff --git a/test/microwaveprop/rover/aggregator_test.exs b/test/microwaveprop/rover/aggregator_test.exs new file mode 100644 index 00000000..5d16d626 --- /dev/null +++ b/test/microwaveprop/rover/aggregator_test.exs @@ -0,0 +1,23 @@ +defmodule Microwaveprop.Rover.AggregatorTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Rover.Aggregator + + describe "cell_margin_db/1" do + test "empty list returns nil" do + assert Aggregator.cell_margin_db([]) == nil + end + + test "[10.0, 5.0, -2.0] returns 0.7*10 + 0.3*(13/3) ≈ 8.30" do + assert_in_delta Aggregator.cell_margin_db([10.0, 5.0, -2.0]), 8.30, 0.01 + end + + test "[nil, 5.0] ignores nil and returns 5.0" do + assert Aggregator.cell_margin_db([nil, 5.0]) == 5.0 + end + + test "all nils returns nil" do + assert Aggregator.cell_margin_db([nil, nil]) == nil + end + end +end diff --git a/test/microwaveprop/rover/drive_time_test.exs b/test/microwaveprop/rover/drive_time_test.exs new file mode 100644 index 00000000..2680ff14 --- /dev/null +++ b/test/microwaveprop/rover/drive_time_test.exs @@ -0,0 +1,31 @@ +defmodule Microwaveprop.Rover.DriveTimeTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Rover.DriveTime + + describe "haversine_km/2" do + test "DFW area to Austin ≈ 305 km" do + assert_in_delta DriveTime.haversine_km({32.9, -97.0}, {30.27, -97.74}), 305.0, 5.0 + end + end + + describe "drive_min/1" do + test "200 km at 65 km/h ≈ 184.6 min" do + assert_in_delta DriveTime.drive_min(200.0), 184.6, 0.5 + end + end + + describe "bearing_compass/2" do + test "due north" do + assert DriveTime.bearing_compass({33.0, -96.0}, {34.0, -96.0}) == "N" + end + + test "due east" do + assert DriveTime.bearing_compass({33.0, -96.0}, {33.0, -95.0}) == "E" + end + + test "north-east" do + assert DriveTime.bearing_compass({33.0, -96.0}, {33.5, -95.5}) == "NE" + end + end +end diff --git a/test/microwaveprop/rover/link_margin_test.exs b/test/microwaveprop/rover/link_margin_test.exs new file mode 100644 index 00000000..cbd8e401 --- /dev/null +++ b/test/microwaveprop/rover/link_margin_test.exs @@ -0,0 +1,37 @@ +defmodule Microwaveprop.Rover.LinkMarginTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Rover.LinkMargin + + describe "score_to_db/1" do + test "0 maps to -25.0 dB" do + assert LinkMargin.score_to_db(0) == -25.0 + end + + test "50 maps to 0.0 dB" do + assert LinkMargin.score_to_db(50) == 0.0 + end + + test "100 maps to 25.0 dB" do + assert LinkMargin.score_to_db(100) == 25.0 + end + + test "nil maps to nil" do + assert LinkMargin.score_to_db(nil) == nil + end + end + + describe "link_margin_from_score/2" do + test "score 80 in :ssb mode = 15.0 dB" do + assert LinkMargin.link_margin_from_score(80, :ssb) == 15.0 + end + + test "score 50 in :cw mode = 18.0 dB (0 - (-18))" do + assert LinkMargin.link_margin_from_score(50, :cw) == 18.0 + end + + test "nil score returns nil" do + assert LinkMargin.link_margin_from_score(nil, :ssb) == nil + end + end +end