Renames the rover-location status enum and every label that referenced it. Existing rows are migrated in place. Also touches the consumers: - Rover.Location enum + default - RoverLocationsLive index, status filter, form, show, badges - Rover.Compute and rover_live (only_ideal_locations → only_good_locations) - RoverPlanning context candidate filter (status == :good) - RoverPlanning.Show / Form copy - All rover-related tests
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)
|
|
|
|
good_locations = Map.get(args, :good_locations, nil)
|
|
|
|
candidate_cells =
|
|
case good_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 good 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
|