From 488b968cdc10bf17b5f486a58703e689077fbde5 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 26 Apr 2026 13:25:27 -0500 Subject: [PATCH] feat(rover): "only use known ideal locations" toggle Adds a checkbox in the rover sidebar that constrains the suggested candidate spots to lat/lon points tagged as :ideal in /rover-locations. When enabled, Compute.run replaces grid cells with synthetic cells at each ideal location's exact coordinates, inheriting the propagation score from the nearest grid cell so atmospheric forecast still factors in. Path-clearance, terrain, building, and canopy penalties run unchanged on those snapped candidates. --- lib/microwaveprop/rover/compute.ex | 32 ++++++++++++++++++- lib/microwaveprop_web/live/rover_live.ex | 39 +++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/lib/microwaveprop/rover/compute.ex b/lib/microwaveprop/rover/compute.ex index c420bb0d..eeddd0f4 100644 --- a/lib/microwaveprop/rover/compute.ex +++ b/lib/microwaveprop/rover/compute.ex @@ -131,8 +131,17 @@ defmodule Microwaveprop.Rover.Compute do 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(raw_cells, fn cell -> + Enum.filter(candidate_cells, fn cell -> DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon}) <= radius_km end) @@ -325,6 +334,27 @@ defmodule Microwaveprop.Rover.Compute do |> 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 diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index 7bc07428..ff4aa5af 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -67,6 +67,7 @@ defmodule MicrowavepropWeb.RoverLive do home_form_error: nil, scoring_loading: false, scoring_step: nil, + only_ideal_locations: false, url_loaded?: false )} end @@ -193,6 +194,10 @@ defmodule MicrowavepropWeb.RoverLive do |> push_event("update_drive_radius", %{km: radius_km})} end + def handle_event("toggle_only_ideal", _params, socket) do + {:noreply, assign(socket, only_ideal_locations: not socket.assigns.only_ideal_locations)} + end + def handle_event("toggle_station", %{"id" => id}, socket) do handle_station_mutation(socket, id, &toggle_station/2) end @@ -713,7 +718,7 @@ defmodule MicrowavepropWeb.RoverLive do end defp scoring_args(socket) do - %{ + base = %{ home: socket.assigns.home, stations: stations_for_compute(socket.assigns.fixed_stations), band_mhz: socket.assigns.band, @@ -722,6 +727,17 @@ defmodule MicrowavepropWeb.RoverLive do max_distance_km: socket.assigns.drive_radius_km, min_elev_gain: 0 } + + if socket.assigns.only_ideal_locations do + ideals = + Rover.list_locations() + |> Enum.filter(&(&1.status == :ideal)) + |> Enum.map(fn l -> %{lat: l.lat, lon: l.lon} end) + + Map.put(base, :ideal_locations, ideals) + else + base + end end defp find_candidate(candidates, grid) do @@ -1163,6 +1179,27 @@ defmodule MicrowavepropWeb.RoverLive do + <.sidebar_section title="CANDIDATES"> + + +
Tip: click any cell to see per-station predicted SNR.