From 7d80babb188bc9a06b5bd611db4e9e6cb1e23fbb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 26 Apr 2026 09:12:20 -0500 Subject: [PATCH] feat(rover): snap top candidates to SRTM hilltops + add hillshade overlay --- assets/js/rover_map_hook.ts | 8 +++- lib/microwaveprop/rover/compute.ex | 29 +++++++----- lib/microwaveprop/rover/hilltop.ex | 55 +++++++++++++++++++++++ test/microwaveprop/rover/hilltop_test.exs | 41 +++++++++++++++++ 4 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 lib/microwaveprop/rover/hilltop.ex create mode 100644 test/microwaveprop/rover/hilltop_test.exs diff --git a/assets/js/rover_map_hook.ts b/assets/js/rover_map_hook.ts index e92bce10..13db5109 100644 --- a/assets/js/rover_map_hook.ts +++ b/assets/js/rover_map_hook.ts @@ -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 = { 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) diff --git a/lib/microwaveprop/rover/compute.ex b/lib/microwaveprop/rover/compute.ex index 432dd031..49e8eed8 100644 --- a/lib/microwaveprop/rover/compute.ex +++ b/lib/microwaveprop/rover/compute.ex @@ -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 diff --git a/lib/microwaveprop/rover/hilltop.ex b/lib/microwaveprop/rover/hilltop.ex new file mode 100644 index 00000000..bc1766a3 --- /dev/null +++ b/lib/microwaveprop/rover/hilltop.ex @@ -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 diff --git a/test/microwaveprop/rover/hilltop_test.exs b/test/microwaveprop/rover/hilltop_test.exs new file mode 100644 index 00000000..5e7345e3 --- /dev/null +++ b/test/microwaveprop/rover/hilltop_test.exs @@ -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