From 2e462b06976fb790657923ca4635abab62503185 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 16:15:31 -0500 Subject: [PATCH] feat(rover): end-to-end Calculate pipeline --- lib/microwaveprop/rover/compute.ex | 172 ++++++++++++++++++++++ test/microwaveprop/rover/compute_test.exs | 100 +++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 lib/microwaveprop/rover/compute.ex create mode 100644 test/microwaveprop/rover/compute_test.exs diff --git a/lib/microwaveprop/rover/compute.ex b/lib/microwaveprop/rover/compute.ex new file mode 100644 index 00000000..84f39bd2 --- /dev/null +++ b/lib/microwaveprop/rover/compute.ex @@ -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 diff --git a/test/microwaveprop/rover/compute_test.exs b/test/microwaveprop/rover/compute_test.exs new file mode 100644 index 00000000..bfff6cab --- /dev/null +++ b/test/microwaveprop/rover/compute_test.exs @@ -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