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, 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 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