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.
This commit is contained in:
Graham McIntire 2026-04-26 13:25:27 -05:00
parent 5094ea0837
commit 488b968cdc
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 69 additions and 2 deletions

View file

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

View file

@ -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
</div>
</.sidebar_section>
<.sidebar_section title="CANDIDATES">
<label class="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={@only_ideal_locations}
phx-click="toggle_only_ideal"
class="checkbox checkbox-sm checkbox-primary mt-0.5"
/>
<span class="text-xs leading-tight">
Only use known ideal locations
<span class="block text-[10px] opacity-60 mt-0.5">
Restricts suggestions to spots tagged "ideal" in <a
href={~p"/rover-locations"}
class="link"
target="_blank"
>/rover-locations</a>.
</span>
</span>
</label>
</.sidebar_section>
<div class="mt-auto p-3 text-[11px] text-neutral-content/50 border-t border-base-300/50">
Tip: click any cell to see per-station predicted SNR.
</div>