feat(rover): prefer broad hilltops near roads as ideal candidates

- Local prominence: each cell's elevation vs. its 8-neighbor ring
  (~1.3 km radius) feeds a small additive bonus so broad hilltops
  rank above isolated SRTM voxels
- Road proximity: one Overpass call per Calculate fetches drivable
  ways inside the bbox; cells beyond ~0.5 km of any road get a
  light dB penalty, gated off in tests
This commit is contained in:
Graham McIntire 2026-04-25 17:45:48 -05:00
parent 4611926b8d
commit eac817cd57
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 258 additions and 5 deletions

View file

@ -68,6 +68,10 @@ config :microwaveprop,
:propagation_scores_dir,
Path.join(System.tmp_dir!(), "microwaveprop_test_scores_#{System.unique_integer([:positive])}")
# Skip the Overpass road-proximity HTTP call in tests — the rover live
# test exercises the full Calculate pipeline and we don't want it
# reaching out to overpass-api.de.
config :microwaveprop, :rover_road_proximity_enabled, false
config :microwaveprop, cache_contact_count: false
config :microwaveprop, cache_contact_map: false
config :microwaveprop, elevation_req_options: [plug: {Req.Test, Microwaveprop.Terrain.ElevationClient}, retry: false]

View file

@ -14,6 +14,8 @@ defmodule Microwaveprop.Rover.Compute do
alias Microwaveprop.Rover.Elevation
alias Microwaveprop.Rover.LinkMargin
alias Microwaveprop.Rover.PathTerrain
alias Microwaveprop.Rover.Prominence
alias Microwaveprop.Rover.RoadProximity
@avg_speed_kmh 65.0
@drive_penalty_db_per_hour 2.0
@ -26,6 +28,20 @@ defmodule Microwaveprop.Rover.Compute do
@clearance_m_per_db 30.0
@clearance_cap_db 10.0
# Per-cell prominence bonus (broad-hilltop preference). Capped low
# so it only acts as a tiebreaker between cells with similar link
# margins — clearance to stations is the primary signal.
@prominence_m_per_db 30.0
@prominence_cap_db 4.0
# Per-cell road-proximity penalty: cells inside @road_free_km of a road
# take no penalty, then 0.5 dB / km up to @road_penalty_cap_db. Cells
# far from any road are still surfaced (just down-ranked) so wilderness
# spots aren't impossible to find.
@road_free_km 0.5
@road_penalty_db_per_km 0.5
@road_penalty_cap_db 6.0
@tier_excellent 10.0
@tier_good 3.0
@tier_marginal 0.0
@ -53,6 +69,8 @@ defmodule Microwaveprop.Rover.Compute do
scores_at = Keyword.get(deps, :scores_at, &Propagation.scores_at/3)
elev_lookup = Keyword.get(deps, :elev_lookup, &Elevation.lookup_many/1)
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)
%{
home: home,
@ -80,12 +98,28 @@ defmodule Microwaveprop.Rover.Compute do
points = Enum.map(in_radius, &{&1.lat, &1.lon})
elev_map = elev_lookup.(points)
clearance_map = clearance_lookup.(in_radius, selected_stations)
prominence_map = prominence_lookup.(in_radius)
road_map =
if Application.get_env(:microwaveprop, :rover_road_proximity_enabled, true),
do: fetch_road_map(road_lookup, in_radius, bbox),
else: %{}
home_elev = home.elev_m || 0
cells =
in_radius
|> Enum.map(fn cell ->
annotate_cell(cell, elev_map, clearance_map, home, mode, selected_stations)
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))
@ -132,8 +166,10 @@ defmodule Microwaveprop.Rover.Compute do
defp keep_cell?(_, _, _), do: false
defp annotate_cell(cell, elev_map, clearance_map, home, mode, stations) do
defp annotate_cell(cell, elev_map, clearance_map, prominence_map, road_map, home, mode, stations) do
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})
base_margin = LinkMargin.link_margin_from_score(cell.score, mode)
margins =
@ -150,12 +186,16 @@ defmodule Microwaveprop.Rover.Compute do
agg_db ->
dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon})
score = agg_db - drive_penalty(dist_km)
score =
agg_db + prominence_db(prominence_m) - drive_penalty(dist_km) - road_penalty_db(road_km)
%{
lat: cell.lat,
lon: cell.lon,
elev_m: elev_m,
prominence_m: prominence_m,
road_km: road_km,
score: score,
distance_km: dist_km,
tier_color: tier_color(score)
@ -163,6 +203,21 @@ defmodule Microwaveprop.Rover.Compute do
end
end
defp fetch_road_map(road_lookup, cells, bbox) do
case road_lookup.(cells, bbox) do
{:ok, map} -> map
{:error, _} -> %{}
end
end
defp road_penalty_db(nil), do: 0.0
defp road_penalty_db(km) when is_number(km) do
excess = max(km - @road_free_km, 0.0)
db = excess * @road_penalty_db_per_km
min(db, @road_penalty_cap_db)
end
defp terrain_db(nil), do: 0.0
defp terrain_db(clearance_m) do
@ -170,6 +225,13 @@ defmodule Microwaveprop.Rover.Compute do
db |> max(-@clearance_cap_db) |> min(@clearance_cap_db)
end
defp prominence_db(nil), do: 0.0
defp prominence_db(prominence_m) do
db = prominence_m / @prominence_m_per_db
db |> max(-@prominence_cap_db) |> min(@prominence_cap_db)
end
defp candidate_payload(cell, home) do
drive_min = DriveTime.drive_min(cell.distance_km)
bearing = DriveTime.bearing_compass({home.lat, home.lon}, {cell.lat, cell.lon})
@ -180,6 +242,8 @@ defmodule Microwaveprop.Rover.Compute do
lat: cell.lat,
lon: cell.lon,
elev_m: cell.elev_m,
prominence_m: cell.prominence_m,
road_km: cell.road_km,
drive_min: drive_min,
score: cell.score,
tier_color: cell.tier_color,

View file

@ -0,0 +1,67 @@
defmodule Microwaveprop.Rover.Prominence do
@moduledoc """
Local terrain prominence per rover cell: how far the cell sits above
the average elevation of its 8 immediate neighbors at SRTM tile scale.
Used by the rover scorer to favor *broad* hilltops over isolated SRTM
voxels that just happen to be tall a hill big enough to actually
park on and run a station, not a tree poking through the data.
"""
alias Microwaveprop.Rover.Elevation
@ring_offset_deg 0.012
@ring_offsets [
{@ring_offset_deg, 0.0},
{-@ring_offset_deg, 0.0},
{0.0, @ring_offset_deg},
{0.0, -@ring_offset_deg},
{@ring_offset_deg, @ring_offset_deg},
{@ring_offset_deg, -@ring_offset_deg},
{-@ring_offset_deg, @ring_offset_deg},
{-@ring_offset_deg, -@ring_offset_deg}
]
@type latlon :: {float(), float()}
@doc """
Returns `%{ {lat, lon} => prominence_m | nil }` for each cell.
`prominence_m = cell_elev - mean(ring_elevs)`. ~1.3 km ring radius
matches the scale of the kind of hill a rover wants to park on.
"""
@spec prominence_map([map()], keyword()) :: %{latlon() => integer() | nil}
def prominence_map(cells, opts \\ []) do
elev_lookup = Keyword.get(opts, :elev_lookup, &Elevation.lookup_many/1)
cell_points = Enum.map(cells, fn c -> {c.lat, c.lon} end)
ring_points =
cells
|> Enum.flat_map(&ring_for/1)
|> Enum.uniq()
elev = elev_lookup.(cell_points ++ ring_points)
Map.new(cells, fn cell ->
key = {cell.lat, cell.lon}
cell_elev = Map.get(elev, key)
ring =
cell
|> ring_for()
|> Enum.map(&Map.get(elev, &1))
|> Enum.reject(&is_nil/1)
prominence =
if is_integer(cell_elev) and ring != [],
do: cell_elev - round(Enum.sum(ring) / length(ring))
{key, prominence}
end)
end
defp ring_for(%{lat: lat, lon: lon}) do
Enum.map(@ring_offsets, fn {dlat, dlon} -> {lat + dlat, lon + dlon} end)
end
end

View file

@ -0,0 +1,110 @@
defmodule Microwaveprop.Rover.RoadProximity do
@moduledoc """
Approximate distance from each rover candidate cell to the nearest
drivable road, sourced from OpenStreetMap via the Overpass API.
One Overpass query is issued per Calculate, fetching all motorway,
trunk, primary, secondary, tertiary, unclassified, and residential
ways inside the candidate bounding box. We then scan road segments
per cell to find the minimum point-to-segment distance.
Failure (network down, Overpass throttled, no roads in box) returns
`:unavailable` and the scorer falls back to ignoring road proximity.
"""
require Logger
@overpass_url "https://overpass-api.de/api/interpreter"
@road_types ~w(motorway trunk primary secondary tertiary unclassified residential)
@type latlon :: {float(), float()}
@type segment :: {latlon(), latlon()}
@type bbox :: %{required(String.t()) => float()}
@spec road_distances([map()], bbox(), keyword()) ::
{:ok, %{latlon() => float() | nil}} | {:error, term()}
def road_distances(cells, bbox, opts \\ []) do
fetch = Keyword.get(opts, :fetch, &fetch_segments/1)
case fetch.(bbox) do
{:ok, segments} when segments != [] ->
{:ok, Map.new(cells, fn c -> {{c.lat, c.lon}, nearest_road_km(c, segments)} end)}
{:ok, []} ->
{:error, :no_roads}
{:error, reason} ->
Logger.warning("rover road proximity fetch failed: #{inspect(reason)}")
{:error, reason}
end
end
@spec fetch_segments(bbox()) :: {:ok, [segment()]} | {:error, term()}
def fetch_segments(%{"south" => s, "west" => w, "north" => n, "east" => e}) do
types = Enum.join(@road_types, "|")
query = """
[out:json][timeout:25];
way["highway"~"^(#{types})$"](#{s},#{w},#{n},#{e});
out geom;
"""
case Req.post(@overpass_url, form: [data: query], receive_timeout: 30_000) do
{:ok, %{status: 200, body: %{"elements" => elements}}} ->
{:ok, segments_from_elements(elements)}
{:ok, %{status: status}} ->
{:error, {:http, status}}
{:error, reason} ->
{:error, reason}
end
end
defp segments_from_elements(elements) do
Enum.flat_map(elements, fn
%{"type" => "way", "geometry" => geom} when is_list(geom) and length(geom) >= 2 ->
geom
|> Enum.map(fn %{"lat" => lat, "lon" => lon} -> {lat, lon} end)
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [a, b] -> {a, b} end)
_ ->
[]
end)
end
defp nearest_road_km(%{lat: lat, lon: lon}, segments) do
pt = {lat, lon}
segments
|> Enum.map(&segment_distance_km(pt, &1))
|> Enum.min(fn -> nil end)
end
# Approximate point-to-segment distance using equirectangular projection
# (km), which is plenty accurate over the few-km segments we care about.
defp segment_distance_km({plat, plon}, {{alat, alon}, {blat, blon}}) do
cos_lat = :math.cos(plat * :math.pi() / 180.0)
px = plon * cos_lat * 111.0
py = plat * 111.0
ax = alon * cos_lat * 111.0
ay = alat * 111.0
bx = blon * cos_lat * 111.0
by = blat * 111.0
dx = bx - ax
dy = by - ay
seg_len_sq = dx * dx + dy * dy
t =
if seg_len_sq <= 0,
do: 0.0,
else: ((px - ax) * dx + (py - ay) * dy) / seg_len_sq
t = t |> max(0.0) |> min(1.0)
cx = ax + t * dx
cy = ay + t * dy
:math.sqrt((px - cx) ** 2 + (py - cy) ** 2)
end
end

View file

@ -22,6 +22,8 @@ defmodule Microwaveprop.Rover.ComputeTest do
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,
@ -37,7 +39,9 @@ defmodule Microwaveprop.Rover.ComputeTest do
Compute.run(args,
scores_at: scores_at,
elev_lookup: elev_lookup,
clearance_lookup: clearance_lookup
clearance_lookup: clearance_lookup,
prominence_lookup: prominence_lookup,
road_lookup: road_lookup
)
assert is_map(result)
@ -85,6 +89,8 @@ defmodule Microwaveprop.Rover.ComputeTest do
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(
@ -99,7 +105,9 @@ defmodule Microwaveprop.Rover.ComputeTest do
},
scores_at: scores_at,
elev_lookup: elev_lookup,
clearance_lookup: clearance_lookup
clearance_lookup: clearance_lookup,
prominence_lookup: prominence_lookup,
road_lookup: road_lookup
)
assert result.cells == []