feat(rover): penalize cells surrounded by tall buildings (clutter)

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.
This commit is contained in:
Graham McIntire 2026-04-26 11:13:25 -05:00
parent 6710140b72
commit 2f060b4371
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 102 additions and 14 deletions

View file

@ -7,6 +7,7 @@ defmodule Microwaveprop.Rover.Compute do
plus the top 5 candidate parking spots.
"""
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
alias Microwaveprop.Propagation
alias Microwaveprop.Radio.Maidenhead
@ -46,6 +47,15 @@ defmodule Microwaveprop.Rover.Compute do
@road_penalty_db_per_km 0.5
@road_penalty_cap_db 6.0
# Per-cell building-clutter penalty: tallest building within
# @building_clutter_radius_m of the cell scaled at 1 dB / 5 m, capped low so
# urban canyons get down-ranked without dominating link-margin signal. The
# path-clearance step already accounts for buildings ON the link path; this
# penalty captures "surrounded by stuff that scatters/blocks every direction".
@building_clutter_radius_m 75
@building_clutter_m_per_db 5.0
@building_clutter_cap_db 6.0
@tier_excellent 10.0
@tier_good 3.0
@tier_marginal 0.0
@ -75,6 +85,10 @@ defmodule Microwaveprop.Rover.Compute do
clearance_lookup = Keyword.get(deps, :clearance_lookup, &PathTerrain.clearance_map/2)
prominence_lookup = Keyword.get(deps, :prominence_lookup, &Prominence.prominence_map/1)
road_lookup = Keyword.get(deps, :road_lookup, &RoadProximity.road_distances/2)
buildings_clutter_lookup =
Keyword.get(deps, :buildings_clutter_lookup, &default_building_clutter/1)
hilltop_snap = Keyword.get(deps, :hilltop_snap, &Hilltop.snap/1)
%{
@ -113,22 +127,21 @@ defmodule Microwaveprop.Rover.Compute do
do: time_step("road_proximity", fn -> fetch_road_map(road_lookup, in_radius, bbox) end),
else: %{}
clutter_map = time_step("building_clutter", fn -> buildings_clutter_lookup.(in_radius) end)
home_elev = home.elev_m || 0
cell_maps = %{
elev: elev_map,
clearance: clearance_map,
prominence: prominence_map,
road: road_map,
clutter: clutter_map
}
cells =
in_radius
|> Enum.map(fn cell ->
annotate_cell(
cell,
elev_map,
clearance_map,
prominence_map,
road_map,
home,
mode,
selected_stations
)
end)
|> Enum.map(fn cell -> annotate_cell(cell, cell_maps, home, mode, selected_stations) end)
|> Enum.filter(&keep_cell?(&1, home_elev, min_elev_gain))
top_candidates =
@ -174,10 +187,19 @@ defmodule Microwaveprop.Rover.Compute do
defp keep_cell?(_, _, _), do: false
defp annotate_cell(cell, elev_map, clearance_map, prominence_map, road_map, home, mode, stations) do
defp annotate_cell(cell, cell_maps, home, mode, stations) do
%{
elev: elev_map,
clearance: clearance_map,
prominence: prominence_map,
road: road_map,
clutter: clutter_map
} = cell_maps
elev_m = Map.get(elev_map, {cell.lat, cell.lon})
prominence_m = Map.get(prominence_map, {cell.lat, cell.lon})
road_km = Map.get(road_map, {cell.lat, cell.lon})
building_height_m = Map.get(clutter_map, {cell.lat, cell.lon})
base_margin = LinkMargin.link_margin_from_score(cell.score, mode)
margins =
@ -196,7 +218,8 @@ defmodule Microwaveprop.Rover.Compute do
dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon})
score =
agg_db + prominence_db(prominence_m) - drive_penalty(dist_km) - road_penalty_db(road_km)
agg_db + prominence_db(prominence_m) - drive_penalty(dist_km) -
road_penalty_db(road_km) - building_clutter_db(building_height_m)
%{
lat: cell.lat,
@ -204,6 +227,7 @@ defmodule Microwaveprop.Rover.Compute do
elev_m: elev_m,
prominence_m: prominence_m,
road_km: road_km,
building_height_m: building_height_m,
score: score,
distance_km: dist_km,
tier_color: tier_color(score)
@ -232,6 +256,21 @@ defmodule Microwaveprop.Rover.Compute do
min(db, @road_penalty_cap_db)
end
defp building_clutter_db(nil), do: 0.0
defp building_clutter_db(height_m) when is_number(height_m) and height_m <= 0, do: 0.0
defp building_clutter_db(height_m) when is_number(height_m) do
db = height_m / @building_clutter_m_per_db
min(db, @building_clutter_cap_db)
end
defp default_building_clutter(cells) do
Map.new(cells, fn cell ->
{{cell.lat, cell.lon}, BuildingsIndex.max_height_near(cell.lat, cell.lon, @building_clutter_radius_m)}
end)
end
defp terrain_db(nil), do: 0.0
defp terrain_db(clearance_m) do

View file

@ -80,6 +80,55 @@ defmodule Microwaveprop.Rover.ComputeTest do
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}]