prop/lib/microwaveprop/rover/candidate_detail.ex
Graham McIntire 4f647e7894
feat(canopy): add tree-canopy height layer for path obstruction analysis
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.
2026-04-26 12:51:48 -05:00

130 lines
3.9 KiB
Elixir

defmodule Microwaveprop.Rover.CandidateDetail do
@moduledoc """
Server-side summary for a rover candidate cell: per-link distance,
predicted SNR margin, terrain clearance, and the elevation profile
along the great-circle path to each selected fixed station.
"""
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Canopy
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Rover.DriveTime
alias Microwaveprop.Rover.LinkMargin
alias Microwaveprop.Terrain.Srtm
@sample_count 48
# Search radius around each profile sample when checking buildings.
# Slightly tighter than the path-clearance radius (150 m) because the
# profile is a narrower visualization — we want what's "on the line".
@profile_building_radius_m 80
@type station :: %{callsign: String.t(), lat: float(), lon: float()}
@type link_summary :: %{
callsign: String.t(),
distance_km: float(),
bearing: String.t(),
bearing_deg: float(),
margin_db: float() | nil,
rover_elev_m: integer() | nil,
station_elev_m: integer() | nil,
max_obstacle_m: integer() | nil,
clearance_m: integer() | nil,
profile: [%{dist_km: float(), elev: integer(), building_m: float(), canopy_m: integer()}]
}
@spec summarize(map(), [station()], non_neg_integer(), DateTime.t(), atom()) :: %{
grid: String.t(),
rover_elev_m: integer() | nil,
links: [link_summary()]
}
def summarize(candidate, stations, band_mhz, valid_time, mode) do
tiles_dir = Application.get_env(:microwaveprop, :srtm_tiles_dir, "/data/srtm")
rover_elev = lookup_elev(candidate.lat, candidate.lon, tiles_dir)
links =
Enum.map(stations, fn s ->
profile = profile_for(candidate, s, tiles_dir)
max_obstacle =
profile
|> middle_samples()
|> Enum.map(fn pt -> pt.elev + round(max(pt.building_m, pt.canopy_m)) end)
|> safe_max()
clearance =
if is_integer(rover_elev) and is_integer(max_obstacle),
do: rover_elev - max_obstacle
margin =
LinkMargin.link_margin_db(
band_mhz,
valid_time,
{candidate.lat, candidate.lon},
{s.lat, s.lon},
mode
)
from = {candidate.lat, candidate.lon}
to = {s.lat, s.lon}
%{
callsign: s.callsign,
distance_km: DriveTime.haversine_km(from, to),
bearing: DriveTime.bearing_compass(from, to),
bearing_deg: DriveTime.bearing_deg(from, to),
margin_db: margin,
rover_elev_m: rover_elev,
station_elev_m: profile |> List.last() |> Map.get(:elev),
max_obstacle_m: max_obstacle,
clearance_m: clearance,
profile: profile
}
end)
%{
grid: Maidenhead.from_latlon(candidate.lat, candidate.lon, 10),
rover_elev_m: rover_elev,
links: links
}
end
defp profile_for(candidate, station, tiles_dir) do
case Srtm.fetch_elevation_profile(
candidate.lat,
candidate.lon,
station.lat,
station.lon,
tiles_dir,
@sample_count
) do
{:ok, points} ->
Enum.map(points, fn pt ->
%{
dist_km: pt.dist_km,
elev: pt.elev,
building_m: BuildingsIndex.max_height_near(pt.lat, pt.lon, @profile_building_radius_m),
canopy_m: Canopy.lookup(pt.lat, pt.lon)
}
end)
_ ->
[]
end
end
defp lookup_elev(lat, lon, tiles_dir) do
case Srtm.lookup(lat, lon, tiles_dir) do
{:ok, e} -> e
_ -> nil
end
end
defp middle_samples([]), do: []
defp middle_samples([_]), do: []
defp middle_samples([_ | rest]), do: Enum.drop(rest, -1)
defp safe_max([]), do: nil
defp safe_max(xs), do: Enum.max(xs)
end