feat(rover): pure scoring math (link margin, aggregator, drive time)
This commit is contained in:
parent
18f04a4345
commit
25d07c0d42
6 changed files with 209 additions and 0 deletions
20
lib/microwaveprop/rover/aggregator.ex
Normal file
20
lib/microwaveprop/rover/aggregator.ex
Normal 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
|
||||
53
lib/microwaveprop/rover/drive_time.ex
Normal file
53
lib/microwaveprop/rover/drive_time.ex
Normal 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
|
||||
45
lib/microwaveprop/rover/link_margin.ex
Normal file
45
lib/microwaveprop/rover/link_margin.ex
Normal 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
|
||||
23
test/microwaveprop/rover/aggregator_test.exs
Normal file
23
test/microwaveprop/rover/aggregator_test.exs
Normal 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
|
||||
31
test/microwaveprop/rover/drive_time_test.exs
Normal file
31
test/microwaveprop/rover/drive_time_test.exs
Normal 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
|
||||
37
test/microwaveprop/rover/link_margin_test.exs
Normal file
37
test/microwaveprop/rover/link_margin_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue