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) |> Enum.max(fn -> nil end) 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 {:ok, points} = Srtm.fetch_elevation_profile( candidate.lat, candidate.lon, station.lat, station.lon, tiles_dir, @sample_count ) 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 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) end