feat(rover): snap top candidates to SRTM hilltops + add hillshade overlay

This commit is contained in:
Graham McIntire 2026-04-26 09:12:20 -05:00
parent 3c877b8b8e
commit 7d80babb18
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 122 additions and 11 deletions

View file

@ -18,6 +18,7 @@ interface RoverMapHook extends ViewHook {
map: L.Map
osmLayer: L.TileLayer
topoLayer: L.TileLayer | null
hillshadeLayer: L.TileLayer
layersControl: L.Control.Layers
stations: Station[]
stationMarkers: L.LayerGroup
@ -108,12 +109,17 @@ export const RoverMap: Partial<RoverMapHook> = {
this.topoLayer = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
maxZoom: 17
})
this.hillshadeLayer = L.tileLayer(
"https://services.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}",
{ maxZoom: 16, opacity: 0.45 }
)
this.osmLayer.addTo(map)
this.hillshadeLayer.addTo(map)
this.layersControl = L.control.layers(
{ "OSM": this.osmLayer, "Topo": this.topoLayer },
undefined,
{ "Hillshade": this.hillshadeLayer },
{ position: "topright" }
).addTo(map)

View file

@ -12,6 +12,7 @@ defmodule Microwaveprop.Rover.Compute do
alias Microwaveprop.Rover.Aggregator
alias Microwaveprop.Rover.DriveTime
alias Microwaveprop.Rover.Elevation
alias Microwaveprop.Rover.Hilltop
alias Microwaveprop.Rover.LinkMargin
alias Microwaveprop.Rover.PathTerrain
alias Microwaveprop.Rover.Prominence
@ -73,6 +74,7 @@ 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)
hilltop_snap = Keyword.get(deps, :hilltop_snap, &Hilltop.snap/1)
%{
home: home,
@ -131,7 +133,7 @@ defmodule Microwaveprop.Rover.Compute do
cells
|> Enum.sort_by(& &1.score, :desc)
|> Enum.take(@top_n)
|> Enum.map(&candidate_payload(&1, home))
|> Enum.map(&candidate_payload(&1, home, hilltop_snap))
warnings = build_warnings(raw_cells, in_radius, elev_map, cells)
@ -242,24 +244,31 @@ defmodule Microwaveprop.Rover.Compute do
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})
grid = Maidenhead.from_latlon(cell.lat, cell.lon, 10)
defp candidate_payload(cell, home, hilltop_snap) do
{lat, lon, elev_m} =
case hilltop_snap.({cell.lat, cell.lon}) do
{hl_lat, hl_lon, hl_elev} -> {hl_lat, hl_lon, hl_elev}
_ -> {cell.lat, cell.lon, cell.elev_m}
end
distance_km = DriveTime.haversine_km({home.lat, home.lon}, {lat, lon})
drive_min = DriveTime.drive_min(distance_km)
bearing = DriveTime.bearing_compass({home.lat, home.lon}, {lat, lon})
grid = Maidenhead.from_latlon(lat, lon, 10)
%{
grid: grid,
lat: cell.lat,
lon: cell.lon,
elev_m: cell.elev_m,
lat: lat,
lon: lon,
elev_m: elev_m,
prominence_m: cell.prominence_m,
road_km: cell.road_km,
drive_min: drive_min,
score: cell.score,
tier_color: cell.tier_color,
distance_km: cell.distance_km,
distance_km: distance_km,
bearing_compass: bearing,
name: "#{grid}#{round(cell.distance_km)} km #{bearing} of home"
name: "#{grid}#{round(distance_km)} km #{bearing} of home"
}
end

View file

@ -0,0 +1,55 @@
defmodule Microwaveprop.Rover.Hilltop do
@moduledoc """
Snaps a propagation-grid cell center to the highest SRTM point inside
its footprint. The propagation grid is on a 0.125° lattice (~14 km),
but a rover wants to park on the actual hilltop inside that cell, not
at the nominal cell center. Sample SRTM at sub-cell resolution and
return the highest point.
"""
alias Microwaveprop.Rover.Elevation
@default_half_deg 0.0625
@default_step_deg 0.01
@type latlon :: {float(), float()}
@doc """
Returns `{best_lat, best_lon, best_elev}` for the highest SRTM point
inside `±half_deg` of `cell_center`, or `nil` if SRTM coverage is
missing across the whole footprint.
"""
@spec snap(latlon(), keyword()) :: {float(), float(), integer()} | nil
def snap({lat, lon}, opts \\ []) do
half = Keyword.get(opts, :half_deg, @default_half_deg)
step = Keyword.get(opts, :step_deg, @default_step_deg)
elev_lookup = Keyword.get(opts, :elev_lookup, &Elevation.lookup_many/1)
points = sample_grid(lat, lon, half, step)
elev_map = elev_lookup.(points)
points
|> Enum.flat_map(fn pt ->
case Map.get(elev_map, pt) do
nil -> []
elev -> [{pt, elev}]
end
end)
|> case do
[] ->
nil
list ->
{{best_lat, best_lon}, best_elev} = Enum.max_by(list, fn {_, e} -> e end)
{best_lat, best_lon, best_elev}
end
end
defp sample_grid(lat, lon, half, step) do
n = max(trunc(half / step), 1)
for i <- -n..n, j <- -n..n do
{Float.round(lat + i * step, 4), Float.round(lon + j * step, 4)}
end
end
end

View file

@ -0,0 +1,41 @@
defmodule Microwaveprop.Rover.HilltopTest do
use ExUnit.Case, async: true
alias Microwaveprop.Rover.Hilltop
test "snap/2 returns the highest sampled SRTM point inside the cell" do
cell_center = {33.0, -96.0}
# Stub elev lookup: highest elevation NE of cell center.
elev_lookup = fn points ->
Map.new(points, fn {lat, lon} ->
elev = round((lat - 33.0) * 1000 + (lon + 96.0) * 1000)
{{lat, lon}, elev}
end)
end
{lat, lon, elev} = Hilltop.snap(cell_center, elev_lookup: elev_lookup)
# Best point should be the corner with the largest +lat, +lon offsets
# (NE corner of the sample grid).
assert lat > 33.0
assert lon > -96.0
assert is_integer(elev)
assert elev > 0
end
test "snap/2 returns nil when SRTM lookup yields no values" do
elev_lookup = fn points -> Map.new(points, fn p -> {p, nil} end) end
assert Hilltop.snap({33.0, -96.0}, elev_lookup: elev_lookup) == nil
end
test "snap/2 stays within the requested half-degree radius" do
elev_lookup = fn points -> Map.new(points, fn p -> {p, 100} end) end
{lat, lon, _} = Hilltop.snap({33.0, -96.0}, elev_lookup: elev_lookup, half_deg: 0.0625)
assert_in_delta lat, 33.0, 0.0625 + 1.0e-6
assert_in_delta lon, -96.0, 0.0625 + 1.0e-6
end
end