Adds a per-cell building-clutter penalty so the algorithm down-ranks spots in built environments where buildings on multiple sides scatter/block signal regardless of which station you're aiming at. Penalty = max_height_within_75m / 5 dB, capped at 6 dB. Path-clearance already accounts for buildings ON the link path; this is the "surrounded by stuff" signal.
165 lines
5.1 KiB
Elixir
165 lines
5.1 KiB
Elixir
defmodule Microwaveprop.Rover.ComputeTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Rover.Compute
|
|
|
|
defp build_grid(home_lat, home_lon) do
|
|
# 3x3 grid of cells around home spaced 0.1 deg apart
|
|
for dlat <- [-0.1, 0.0, 0.1], dlon <- [-0.1, 0.0, 0.1] do
|
|
%{
|
|
lat: Float.round(home_lat + dlat, 4),
|
|
lon: Float.round(home_lon + dlon, 4),
|
|
score: 80
|
|
}
|
|
end
|
|
end
|
|
|
|
test "returns top_candidates ordered by score desc with required fields" do
|
|
home = %{lat: 33.0, lon: -96.0, elev_m: 200}
|
|
stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}]
|
|
grid = build_grid(home.lat, home.lon)
|
|
|
|
scores_at = fn _band, _t, _bbox -> grid end
|
|
elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end
|
|
clearance_lookup = fn _cells, _stations -> %{} end
|
|
prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end
|
|
road_lookup = fn _cells, _bbox -> {:error, :stubbed} end
|
|
|
|
args = %{
|
|
home: home,
|
|
stations: stations,
|
|
band_mhz: 10_000,
|
|
valid_time: ~U[2026-04-25 12:00:00Z],
|
|
mode: :ssb,
|
|
max_distance_km: 65.0,
|
|
min_elev_gain: 0
|
|
}
|
|
|
|
result =
|
|
Compute.run(args,
|
|
scores_at: scores_at,
|
|
elev_lookup: elev_lookup,
|
|
clearance_lookup: clearance_lookup,
|
|
prominence_lookup: prominence_lookup,
|
|
road_lookup: road_lookup
|
|
)
|
|
|
|
assert is_map(result)
|
|
assert Map.has_key?(result, :cells)
|
|
assert Map.has_key?(result, :top_candidates)
|
|
|
|
assert is_list(result.cells)
|
|
assert length(result.top_candidates) <= 5
|
|
refute result.top_candidates == []
|
|
|
|
# Sorted by score desc.
|
|
scores = Enum.map(result.top_candidates, & &1.score)
|
|
assert scores == Enum.sort(scores, :desc)
|
|
|
|
# Required candidate fields.
|
|
cand = hd(result.top_candidates)
|
|
|
|
for key <- [
|
|
:grid,
|
|
:lat,
|
|
:lon,
|
|
:elev_m,
|
|
:drive_min,
|
|
:score,
|
|
:tier_color,
|
|
:distance_km,
|
|
:bearing_compass,
|
|
:name
|
|
] do
|
|
assert Map.has_key?(cand, key), "expected candidate to have key #{inspect(key)}"
|
|
end
|
|
|
|
assert is_binary(cand.grid)
|
|
assert is_binary(cand.tier_color)
|
|
assert is_binary(cand.bearing_compass)
|
|
assert is_binary(cand.name)
|
|
end
|
|
|
|
test "penalizes cells with tall nearby buildings (clutter)" do
|
|
home = %{lat: 33.0, lon: -96.0, elev_m: 200}
|
|
stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}]
|
|
grid = build_grid(home.lat, home.lon)
|
|
|
|
tall_cell = {Float.round(home.lat + 0.1, 4), Float.round(home.lon, 4)}
|
|
ref_cell = {Float.round(home.lat - 0.1, 4), Float.round(home.lon, 4)}
|
|
|
|
scores_at = fn _band, _t, _bbox -> grid end
|
|
elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end
|
|
clearance_lookup = fn _cells, _stations -> %{} end
|
|
prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end
|
|
road_lookup = fn _cells, _bbox -> {:error, :stubbed} end
|
|
|
|
buildings_clutter_lookup = fn cells ->
|
|
Map.new(cells, fn c ->
|
|
height = if {c.lat, c.lon} == tall_cell, do: 30.0, else: 0.0
|
|
{{c.lat, c.lon}, height}
|
|
end)
|
|
end
|
|
|
|
args = %{
|
|
home: home,
|
|
stations: stations,
|
|
band_mhz: 10_000,
|
|
valid_time: ~U[2026-04-25 12:00:00Z],
|
|
mode: :ssb,
|
|
max_distance_km: 65.0,
|
|
min_elev_gain: 0
|
|
}
|
|
|
|
result =
|
|
Compute.run(args,
|
|
scores_at: scores_at,
|
|
elev_lookup: elev_lookup,
|
|
clearance_lookup: clearance_lookup,
|
|
prominence_lookup: prominence_lookup,
|
|
road_lookup: road_lookup,
|
|
buildings_clutter_lookup: buildings_clutter_lookup
|
|
)
|
|
|
|
cells_by_pos = Map.new(result.cells, fn c -> {{c.lat, c.lon}, c.score} end)
|
|
tall_score = Map.fetch!(cells_by_pos, tall_cell)
|
|
ref_score = Map.fetch!(cells_by_pos, ref_cell)
|
|
|
|
# 30m nearby building → penalty = 30/5 = 6.0 dB
|
|
assert ref_score - tall_score >= 5.5
|
|
end
|
|
|
|
test "filters cells failing min_elev_gain" do
|
|
home = %{lat: 33.0, lon: -96.0, elev_m: 500}
|
|
stations = [%{callsign: "W5LUA", lat: 33.1, lon: -96.6, selected: true}]
|
|
grid = build_grid(home.lat, home.lon)
|
|
|
|
# All elevations below home — nothing should pass min_elev_gain=200.
|
|
scores_at = fn _band, _t, _bbox -> grid end
|
|
elev_lookup = fn points -> Map.new(points, fn p -> {p, 100} end) end
|
|
clearance_lookup = fn _cells, _stations -> %{} end
|
|
prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end
|
|
road_lookup = fn _cells, _bbox -> {:error, :stubbed} end
|
|
|
|
result =
|
|
Compute.run(
|
|
%{
|
|
home: home,
|
|
stations: stations,
|
|
band_mhz: 10_000,
|
|
valid_time: ~U[2026-04-25 12:00:00Z],
|
|
mode: :ssb,
|
|
max_distance_km: 65.0,
|
|
min_elev_gain: 200
|
|
},
|
|
scores_at: scores_at,
|
|
elev_lookup: elev_lookup,
|
|
clearance_lookup: clearance_lookup,
|
|
prominence_lookup: prominence_lookup,
|
|
road_lookup: road_lookup
|
|
)
|
|
|
|
assert result.cells == []
|
|
assert result.top_candidates == []
|
|
end
|
|
end
|