Adds Microwaveprop.Canopy module that reads 1° × 1° uint8 per-degree
canopy-height tiles (mirrors SRTM .hgt naming/layout, 30 m resolution).
Returns 0 m for areas with no tile on disk so the lookup is safe to
thread through the existing path-clearance pipeline before any data
lands.
Wires canopy into:
- PathTerrain.obstacle_top: per-sample blocker = ground +
max(building_m, canopy_m), so the rover ranks paths through forests
as obstructed even when no buildings sit on the line
- CandidateDetail profile: each sample now carries canopy_m alongside
building_m
- Rover-detail SVG: green "trees" polygon stacked on terrain, under
the red building polygon
- Path calculator elevation chart: green Tree Canopy dataset between
terrain and buildings
Data prep (download Potapov 2019 / Lang 2022 GeoTIFF, slice to per-degree
uint8 tiles, drop in /data/canopy/) is a separate one-shot operation —
without tiles, lookups return 0 and the existing behavior is unchanged.
107 lines
3.5 KiB
Elixir
107 lines
3.5 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.Buildings.Index, as: BuildingsIndex
|
|
alias Microwaveprop.Canopy
|
|
alias Microwaveprop.Rover.Elevation
|
|
|
|
# Number of intermediate samples between rover and station.
|
|
@sample_count 12
|
|
|
|
# Search radius around each path sample point when looking for
|
|
# buildings that obstruct the line of sight. ~150 m catches the
|
|
# typical width of a downtown high-rise plus a margin for building
|
|
# centroid imprecision.
|
|
@building_search_radius_m 150
|
|
|
|
@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)
|
|
buildings_lookup = Keyword.get(opts, :buildings_lookup, &default_building_height/1)
|
|
canopy_lookup = Keyword.get(opts, :canopy_lookup, &default_canopy_height/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, buildings_lookup, canopy_lookup)}
|
|
end)
|
|
end
|
|
|
|
defp clearance(rover_pt, station_pt, elev, buildings_lookup, canopy_lookup) do
|
|
rover_elev = Map.get(elev, rover_pt)
|
|
|
|
case rover_elev do
|
|
nil ->
|
|
nil
|
|
|
|
_ ->
|
|
path_max =
|
|
rover_pt
|
|
|> intermediate_samples(station_pt)
|
|
|> Enum.map(&obstacle_top(&1, elev, buildings_lookup, canopy_lookup))
|
|
|> Enum.reject(&is_nil/1)
|
|
|> safe_max()
|
|
|
|
if path_max, do: rover_elev - path_max
|
|
end
|
|
end
|
|
|
|
defp obstacle_top({lat, lon} = pt, elev, buildings_lookup, canopy_lookup) do
|
|
case Map.get(elev, pt) do
|
|
nil ->
|
|
nil
|
|
|
|
ground ->
|
|
building_m = buildings_lookup.({lat, lon})
|
|
canopy_m = canopy_lookup.({lat, lon})
|
|
ground + round(max(building_m, canopy_m))
|
|
end
|
|
end
|
|
|
|
defp default_building_height({lat, lon}) do
|
|
BuildingsIndex.max_height_near(lat, lon, @building_search_radius_m)
|
|
end
|
|
|
|
defp default_canopy_height({lat, lon}) do
|
|
Canopy.lookup(lat, lon)
|
|
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
|