prop/lib/microwaveprop/rover/path_terrain.ex
Graham McIntire 2defa3db7a
refactor: deduplicate helpers and simplify Scorer
- Unify haversine_km / deg_to_rad: Geo is the canonical home (atan2
  form for antipodal stability); Radio, common_volume, rain_scatter,
  ionosphere, terrain/srtm, terrain/elevation_client now delegate.
- Drop safe_min/safe_max wrappers in favor of Enum.min/max defaults.
- Move parse_float / parse_int to MicrowavepropWeb.LiveHelpers; eme,
  path, and rover LiveViews import from there.
- Extract MicrowavepropWeb.LocationResolver from the eme/path
  resolve_source / resolve_location duplicate. Standardize the kind
  key and "Invalid grid square" error message.
- Convert Scorer cond chains (humidity, td_depression, sky, wind,
  rain, pwat, pressure) to multi-clause guarded defs, matching the
  existing classify_time_period style.
- extract_profile_fields uses a named map accumulator instead of a
  6-tuple; build_path_conditions guards on %{temps: []} / %{dewpoints: []}.
- Collapse scores_file clamp/3 to max |> min.
- humidity_effect_label lives in BandConfig; map_live and path_live
  both use it.
2026-04-28 14:14:34 -05:00

104 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)
|> 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