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) |> Enum.max(fn -> nil end) 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 end