feat(rover): end-to-end Calculate pipeline
This commit is contained in:
parent
d80ca24e2e
commit
2e462b0697
2 changed files with 272 additions and 0 deletions
172
lib/microwaveprop/rover/compute.ex
Normal file
172
lib/microwaveprop/rover/compute.ex
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
@avg_speed_kmh 65.0
|
||||||
|
@drive_penalty_db_per_hour 2.0
|
||||||
|
@elev_bonus_db_per_100m 1.0
|
||||||
|
@elev_bonus_cap_db 5.0
|
||||||
|
@top_n 5
|
||||||
|
|
||||||
|
@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_drive_min: integer(),
|
||||||
|
min_elev_gain: integer()
|
||||||
|
}
|
||||||
|
|
||||||
|
@spec run(run_args(), keyword()) :: %{cells: [map()], top_candidates: [map()]}
|
||||||
|
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)
|
||||||
|
|
||||||
|
%{
|
||||||
|
home: home,
|
||||||
|
stations: stations,
|
||||||
|
band_mhz: band_mhz,
|
||||||
|
valid_time: valid_time,
|
||||||
|
mode: mode,
|
||||||
|
max_drive_min: max_drive_min,
|
||||||
|
min_elev_gain: min_elev_gain
|
||||||
|
} = args
|
||||||
|
|
||||||
|
selected_stations = Enum.filter(stations, & &1.selected)
|
||||||
|
radius_km = max_drive_min * @avg_speed_kmh / 60.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 elevation lookup for surviving cells
|
||||||
|
points = Enum.map(in_radius, &{&1.lat, &1.lon})
|
||||||
|
elev_map = elev_lookup.(points)
|
||||||
|
home_elev = home.elev_m || 0
|
||||||
|
|
||||||
|
cells =
|
||||||
|
in_radius
|
||||||
|
|> Enum.map(fn cell -> annotate_cell(cell, elev_map, home, home_elev, 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))
|
||||||
|
|
||||||
|
%{cells: cells, top_candidates: top_candidates}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp keep_cell?(nil, _home_elev, _min_gain), do: false
|
||||||
|
|
||||||
|
defp keep_cell?(%{elev_m: elev_m, score: score}, home_elev, min_gain) when is_integer(elev_m) do
|
||||||
|
elev_m - home_elev >= min_gain and score >= 0
|
||||||
|
end
|
||||||
|
|
||||||
|
defp keep_cell?(_, _, _), do: false
|
||||||
|
|
||||||
|
defp annotate_cell(cell, elev_map, home, home_elev, mode, stations) do
|
||||||
|
elev_m = Map.get(elev_map, {cell.lat, cell.lon})
|
||||||
|
|
||||||
|
case elev_m do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
margins = Enum.map(stations, fn _s -> LinkMargin.link_margin_from_score(cell.score, mode) end)
|
||||||
|
agg = Aggregator.cell_margin_db(margins)
|
||||||
|
|
||||||
|
case agg do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
agg_db ->
|
||||||
|
dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon})
|
||||||
|
elev_bonus = elev_bonus(elev_m, home_elev)
|
||||||
|
drive_penalty = drive_penalty(dist_km)
|
||||||
|
score = agg_db + elev_bonus - drive_penalty
|
||||||
|
|
||||||
|
%{
|
||||||
|
lat: cell.lat,
|
||||||
|
lon: cell.lon,
|
||||||
|
elev_m: elev_m,
|
||||||
|
score: score,
|
||||||
|
distance_km: dist_km,
|
||||||
|
tier_color: tier_color(score)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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, 6)
|
||||||
|
|
||||||
|
%{
|
||||||
|
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 elev_bonus(elev_c, elev_home) do
|
||||||
|
diff = (elev_c - elev_home) / 100.0 * @elev_bonus_db_per_100m
|
||||||
|
diff |> max(0.0) |> min(@elev_bonus_cap_db)
|
||||||
|
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
|
||||||
100
test/microwaveprop/rover/compute_test.exs
Normal file
100
test/microwaveprop/rover/compute_test.exs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
defmodule Microwaveprop.Rover.ComputeTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.Rover.Compute
|
||||||
|
|
||||||
|
defp build_grid(home_lat, home_lon) do
|
||||||
|
# 3x3 grid of cells around home spaced 0.1 deg apart
|
||||||
|
for dlat <- [-0.1, 0.0, 0.1], dlon <- [-0.1, 0.0, 0.1] do
|
||||||
|
%{
|
||||||
|
lat: Float.round(home_lat + dlat, 4),
|
||||||
|
lon: Float.round(home_lon + dlon, 4),
|
||||||
|
score: 80
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns top_candidates ordered by score desc with required fields" do
|
||||||
|
home = %{lat: 33.0, lon: -96.0, elev_m: 200}
|
||||||
|
stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}]
|
||||||
|
grid = build_grid(home.lat, home.lon)
|
||||||
|
|
||||||
|
scores_at = fn _band, _t, _bbox -> grid end
|
||||||
|
elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end
|
||||||
|
|
||||||
|
args = %{
|
||||||
|
home: home,
|
||||||
|
stations: stations,
|
||||||
|
band_mhz: 10_000,
|
||||||
|
valid_time: ~U[2026-04-25 12:00:00Z],
|
||||||
|
mode: :ssb,
|
||||||
|
max_drive_min: 60,
|
||||||
|
min_elev_gain: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Compute.run(args, scores_at: scores_at, elev_lookup: elev_lookup)
|
||||||
|
|
||||||
|
assert is_map(result)
|
||||||
|
assert Map.has_key?(result, :cells)
|
||||||
|
assert Map.has_key?(result, :top_candidates)
|
||||||
|
|
||||||
|
assert is_list(result.cells)
|
||||||
|
assert length(result.top_candidates) <= 5
|
||||||
|
assert length(result.top_candidates) > 0
|
||||||
|
|
||||||
|
# Sorted by score desc.
|
||||||
|
scores = Enum.map(result.top_candidates, & &1.score)
|
||||||
|
assert scores == Enum.sort(scores, :desc)
|
||||||
|
|
||||||
|
# Required candidate fields.
|
||||||
|
cand = hd(result.top_candidates)
|
||||||
|
|
||||||
|
for key <- [
|
||||||
|
:grid,
|
||||||
|
:lat,
|
||||||
|
:lon,
|
||||||
|
:elev_m,
|
||||||
|
:drive_min,
|
||||||
|
:score,
|
||||||
|
:tier_color,
|
||||||
|
:distance_km,
|
||||||
|
:bearing_compass,
|
||||||
|
:name
|
||||||
|
] do
|
||||||
|
assert Map.has_key?(cand, key), "expected candidate to have key #{inspect(key)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
assert is_binary(cand.grid)
|
||||||
|
assert is_binary(cand.tier_color)
|
||||||
|
assert is_binary(cand.bearing_compass)
|
||||||
|
assert is_binary(cand.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filters cells failing min_elev_gain" do
|
||||||
|
home = %{lat: 33.0, lon: -96.0, elev_m: 500}
|
||||||
|
stations = [%{callsign: "W5LUA", lat: 33.1, lon: -96.6, selected: true}]
|
||||||
|
grid = build_grid(home.lat, home.lon)
|
||||||
|
|
||||||
|
# All elevations below home — nothing should pass min_elev_gain=200.
|
||||||
|
scores_at = fn _band, _t, _bbox -> grid end
|
||||||
|
elev_lookup = fn points -> Map.new(points, fn p -> {p, 100} end) end
|
||||||
|
|
||||||
|
result =
|
||||||
|
Compute.run(
|
||||||
|
%{
|
||||||
|
home: home,
|
||||||
|
stations: stations,
|
||||||
|
band_mhz: 10_000,
|
||||||
|
valid_time: ~U[2026-04-25 12:00:00Z],
|
||||||
|
mode: :ssb,
|
||||||
|
max_drive_min: 60,
|
||||||
|
min_elev_gain: 200
|
||||||
|
},
|
||||||
|
scores_at: scores_at,
|
||||||
|
elev_lookup: elev_lookup
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.cells == []
|
||||||
|
assert result.top_candidates == []
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue