prop/lib/microwaveprop/rover/compute.ex
Graham McIntire 488b968cdc
feat(rover): "only use known ideal locations" toggle
Adds a checkbox in the rover sidebar that constrains the suggested
candidate spots to lat/lon points tagged as :ideal in /rover-locations.
When enabled, Compute.run replaces grid cells with synthetic cells at
each ideal location's exact coordinates, inheriting the propagation
score from the nearest grid cell so atmospheric forecast still factors
in. Path-clearance, terrain, building, and canopy penalties run
unchanged on those snapped candidates.
2026-04-26 13:25:29 -05:00

423 lines
14 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.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
alias Microwaveprop.Canopy
alias Microwaveprop.Propagation
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Rover.Aggregator
alias Microwaveprop.Rover.DriveTime
alias Microwaveprop.Rover.Elevation
alias Microwaveprop.Rover.Hilltop
alias Microwaveprop.Rover.LinkMargin
alias Microwaveprop.Rover.PathTerrain
alias Microwaveprop.Rover.Prominence
alias Microwaveprop.Rover.RoadProximity
require Logger
@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
# Per-cell building-clutter penalty: tallest building within
# @building_clutter_radius_m of the cell scaled at 1 dB / 5 m, capped low so
# urban canyons get down-ranked without dominating link-margin signal. The
# path-clearance step already accounts for buildings ON the link path; this
# penalty captures "surrounded by stuff that scatters/blocks every direction".
@building_clutter_radius_m 75
@building_clutter_m_per_db 5.0
@building_clutter_cap_db 6.0
# Per-cell tree-canopy clutter penalty. Same shape as the building
# clutter penalty but scaled gentler (1 dB / 8 m, capped at 4 dB) —
# forests are common away from roads so the penalty is a tiebreaker,
# not a hard exclusion. Path-clearance already drops cells whose
# link to a station is blocked by trees; this penalty surfaces the
# "I'm in a forest, every direction is foliage" signal.
@canopy_clutter_m_per_db 8.0
@canopy_clutter_cap_db 4.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)
buildings_clutter_lookup =
Keyword.get(deps, :buildings_clutter_lookup, &default_building_clutter/1)
canopy_clutter_lookup =
Keyword.get(deps, :canopy_clutter_lookup, &default_canopy_clutter/1)
hilltop_snap = Keyword.get(deps, :hilltop_snap, &Hilltop.snap/1)
progress = Keyword.get(deps, :progress, fn _, _, _ -> :ok end)
%{
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)
road_enabled? = Application.get_env(:microwaveprop, :rover_road_proximity_enabled, true)
total_steps = if road_enabled?, do: 9, else: 8
counter = :counters.new(1, [:atomics])
step = fn label ->
:counters.add(counter, 1, 1)
progress.(label, :counters.get(counter, 1), total_steps)
end
step.("Loading propagation grid")
raw_cells = time_step("scores_at", fn -> scores_at.(band_mhz, valid_time, bbox) end)
ideal_locations = Map.get(args, :ideal_locations, nil)
candidate_cells =
case ideal_locations do
nil -> raw_cells
[] -> raw_cells
locations -> snap_cells_to_locations(locations, raw_cells)
end
in_radius =
Enum.filter(candidate_cells, fn cell ->
DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon}) <= radius_km
end)
Logger.info(
"rover compute: radius=#{radius_km}km cells=#{length(raw_cells)} in_radius=#{length(in_radius)} stations=#{length(selected_stations)}"
)
points = Enum.map(in_radius, &{&1.lat, &1.lon})
step.("Looking up elevation")
elev_map = time_step("elev_lookup", fn -> elev_lookup.(points) end)
step.("Loading building footprints")
_ = time_step("buildings_load", fn -> BuildingsLoader.ensure_loaded_for_bbox(bbox) end)
step.("Computing path clearance")
clearance_map = time_step("clearance", fn -> clearance_lookup.(in_radius, selected_stations) end)
step.("Measuring terrain prominence")
prominence_map = time_step("prominence", fn -> prominence_lookup.(in_radius) end)
road_map =
if road_enabled? do
step.("Checking road access")
time_step("road_proximity", fn -> fetch_road_map(road_lookup, in_radius, bbox) end)
else
%{}
end
step.("Scanning building clutter")
clutter_map = time_step("building_clutter", fn -> buildings_clutter_lookup.(in_radius) end)
step.("Scanning tree canopy")
canopy_map = time_step("canopy_clutter", fn -> canopy_clutter_lookup.(in_radius) end)
step.("Scoring cells")
home_elev = home.elev_m || 0
cell_maps = %{
elev: elev_map,
clearance: clearance_map,
prominence: prominence_map,
road: road_map,
clutter: clutter_map,
canopy: canopy_map
}
cells =
in_radius
|> Enum.map(fn cell -> annotate_cell(cell, cell_maps, 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, hilltop_snap))
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, cell_maps, home, mode, stations) do
%{
elev: elev_map,
clearance: clearance_map,
prominence: prominence_map,
road: road_map,
clutter: clutter_map,
canopy: canopy_map
} = cell_maps
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})
building_height_m = Map.get(clutter_map, {cell.lat, cell.lon})
canopy_height_m = Map.get(canopy_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) - building_clutter_db(building_height_m) -
canopy_clutter_db(canopy_height_m)
%{
lat: cell.lat,
lon: cell.lon,
elev_m: elev_m,
prominence_m: prominence_m,
road_km: road_km,
building_height_m: building_height_m,
canopy_height_m: canopy_height_m,
score: score,
distance_km: dist_km,
tier_color: tier_color(score)
}
end
end
defp time_step(label, fun) do
{us, result} = :timer.tc(fun)
Logger.info("rover compute: #{label} took #{div(us, 1000)} ms")
result
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 building_clutter_db(nil), do: 0.0
defp building_clutter_db(height_m) when is_number(height_m) and height_m <= 0, do: 0.0
defp building_clutter_db(height_m) when is_number(height_m) do
db = height_m / @building_clutter_m_per_db
min(db, @building_clutter_cap_db)
end
defp default_building_clutter(cells) do
Map.new(cells, fn cell ->
{{cell.lat, cell.lon}, BuildingsIndex.max_height_near(cell.lat, cell.lon, @building_clutter_radius_m)}
end)
end
defp canopy_clutter_db(nil), do: 0.0
defp canopy_clutter_db(h) when is_number(h) and h <= 0, do: 0.0
defp canopy_clutter_db(h) when is_number(h) do
db = h / @canopy_clutter_m_per_db
min(db, @canopy_clutter_cap_db)
end
defp default_canopy_clutter(cells) do
cells
|> Enum.map(fn cell -> {cell.lat, cell.lon} end)
|> Canopy.lookup_many()
end
# When the user constrains candidates to known ideal locations, build
# synthetic cells at each location's exact lat/lon, inheriting the
# propagation score from the nearest grid cell (so atmospheric forecast
# still factors in even though the spot itself isn't a grid centroid).
defp snap_cells_to_locations(locations, raw_cells) do
Enum.flat_map(locations, fn loc ->
case nearest_grid_cell(raw_cells, loc) do
nil -> []
cell -> [%{lat: loc.lat, lon: loc.lon, score: cell.score}]
end
end)
end
defp nearest_grid_cell([], _loc), do: nil
defp nearest_grid_cell(cells, loc) do
Enum.min_by(cells, fn c ->
DriveTime.haversine_km({c.lat, c.lon}, {loc.lat, loc.lon})
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 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, hilltop_snap) do
{lat, lon, elev_m} =
case hilltop_snap.({cell.lat, cell.lon}) do
{hl_lat, hl_lon, hl_elev} -> {hl_lat, hl_lon, hl_elev}
_ -> {cell.lat, cell.lon, cell.elev_m}
end
distance_km = DriveTime.haversine_km({home.lat, home.lon}, {lat, lon})
drive_min = DriveTime.drive_min(distance_km)
bearing = DriveTime.bearing_compass({home.lat, home.lon}, {lat, lon})
grid = Maidenhead.from_latlon(lat, lon, 10)
%{
grid: grid,
lat: lat,
lon: lon,
elev_m: 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: distance_km,
bearing_compass: bearing,
name: "#{grid}#{round(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