prop/lib/microwaveprop/rover/compute.ex
Graham McIntire eac817cd57
feat(rover): prefer broad hilltops near roads as ideal candidates
- Local prominence: each cell's elevation vs. its 8-neighbor ring
  (~1.3 km radius) feeds a small additive bonus so broad hilltops
  rank above isolated SRTM voxels
- Road proximity: one Overpass call per Calculate fetches drivable
  ways inside the bbox; cells beyond ~0.5 km of any road get a
  light dB penalty, gated off in tests
2026-04-25 17:45:48 -05:00

279 lines
8.6 KiB
Elixir

defmodule Microwaveprop.Rover.Compute do
@moduledoc """
End-to-end Calculate pipeline for the rover planner.
Given a home QTH, a list of selected fixed stations, a band/time/mode,
and drive/elevation constraints, returns the per-cell quality scores
plus the top 5 candidate parking spots.
"""
alias Microwaveprop.Propagation
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Rover.Aggregator
alias Microwaveprop.Rover.DriveTime
alias Microwaveprop.Rover.Elevation
alias Microwaveprop.Rover.LinkMargin
alias Microwaveprop.Rover.PathTerrain
alias Microwaveprop.Rover.Prominence
alias Microwaveprop.Rover.RoadProximity
@avg_speed_kmh 65.0
@drive_penalty_db_per_hour 2.0
@top_n 5
# Per-link terrain advantage (dB) is `clearance_m / @clearance_m_per_db`,
# clamped to [-@clearance_cap_db, +@clearance_cap_db]. ~30 m matches a
# mature tree canopy so a rover one canopy-height above the worst
# mid-path terrain earns +1 dB.
@clearance_m_per_db 30.0
@clearance_cap_db 10.0
# Per-cell prominence bonus (broad-hilltop preference). Capped low
# so it only acts as a tiebreaker between cells with similar link
# margins — clearance to stations is the primary signal.
@prominence_m_per_db 30.0
@prominence_cap_db 4.0
# Per-cell road-proximity penalty: cells inside @road_free_km of a road
# take no penalty, then 0.5 dB / km up to @road_penalty_cap_db. Cells
# far from any road are still surfaced (just down-ranked) so wilderness
# spots aren't impossible to find.
@road_free_km 0.5
@road_penalty_db_per_km 0.5
@road_penalty_cap_db 6.0
@tier_excellent 10.0
@tier_good 3.0
@tier_marginal 0.0
@color_excellent "#16a34a"
@color_good "#eab308"
@color_marginal "#f97316"
@type run_args :: %{
home: %{lat: float(), lon: float(), elev_m: integer() | nil},
stations: [%{callsign: String.t(), lat: float(), lon: float(), selected: boolean()}],
band_mhz: non_neg_integer(),
valid_time: DateTime.t(),
mode: atom(),
max_distance_km: float(),
min_elev_gain: integer()
}
@spec run(run_args(), keyword()) :: %{
cells: [map()],
top_candidates: [map()],
warnings: [String.t()]
}
def run(args, deps \\ []) do
scores_at = Keyword.get(deps, :scores_at, &Propagation.scores_at/3)
elev_lookup = Keyword.get(deps, :elev_lookup, &Elevation.lookup_many/1)
clearance_lookup = Keyword.get(deps, :clearance_lookup, &PathTerrain.clearance_map/2)
prominence_lookup = Keyword.get(deps, :prominence_lookup, &Prominence.prominence_map/1)
road_lookup = Keyword.get(deps, :road_lookup, &RoadProximity.road_distances/2)
%{
home: home,
stations: stations,
band_mhz: band_mhz,
valid_time: valid_time,
mode: mode,
max_distance_km: max_distance_km,
min_elev_gain: min_elev_gain
} = args
selected_stations = Enum.filter(stations, & &1.selected)
radius_km = max_distance_km * 1.0
bbox = bbox_around(home, radius_km)
raw_cells = scores_at.(band_mhz, valid_time, bbox)
# First filter: drive radius
in_radius =
Enum.filter(raw_cells, fn cell ->
DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon}) <= radius_km
end)
# Bulk SRTM lookup for cell centers, then per-link path-terrain clearance.
points = Enum.map(in_radius, &{&1.lat, &1.lon})
elev_map = elev_lookup.(points)
clearance_map = clearance_lookup.(in_radius, selected_stations)
prominence_map = prominence_lookup.(in_radius)
road_map =
if Application.get_env(:microwaveprop, :rover_road_proximity_enabled, true),
do: fetch_road_map(road_lookup, in_radius, bbox),
else: %{}
home_elev = home.elev_m || 0
cells =
in_radius
|> Enum.map(fn cell ->
annotate_cell(
cell,
elev_map,
clearance_map,
prominence_map,
road_map,
home,
mode,
selected_stations
)
end)
|> Enum.filter(&keep_cell?(&1, home_elev, min_elev_gain))
top_candidates =
cells
|> Enum.sort_by(& &1.score, :desc)
|> Enum.take(@top_n)
|> Enum.map(&candidate_payload(&1, home))
warnings = build_warnings(raw_cells, in_radius, elev_map, cells)
%{cells: cells, top_candidates: top_candidates, warnings: warnings}
end
defp build_warnings(raw_cells, in_radius, elev_map, cells) do
[]
|> add_warning_if(raw_cells == [], "No HRRR score grid available for this band/time.")
|> add_warning_if(in_radius == [], "No score grid cells within the drive radius.")
|> add_warning_if(
in_radius != [] and elev_map_all_nil?(elev_map),
"Elevation tiles unavailable (SRTM not mounted); ranking ignores elevation."
)
|> add_warning_if(
in_radius != [] and cells == [],
"All cells filtered out by score/elevation thresholds."
)
end
defp add_warning_if(list, true, msg), do: list ++ [msg]
defp add_warning_if(list, false, _msg), do: list
defp elev_map_all_nil?(elev_map) do
elev_map != %{} and Enum.all?(elev_map, fn {_k, v} -> is_nil(v) end)
end
defp keep_cell?(nil, _home_elev, _min_gain), do: false
defp keep_cell?(%{score: score, elev_m: elev_m}, home_elev, min_gain) do
# Cells with unknown elevation (no SRTM tile) are kept; elev gain
# filter only applies when we actually know the cell elevation.
elev_ok? = is_nil(elev_m) or elev_m - home_elev >= min_gain
elev_ok? and score >= 0
end
defp keep_cell?(_, _, _), do: false
defp annotate_cell(cell, elev_map, clearance_map, prominence_map, road_map, home, mode, stations) do
elev_m = Map.get(elev_map, {cell.lat, cell.lon})
prominence_m = Map.get(prominence_map, {cell.lat, cell.lon})
road_km = Map.get(road_map, {cell.lat, cell.lon})
base_margin = LinkMargin.link_margin_from_score(cell.score, mode)
margins =
Enum.map(stations, fn s ->
clearance_db =
terrain_db(Map.get(clearance_map, {{cell.lat, cell.lon}, {s.lat, s.lon}}))
base_margin + clearance_db
end)
case Aggregator.cell_margin_db(margins) do
nil ->
nil
agg_db ->
dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon})
score =
agg_db + prominence_db(prominence_m) - drive_penalty(dist_km) - road_penalty_db(road_km)
%{
lat: cell.lat,
lon: cell.lon,
elev_m: elev_m,
prominence_m: prominence_m,
road_km: road_km,
score: score,
distance_km: dist_km,
tier_color: tier_color(score)
}
end
end
defp fetch_road_map(road_lookup, cells, bbox) do
case road_lookup.(cells, bbox) do
{:ok, map} -> map
{:error, _} -> %{}
end
end
defp road_penalty_db(nil), do: 0.0
defp road_penalty_db(km) when is_number(km) do
excess = max(km - @road_free_km, 0.0)
db = excess * @road_penalty_db_per_km
min(db, @road_penalty_cap_db)
end
defp terrain_db(nil), do: 0.0
defp terrain_db(clearance_m) do
db = clearance_m / @clearance_m_per_db
db |> max(-@clearance_cap_db) |> min(@clearance_cap_db)
end
defp prominence_db(nil), do: 0.0
defp prominence_db(prominence_m) do
db = prominence_m / @prominence_m_per_db
db |> max(-@prominence_cap_db) |> min(@prominence_cap_db)
end
defp candidate_payload(cell, home) do
drive_min = DriveTime.drive_min(cell.distance_km)
bearing = DriveTime.bearing_compass({home.lat, home.lon}, {cell.lat, cell.lon})
grid = Maidenhead.from_latlon(cell.lat, cell.lon, 10)
%{
grid: grid,
lat: cell.lat,
lon: cell.lon,
elev_m: cell.elev_m,
prominence_m: cell.prominence_m,
road_km: cell.road_km,
drive_min: drive_min,
score: cell.score,
tier_color: cell.tier_color,
distance_km: cell.distance_km,
bearing_compass: bearing,
name: "#{grid}#{round(cell.distance_km)} km #{bearing} of home"
}
end
defp drive_penalty(dist_km), do: dist_km / @avg_speed_kmh * @drive_penalty_db_per_hour
defp tier_color(score) do
cond do
score >= @tier_excellent -> @color_excellent
score >= @tier_good -> @color_good
score >= @tier_marginal -> @color_marginal
true -> @color_marginal
end
end
# Approximate bounding box. 1 deg lat ≈ 111 km; 1 deg lon ≈ 111·cos(lat) km.
defp bbox_around(%{lat: lat, lon: lon}, radius_km) do
dlat = radius_km / 111.0
dlon = radius_km / (111.0 * max(:math.cos(lat * :math.pi() / 180.0), 0.1))
%{
"south" => lat - dlat,
"north" => lat + dlat,
"west" => lon - dlon,
"east" => lon + dlon
}
end
end