feat(rover): snap top candidates to SRTM hilltops + add hillshade overlay
This commit is contained in:
parent
3c877b8b8e
commit
7d80babb18
4 changed files with 122 additions and 11 deletions
|
|
@ -18,6 +18,7 @@ interface RoverMapHook extends ViewHook {
|
||||||
map: L.Map
|
map: L.Map
|
||||||
osmLayer: L.TileLayer
|
osmLayer: L.TileLayer
|
||||||
topoLayer: L.TileLayer | null
|
topoLayer: L.TileLayer | null
|
||||||
|
hillshadeLayer: L.TileLayer
|
||||||
layersControl: L.Control.Layers
|
layersControl: L.Control.Layers
|
||||||
stations: Station[]
|
stations: Station[]
|
||||||
stationMarkers: L.LayerGroup
|
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", {
|
this.topoLayer = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
|
||||||
maxZoom: 17
|
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.osmLayer.addTo(map)
|
||||||
|
this.hillshadeLayer.addTo(map)
|
||||||
|
|
||||||
this.layersControl = L.control.layers(
|
this.layersControl = L.control.layers(
|
||||||
{ "OSM": this.osmLayer, "Topo": this.topoLayer },
|
{ "OSM": this.osmLayer, "Topo": this.topoLayer },
|
||||||
undefined,
|
{ "Hillshade": this.hillshadeLayer },
|
||||||
{ position: "topright" }
|
{ position: "topright" }
|
||||||
).addTo(map)
|
).addTo(map)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ defmodule Microwaveprop.Rover.Compute do
|
||||||
alias Microwaveprop.Rover.Aggregator
|
alias Microwaveprop.Rover.Aggregator
|
||||||
alias Microwaveprop.Rover.DriveTime
|
alias Microwaveprop.Rover.DriveTime
|
||||||
alias Microwaveprop.Rover.Elevation
|
alias Microwaveprop.Rover.Elevation
|
||||||
|
alias Microwaveprop.Rover.Hilltop
|
||||||
alias Microwaveprop.Rover.LinkMargin
|
alias Microwaveprop.Rover.LinkMargin
|
||||||
alias Microwaveprop.Rover.PathTerrain
|
alias Microwaveprop.Rover.PathTerrain
|
||||||
alias Microwaveprop.Rover.Prominence
|
alias Microwaveprop.Rover.Prominence
|
||||||
|
|
@ -73,6 +74,7 @@ defmodule Microwaveprop.Rover.Compute do
|
||||||
clearance_lookup = Keyword.get(deps, :clearance_lookup, &PathTerrain.clearance_map/2)
|
clearance_lookup = Keyword.get(deps, :clearance_lookup, &PathTerrain.clearance_map/2)
|
||||||
prominence_lookup = Keyword.get(deps, :prominence_lookup, &Prominence.prominence_map/1)
|
prominence_lookup = Keyword.get(deps, :prominence_lookup, &Prominence.prominence_map/1)
|
||||||
road_lookup = Keyword.get(deps, :road_lookup, &RoadProximity.road_distances/2)
|
road_lookup = Keyword.get(deps, :road_lookup, &RoadProximity.road_distances/2)
|
||||||
|
hilltop_snap = Keyword.get(deps, :hilltop_snap, &Hilltop.snap/1)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
home: home,
|
home: home,
|
||||||
|
|
@ -131,7 +133,7 @@ defmodule Microwaveprop.Rover.Compute do
|
||||||
cells
|
cells
|
||||||
|> Enum.sort_by(& &1.score, :desc)
|
|> Enum.sort_by(& &1.score, :desc)
|
||||||
|> Enum.take(@top_n)
|
|> 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)
|
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)
|
db |> max(-@prominence_cap_db) |> min(@prominence_cap_db)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp candidate_payload(cell, home) do
|
defp candidate_payload(cell, home, hilltop_snap) do
|
||||||
drive_min = DriveTime.drive_min(cell.distance_km)
|
{lat, lon, elev_m} =
|
||||||
bearing = DriveTime.bearing_compass({home.lat, home.lon}, {cell.lat, cell.lon})
|
case hilltop_snap.({cell.lat, cell.lon}) do
|
||||||
grid = Maidenhead.from_latlon(cell.lat, cell.lon, 10)
|
{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,
|
grid: grid,
|
||||||
lat: cell.lat,
|
lat: lat,
|
||||||
lon: cell.lon,
|
lon: lon,
|
||||||
elev_m: cell.elev_m,
|
elev_m: elev_m,
|
||||||
prominence_m: cell.prominence_m,
|
prominence_m: cell.prominence_m,
|
||||||
road_km: cell.road_km,
|
road_km: cell.road_km,
|
||||||
drive_min: drive_min,
|
drive_min: drive_min,
|
||||||
score: cell.score,
|
score: cell.score,
|
||||||
tier_color: cell.tier_color,
|
tier_color: cell.tier_color,
|
||||||
distance_km: cell.distance_km,
|
distance_km: distance_km,
|
||||||
bearing_compass: bearing,
|
bearing_compass: bearing,
|
||||||
name: "#{grid} — #{round(cell.distance_km)} km #{bearing} of home"
|
name: "#{grid} — #{round(distance_km)} km #{bearing} of home"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
55
lib/microwaveprop/rover/hilltop.ex
Normal file
55
lib/microwaveprop/rover/hilltop.ex
Normal 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
|
||||||
41
test/microwaveprop/rover/hilltop_test.exs
Normal file
41
test/microwaveprop/rover/hilltop_test.exs
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue