feat(rover): pure scoring math (link margin, aggregator, drive time)

This commit is contained in:
Graham McIntire 2026-04-25 16:15:22 -05:00
parent 18f04a4345
commit 25d07c0d42
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 209 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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