prop/lib/microwaveprop/rover/candidate_detail.ex
Graham McIntire 828814e767
fix: resolve all compiler warnings except framework-generated phoenix_component_verify
- Replace deprecated xref:exclude with elixirc_options in vendor/oban_pro
- Remove unused require Logger from 8 files
- Pin bitstring size variables with ^ in simple_packing, wgrib2,
  complex_packing, section, nexrad_client, mqtt, and radar worker
- Remove dead defp clauses (format/1 nil, depression/2 nil)
- Rename Buildings.Parser type record/0 -> building_record/0 to avoid
  overriding built-in type
- Remove redundant catch-all in candidate_detail.ex
- Simplify contact_live/show.ex conditional based on type narrowing
- Fix DateTime.from_iso8601 return pattern in vendor/oban_web
- Upgrade phoenix_live_view to 1.1.31
- Drop --warnings-as-errors from precommit alias; known false positives
  from @after_verify-generated code in Elixir 1.20.0-rc.6 + PLV 1.1.x
- Add credo --strict to precommit as replacement static-analysis gate
2026-05-29 10:18:52 -05:00

124 lines
3.7 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)
|> 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