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:
parent
4611926b8d
commit
eac817cd57
5 changed files with 258 additions and 5 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
67
lib/microwaveprop/rover/prominence.ex
Normal file
67
lib/microwaveprop/rover/prominence.ex
Normal 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
|
||||
110
lib/microwaveprop/rover/road_proximity.ex
Normal file
110
lib/microwaveprop/rover/road_proximity.ex
Normal 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
|
||||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue