prop/lib/microwaveprop/rover/compute.ex
Graham McIntire 40cbb40cb1
feat(rover): score per-station terrain clearance from SRTM path samples
For each (rover cell, fixed station) pair, sample SRTM along the
great-circle path and add a per-link dB bonus proportional to the
rover's elevation above the highest intermediate terrain. Rover spots
on hilltops with clear sight to multiple stations now rise to the top.

Also vendor Leaflet's layers control PNGs and copy them under
priv/static/assets/css/images/ on build so /rover stops 404'ing on
layers-2x.png.
2026-04-25 17:29:29 -05:00

215 lines
6.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
@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
@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)
%{
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)
home_elev = home.elev_m || 0
cells =
in_radius
|> Enum.map(fn cell ->
annotate_cell(cell, elev_map, clearance_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, home, mode, stations) do
elev_m = Map.get(elev_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 - drive_penalty(dist_km)
%{
lat: cell.lat,
lon: cell.lon,
elev_m: elev_m,
score: score,
distance_km: dist_km,
tier_color: tier_color(score)
}
end
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 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,
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