From 2f060b43715cd295641bbd168810c8fa71042f24 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 26 Apr 2026 11:13:25 -0500 Subject: [PATCH] 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. --- lib/microwaveprop/rover/compute.ex | 67 ++++++++++++++++++----- test/microwaveprop/rover/compute_test.exs | 49 +++++++++++++++++ 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/lib/microwaveprop/rover/compute.ex b/lib/microwaveprop/rover/compute.ex index 055aee52..d0fcffed 100644 --- a/lib/microwaveprop/rover/compute.ex +++ b/lib/microwaveprop/rover/compute.ex @@ -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 diff --git a/test/microwaveprop/rover/compute_test.exs b/test/microwaveprop/rover/compute_test.exs index 7efc965d..50fa6cd0 100644 --- a/test/microwaveprop/rover/compute_test.exs +++ b/test/microwaveprop/rover/compute_test.exs @@ -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}]