diff --git a/config/test.exs b/config/test.exs index ea4eae2b..fe101cf5 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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] diff --git a/lib/microwaveprop/rover/compute.ex b/lib/microwaveprop/rover/compute.ex index ee105dc2..638bf12a 100644 --- a/lib/microwaveprop/rover/compute.ex +++ b/lib/microwaveprop/rover/compute.ex @@ -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, diff --git a/lib/microwaveprop/rover/prominence.ex b/lib/microwaveprop/rover/prominence.ex new file mode 100644 index 00000000..c83c0879 --- /dev/null +++ b/lib/microwaveprop/rover/prominence.ex @@ -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 diff --git a/lib/microwaveprop/rover/road_proximity.ex b/lib/microwaveprop/rover/road_proximity.ex new file mode 100644 index 00000000..14c0adfb --- /dev/null +++ b/lib/microwaveprop/rover/road_proximity.ex @@ -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 diff --git a/test/microwaveprop/rover/compute_test.exs b/test/microwaveprop/rover/compute_test.exs index 1a87f591..7efc965d 100644 --- a/test/microwaveprop/rover/compute_test.exs +++ b/test/microwaveprop/rover/compute_test.exs @@ -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 == []