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:
parent
6710140b72
commit
2f060b4371
2 changed files with 102 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue