feat(rover): score per-station terrain clearance from SRTM path samples

For each (rover cell, fixed station) pair, sample SRTM along the
great-circle path and add a per-link dB bonus proportional to the
rover's elevation above the highest intermediate terrain. Rover spots
on hilltops with clear sight to multiple stations now rise to the top.

Also vendor Leaflet's layers control PNGs and copy them under
priv/static/assets/css/images/ on build so /rover stops 404'ing on
layers-2x.png.
This commit is contained in:
Graham McIntire 2026-04-25 17:29:24 -05:00
parent f02a5b90e7
commit 40cbb40cb1
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 240 additions and 30 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/vendor/leaflet/images/layers.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

View file

@ -13,13 +13,19 @@ defmodule Microwaveprop.Rover.Compute do
alias Microwaveprop.Rover.DriveTime
alias Microwaveprop.Rover.Elevation
alias Microwaveprop.Rover.LinkMargin
alias Microwaveprop.Rover.PathTerrain
@avg_speed_kmh 65.0
@drive_penalty_db_per_hour 2.0
@elev_bonus_db_per_100m 1.0
@elev_bonus_cap_db 5.0
@top_n 5
# Per-link terrain advantage (dB) is `clearance_m / @clearance_m_per_db`,
# clamped to [-@clearance_cap_db, +@clearance_cap_db]. ~30 m matches a
# mature tree canopy so a rover one canopy-height above the worst
# mid-path terrain earns +1 dB.
@clearance_m_per_db 30.0
@clearance_cap_db 10.0
@tier_excellent 10.0
@tier_good 3.0
@tier_marginal 0.0
@ -46,6 +52,7 @@ defmodule Microwaveprop.Rover.Compute do
def run(args, deps \\ []) 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)
%{
home: home,
@ -69,14 +76,17 @@ defmodule Microwaveprop.Rover.Compute do
DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon}) <= radius_km
end)
# Bulk elevation lookup for surviving cells
# Bulk SRTM lookup for cell centers, then per-link path-terrain clearance.
points = Enum.map(in_radius, &{&1.lat, &1.lon})
elev_map = elev_lookup.(points)
clearance_map = clearance_lookup.(in_radius, selected_stations)
home_elev = home.elev_m || 0
cells =
in_radius
|> Enum.map(fn cell -> annotate_cell(cell, elev_map, home, home_elev, mode, selected_stations) end)
|> Enum.map(fn cell ->
annotate_cell(cell, elev_map, clearance_map, home, mode, selected_stations)
end)
|> Enum.filter(&keep_cell?(&1, home_elev, min_elev_gain))
top_candidates =
@ -122,21 +132,25 @@ defmodule Microwaveprop.Rover.Compute do
defp keep_cell?(_, _, _), do: false
defp annotate_cell(cell, elev_map, home, home_elev, mode, stations) do
defp annotate_cell(cell, elev_map, clearance_map, home, mode, stations) do
elev_m = Map.get(elev_map, {cell.lat, cell.lon})
base_margin = LinkMargin.link_margin_from_score(cell.score, mode)
margins = Enum.map(stations, fn _s -> LinkMargin.link_margin_from_score(cell.score, mode) end)
agg = Aggregator.cell_margin_db(margins)
margins =
Enum.map(stations, fn s ->
clearance_db =
terrain_db(Map.get(clearance_map, {{cell.lat, cell.lon}, {s.lat, s.lon}}))
case agg do
base_margin + clearance_db
end)
case Aggregator.cell_margin_db(margins) do
nil ->
nil
agg_db ->
dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon})
elev_bonus = elev_bonus(elev_m, home_elev)
drive_penalty = drive_penalty(dist_km)
score = agg_db + elev_bonus - drive_penalty
score = agg_db - drive_penalty(dist_km)
%{
lat: cell.lat,
@ -149,6 +163,13 @@ defmodule Microwaveprop.Rover.Compute do
end
end
defp terrain_db(nil), do: 0.0
defp terrain_db(clearance_m) do
db = clearance_m / @clearance_m_per_db
db |> max(-@clearance_cap_db) |> min(@clearance_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})
@ -168,13 +189,6 @@ defmodule Microwaveprop.Rover.Compute do
}
end
defp elev_bonus(nil, _elev_home), do: 0.0
defp elev_bonus(elev_c, elev_home) do
diff = (elev_c - elev_home) / 100.0 * @elev_bonus_db_per_100m
diff |> max(0.0) |> min(@elev_bonus_cap_db)
end
defp drive_penalty(dist_km), do: dist_km / @avg_speed_kmh * @drive_penalty_db_per_hour
defp tier_color(score) do

View file

@ -2,22 +2,53 @@ defmodule Microwaveprop.Rover.Elevation do
@moduledoc """
Bulk SRTM elevation lookup for a list of `{lat, lon}` points.
Wraps `Microwaveprop.Terrain.Srtm.lookup/3`, which already opens and
reads each tile per call. Cells whose tile is missing or whose value
is the SRTM void sentinel are mapped to `nil`.
Groups requested points by SRTM tile filename so each `.hgt` is opened
exactly once per call, then closed. Cells whose tile is missing or
whose value is the SRTM void sentinel are mapped to `nil`.
"""
alias Microwaveprop.Terrain.Srtm
@samples 3601
@void -32_768
@spec lookup_many([{float(), float()}]) :: %{{float(), float()} => integer() | nil}
def lookup_many([]), do: %{}
def lookup_many(points) when is_list(points) do
tiles_dir = Application.get_env(:microwaveprop, :srtm_tiles_dir, "/data/srtm")
unique = Enum.uniq(points)
Map.new(points, fn {lat, lon} = key ->
case Srtm.lookup(lat, lon, tiles_dir) do
{:ok, elev} -> {key, elev}
{:error, _} -> {key, nil}
grouped = Enum.group_by(unique, fn {lat, lon} -> Srtm.tile_filename(lat, lon) end)
grouped
|> Enum.flat_map(fn {filename, pts} -> read_tile(Path.join(tiles_dir, filename), pts) end)
|> Map.new()
end
defp read_tile(path, pts) do
case :file.open(path, [:read, :binary, :raw]) do
{:ok, fd} ->
try do
Enum.map(pts, fn {lat, lon} = key -> {key, read_point(fd, lat, lon)} end)
after
:file.close(fd)
end
{:error, _} ->
Enum.map(pts, fn key -> {key, nil} end)
end
end
defp read_point(fd, lat, lon) do
row = round((floor(lat) + 1 - lat) * (@samples - 1))
col = round((lon - floor(lon)) * (@samples - 1))
offset = (row * @samples + col) * 2
case :file.pread(fd, offset, 2) do
{:ok, <<elev::signed-big-integer-size(16)>>} when elev == @void -> nil
{:ok, <<elev::signed-big-integer-size(16)>>} -> elev
_ -> nil
end
end)
end
end

View file

@ -0,0 +1,77 @@
defmodule Microwaveprop.Rover.PathTerrain do
@moduledoc """
Per-link terrain clearance for the rover scoring pipeline.
For each (rover cell, fixed station) pair, samples elevation along the
great-circle path between them and reports how far the rover sits
above the highest intermediate terrain. Positive clearance means the
rover should clear trees/buildings between it and the station; negative
clearance means the path is blocked by an intervening ridge.
"""
alias Microwaveprop.Rover.Elevation
# Number of intermediate samples between rover and station.
@sample_count 12
@type latlon :: {float(), float()}
@doc """
Returns `%{ {rover_pt, station_pt} => clearance_m | nil }`.
`clearance_m` is `rover_elev - max(intermediate_path_elev)`. `nil` when
the rover cell or the entire intermediate path lacks SRTM coverage.
"""
@spec clearance_map([map()], [map()], keyword()) :: %{{latlon(), latlon()} => integer() | nil}
def clearance_map(cells, stations, opts \\ []) when is_list(cells) and is_list(stations) do
elev_lookup = Keyword.get(opts, :elev_lookup, &Elevation.lookup_many/1)
pairs =
for cell <- cells, station <- stations do
{{cell.lat, cell.lon}, {station.lat, station.lon}}
end
sample_points =
pairs
|> Enum.flat_map(fn {a, b} -> intermediate_samples(a, b) end)
|> Enum.uniq()
rover_points = Enum.map(cells, fn c -> {c.lat, c.lon} end)
elev = elev_lookup.(rover_points ++ sample_points)
Map.new(pairs, fn {rover_pt, station_pt} ->
{{rover_pt, station_pt}, clearance(rover_pt, station_pt, elev)}
end)
end
defp clearance(rover_pt, station_pt, elev) do
rover_elev = Map.get(elev, rover_pt)
case rover_elev do
nil ->
nil
_ ->
path_max =
rover_pt
|> intermediate_samples(station_pt)
|> Enum.map(&Map.get(elev, &1))
|> Enum.reject(&is_nil/1)
|> safe_max()
if path_max, do: rover_elev - path_max
end
end
defp intermediate_samples({lat1, lon1}, {lat2, lon2}) do
# Linear interpolation in lat/lon — fine at the distances we work with
# (≤200 mi). Skip endpoints (i=0 is the rover, i=N+1 is the station).
for i <- 1..@sample_count do
f = i / (@sample_count + 1)
{lat1 + f * (lat2 - lat1), lon1 + f * (lon2 - lon1)}
end
end
defp safe_max([]), do: nil
defp safe_max(xs), do: Enum.max(xs)
end

24
mix.exs
View file

@ -124,16 +124,38 @@ defmodule Microwaveprop.MixProject do
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["compile", "tailwind microwaveprop", "esbuild microwaveprop"],
"assets.build": [
"compile",
"tailwind microwaveprop",
"esbuild microwaveprop",
&copy_leaflet_images/1
],
"assets.deploy": [
"tailwind microwaveprop --minify",
"esbuild microwaveprop --minify",
&copy_leaflet_images/1,
"phx.digest"
],
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
]
end
# Mirrors `assets/vendor/leaflet/images/` into `priv/static/assets/css/images/`
# so Leaflet's CSS (which references `images/layers.png` etc. relative to the
# bundled stylesheet) can resolve those URLs in production. `priv/static/`
# is gitignored, so this needs to run on every build/deploy.
defp copy_leaflet_images(_args) do
src = "assets/vendor/leaflet/images"
dest = "priv/static/assets/css/images"
if File.dir?(src) do
File.mkdir_p!(dest)
File.cp_r!(src, dest)
end
:ok
end
# Applies every patch file in `priv/dep_patches/*.patch` to its corresponding
# dependency under `deps/`. Inlined here (instead of a `Mix.Task` module
# under `lib/mix/tasks/`) so it can run during `mix deps.get` itself —

View file

@ -21,6 +21,7 @@ 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
args = %{
home: home,
@ -32,7 +33,12 @@ defmodule Microwaveprop.Rover.ComputeTest do
min_elev_gain: 0
}
result = Compute.run(args, scores_at: scores_at, elev_lookup: elev_lookup)
result =
Compute.run(args,
scores_at: scores_at,
elev_lookup: elev_lookup,
clearance_lookup: clearance_lookup
)
assert is_map(result)
assert Map.has_key?(result, :cells)
@ -78,6 +84,7 @@ defmodule Microwaveprop.Rover.ComputeTest do
# All elevations below home — nothing should pass min_elev_gain=200.
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
result =
Compute.run(
@ -91,7 +98,8 @@ defmodule Microwaveprop.Rover.ComputeTest do
min_elev_gain: 200
},
scores_at: scores_at,
elev_lookup: elev_lookup
elev_lookup: elev_lookup,
clearance_lookup: clearance_lookup
)
assert result.cells == []

View file

@ -0,0 +1,58 @@
defmodule Microwaveprop.Rover.PathTerrainTest do
use ExUnit.Case, async: true
alias Microwaveprop.Rover.PathTerrain
describe "clearance_map/2" do
test "returns positive clearance when rover sits well above intermediate terrain" do
cells = [%{lat: 33.0, lon: -96.0}]
stations = [%{lat: 33.5, lon: -96.5}]
# Rover at 800 m, intermediate path tops out at 200 m → 600 m clearance.
lookup = fn _pts ->
%{}
|> Map.put({33.0, -96.0}, 800)
|> Map.merge(intermediate_elevs(cells, stations, 200))
end
result = PathTerrain.clearance_map(cells, stations, elev_lookup: lookup)
assert result[{{33.0, -96.0}, {33.5, -96.5}}] == 600
end
test "returns negative clearance when rover is below path obstruction" do
cells = [%{lat: 33.0, lon: -96.0}]
stations = [%{lat: 33.5, lon: -96.5}]
lookup = fn _pts ->
%{}
|> Map.put({33.0, -96.0}, 100)
|> Map.merge(intermediate_elevs(cells, stations, 500))
end
result = PathTerrain.clearance_map(cells, stations, elev_lookup: lookup)
assert result[{{33.0, -96.0}, {33.5, -96.5}}] == -400
end
test "returns nil when rover cell has no SRTM coverage" do
cells = [%{lat: 33.0, lon: -96.0}]
stations = [%{lat: 33.5, lon: -96.5}]
lookup = fn _pts -> %{} end
result = PathTerrain.clearance_map(cells, stations, elev_lookup: lookup)
assert result[{{33.0, -96.0}, {33.5, -96.5}}] == nil
end
end
defp intermediate_elevs(cells, stations, elev) do
for cell <- cells, station <- stations, i <- 1..12, into: %{} do
f = i / 13
lat = cell.lat + f * (station.lat - cell.lat)
lon = cell.lon + f * (station.lon - cell.lon)
{{lat, lon}, elev}
end
end
end