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.
423 lines
14 KiB
Elixir
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
|