prop/lib/microwaveprop/rover/candidate_detail.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

127 lines
3.8 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
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)
end