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.
215 lines
6.6 KiB
Elixir
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
|