Compute.run now accepts a `progress` callback and reports human-readable labels before each pipeline step (loading grid, looking up elevation, checking road access, scoring cells, etc.). RoverLive feeds it via send/2 to itself and renders the latest label as small text to the left of the Calculate button while it's running.
350 lines
11 KiB
Elixir
350 lines
11 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.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
|
|
|
|
@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)
|
|
|
|
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)
|
|
|
|
progress.("Loading propagation grid")
|
|
raw_cells = time_step("scores_at", fn -> scores_at.(band_mhz, valid_time, bbox) end)
|
|
|
|
in_radius =
|
|
Enum.filter(raw_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})
|
|
progress.("Looking up elevation")
|
|
elev_map = time_step("elev_lookup", fn -> elev_lookup.(points) end)
|
|
progress.("Loading building footprints")
|
|
_ = time_step("buildings_load", fn -> BuildingsLoader.ensure_loaded_for_bbox(bbox) end)
|
|
progress.("Computing path clearance")
|
|
clearance_map = time_step("clearance", fn -> clearance_lookup.(in_radius, selected_stations) end)
|
|
progress.("Measuring terrain prominence")
|
|
prominence_map = time_step("prominence", fn -> prominence_lookup.(in_radius) end)
|
|
|
|
road_map =
|
|
if Application.get_env(:microwaveprop, :rover_road_proximity_enabled, true) do
|
|
progress.("Checking road access")
|
|
time_step("road_proximity", fn -> fetch_road_map(road_lookup, in_radius, bbox) end)
|
|
else
|
|
%{}
|
|
end
|
|
|
|
progress.("Scanning building clutter")
|
|
clutter_map = time_step("building_clutter", fn -> buildings_clutter_lookup.(in_radius) end)
|
|
progress.("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
|
|
}
|
|
|
|
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
|
|
} = 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})
|
|
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)
|
|
|
|
%{
|
|
lat: cell.lat,
|
|
lon: cell.lon,
|
|
elev_m: elev_m,
|
|
prominence_m: prominence_m,
|
|
road_km: road_km,
|
|
building_height_m: building_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 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
|