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.
|
plus the top 5 candidate parking spots.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
||||||
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
|
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
|
||||||
alias Microwaveprop.Propagation
|
alias Microwaveprop.Propagation
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
|
@ -46,6 +47,15 @@ defmodule Microwaveprop.Rover.Compute do
|
||||||
@road_penalty_db_per_km 0.5
|
@road_penalty_db_per_km 0.5
|
||||||
@road_penalty_cap_db 6.0
|
@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_excellent 10.0
|
||||||
@tier_good 3.0
|
@tier_good 3.0
|
||||||
@tier_marginal 0.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)
|
clearance_lookup = Keyword.get(deps, :clearance_lookup, &PathTerrain.clearance_map/2)
|
||||||
prominence_lookup = Keyword.get(deps, :prominence_lookup, &Prominence.prominence_map/1)
|
prominence_lookup = Keyword.get(deps, :prominence_lookup, &Prominence.prominence_map/1)
|
||||||
road_lookup = Keyword.get(deps, :road_lookup, &RoadProximity.road_distances/2)
|
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)
|
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),
|
do: time_step("road_proximity", fn -> fetch_road_map(road_lookup, in_radius, bbox) end),
|
||||||
else: %{}
|
else: %{}
|
||||||
|
|
||||||
|
clutter_map = time_step("building_clutter", fn -> buildings_clutter_lookup.(in_radius) end)
|
||||||
|
|
||||||
home_elev = home.elev_m || 0
|
home_elev = home.elev_m || 0
|
||||||
|
|
||||||
|
cell_maps = %{
|
||||||
|
elev: elev_map,
|
||||||
|
clearance: clearance_map,
|
||||||
|
prominence: prominence_map,
|
||||||
|
road: road_map,
|
||||||
|
clutter: clutter_map
|
||||||
|
}
|
||||||
|
|
||||||
cells =
|
cells =
|
||||||
in_radius
|
in_radius
|
||||||
|> Enum.map(fn cell ->
|
|> Enum.map(fn cell -> annotate_cell(cell, cell_maps, home, mode, selected_stations) end)
|
||||||
annotate_cell(
|
|
||||||
cell,
|
|
||||||
elev_map,
|
|
||||||
clearance_map,
|
|
||||||
prominence_map,
|
|
||||||
road_map,
|
|
||||||
home,
|
|
||||||
mode,
|
|
||||||
selected_stations
|
|
||||||
)
|
|
||||||
end)
|
|
||||||
|> Enum.filter(&keep_cell?(&1, home_elev, min_elev_gain))
|
|> Enum.filter(&keep_cell?(&1, home_elev, min_elev_gain))
|
||||||
|
|
||||||
top_candidates =
|
top_candidates =
|
||||||
|
|
@ -174,10 +187,19 @@ defmodule Microwaveprop.Rover.Compute do
|
||||||
|
|
||||||
defp keep_cell?(_, _, _), do: false
|
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})
|
elev_m = Map.get(elev_map, {cell.lat, cell.lon})
|
||||||
prominence_m = Map.get(prominence_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})
|
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)
|
base_margin = LinkMargin.link_margin_from_score(cell.score, mode)
|
||||||
|
|
||||||
margins =
|
margins =
|
||||||
|
|
@ -196,7 +218,8 @@ defmodule Microwaveprop.Rover.Compute do
|
||||||
dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon})
|
dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon})
|
||||||
|
|
||||||
score =
|
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,
|
lat: cell.lat,
|
||||||
|
|
@ -204,6 +227,7 @@ defmodule Microwaveprop.Rover.Compute do
|
||||||
elev_m: elev_m,
|
elev_m: elev_m,
|
||||||
prominence_m: prominence_m,
|
prominence_m: prominence_m,
|
||||||
road_km: road_km,
|
road_km: road_km,
|
||||||
|
building_height_m: building_height_m,
|
||||||
score: score,
|
score: score,
|
||||||
distance_km: dist_km,
|
distance_km: dist_km,
|
||||||
tier_color: tier_color(score)
|
tier_color: tier_color(score)
|
||||||
|
|
@ -232,6 +256,21 @@ defmodule Microwaveprop.Rover.Compute do
|
||||||
min(db, @road_penalty_cap_db)
|
min(db, @road_penalty_cap_db)
|
||||||
end
|
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(nil), do: 0.0
|
||||||
|
|
||||||
defp terrain_db(clearance_m) do
|
defp terrain_db(clearance_m) do
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,55 @@ defmodule Microwaveprop.Rover.ComputeTest do
|
||||||
assert is_binary(cand.name)
|
assert is_binary(cand.name)
|
||||||
end
|
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
|
test "filters cells failing min_elev_gain" do
|
||||||
home = %{lat: 33.0, lon: -96.0, elev_m: 500}
|
home = %{lat: 33.0, lon: -96.0, elev_m: 500}
|
||||||
stations = [%{callsign: "W5LUA", lat: 33.1, lon: -96.6, selected: true}]
|
stations = [%{callsign: "W5LUA", lat: 33.1, lon: -96.6, selected: true}]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue