prop/lib/microwaveprop/rover/path_terrain.ex
Graham McIntire 40cbb40cb1
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.
2026-04-25 17:29:29 -05:00

77 lines
2.4 KiB
Elixir

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