- 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
124 lines
3.7 KiB
Elixir
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
|