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) ideal_locations = Map.get(args, :ideal_locations, nil) candidate_cells = case ideal_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 ideal 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