Simplify rover planner: remove grid toggle and coverage calculation

This commit is contained in:
Graham McIntire 2026-04-08 08:57:01 -05:00
parent b705609329
commit 75fd05a6d1
5 changed files with 9 additions and 761 deletions

View file

@ -1,127 +0,0 @@
// Shared Maidenhead grid overlay for Leaflet maps
// Extracted from propagation_map_hook.js for reuse across map pages
export class GridSquare {
constructor(lat, lon) {
this.lat = lat
this.lon = ((lon % 360) + 540) % 360 - 180
}
encode(precision = 4) {
const adjLon = this.lon + 180
const adjLat = this.lat + 90
let g = ""
g += String.fromCharCode(65 + Math.floor(adjLon / 20))
g += String.fromCharCode(65 + Math.floor(adjLat / 10))
g += Math.floor((adjLon % 20) / 2)
g += Math.floor(adjLat % 10)
if (precision > 4) {
g += String.fromCharCode(97 + Math.floor(((adjLon % 2) * 60) / 5))
g += String.fromCharCode(97 + Math.floor(((adjLat % 1) * 60) / 2.5))
}
return g
}
static decode(grid) {
const f1 = grid.charCodeAt(0) - 65
const f2 = grid.charCodeAt(1) - 65
let west = f1 * 20 - 180
let south = f2 * 10 - 90
let w = 20, h = 10
if (grid.length >= 4) {
west += parseInt(grid[2]) * 2
south += parseInt(grid[3])
w = 2; h = 1
}
if (grid.length >= 6) {
west += (grid.charCodeAt(4) - 97) * 5 / 60
south += (grid.charCodeAt(5) - 97) * 2.5 / 60
w = 5 / 60; h = 2.5 / 60
}
return {
west, east: west + w, south, north: south + h,
center: { lat: south + h / 2, lon: west + w / 2 }
}
}
}
function drawGridSquare(grid, precision, bounds, zoom, layerGroup, drawnSet, map, pane) {
if (grid.length < 2 || drawnSet.has(grid)) return
drawnSet.add(grid)
const c = GridSquare.decode(grid)
if (c.west > bounds.getEast() || c.east < bounds.getWest() ||
c.south > bounds.getNorth() || c.north < bounds.getSouth()) return
const field = precision <= 2
const rectOpts = { color: "#E63946", weight: field ? 2.5 : 2, opacity: 0.8, fillOpacity: 0, interactive: false }
if (pane) rectOpts.pane = pane
layerGroup.addLayer(L.rectangle(
[[c.south, c.west], [c.north, c.east]],
rectOpts
))
const sw = map.latLngToContainerPoint([c.south, c.west])
const ne = map.latLngToContainerPoint([c.north, c.east])
const px = Math.abs(ne.x - sw.x)
const minW = field ? 30 : 40
if (px > minW) {
const len = field ? 2 : 4
const text = grid.substring(0, len)
const fs = Math.max(10, Math.min(14, Math.round(px / (len * 1.2))))
const markerOpts = {
interactive: false,
icon: L.divIcon({
className: "grid-label",
html: `<div style="font-size:${fs}px;font-weight:bold;color:#fff;background:rgba(0,0,0,0.5);padding:1px 4px;border-radius:3px;white-space:nowrap;width:fit-content;">${text}</div>`,
iconSize: [Math.round(len * fs * 0.8), Math.round(fs * 1.5)],
iconAnchor: [Math.round(len * fs * 0.4), Math.round(fs * 0.75)]
})
}
if (pane) markerOpts.pane = pane
layerGroup.addLayer(L.marker([c.center.lat, c.center.lon], markerOpts))
}
}
export function updateGridOverlay(map, gridLayer, opts) {
gridLayer.clearLayers()
const drawn = new Set()
const bounds = map.getBounds()
const zoom = map.getZoom()
const showSquares = zoom >= 7
const pane = opts && opts.pane
const south = Math.max(bounds.getSouth(), -90)
const north = Math.min(bounds.getNorth(), 90)
if (!showSquares) {
const fW = Math.floor((bounds.getWest() + 180) / 20) * 20 - 180
const fE = Math.ceil((bounds.getEast() + 180) / 20) * 20 - 180
const fS = Math.floor((south + 90) / 10) * 10 - 90
const fN = Math.ceil((north + 90) / 10) * 10 - 90
for (let lon = fW; lon < fE; lon += 20) {
for (let lat = fS; lat < fN; lat += 10) {
const nLon = ((lon % 360) + 540) % 360 - 180
const g = new GridSquare(lat + 5, nLon + 10).encode(4).substring(0, 2)
drawGridSquare(g, 2, bounds, zoom, gridLayer, drawn, map, pane)
}
}
return
}
const wE = Math.floor((bounds.getWest() + 180) / 2) * 2 - 180
const eE = Math.ceil((bounds.getEast() + 180) / 2) * 2 - 180
const sE = Math.floor(south + 90) - 90
const nE = Math.ceil(north + 90) - 90
if (Math.ceil((eE - wE) / 2) * Math.ceil(nE - sE) > 500) return
for (let lon = wE; lon < eE; lon += 2) {
for (let lat = sE; lat < nE; lat += 1) {
const nLon = ((lon % 360) + 540) % 360 - 180
const g = new GridSquare(lat + 0.5, nLon + 1).encode(4)
drawGridSquare(g.substring(0, 4), 4, bounds, zoom, gridLayer, drawn, map, pane)
}
}
}

View file

@ -1,12 +1,7 @@
import { updateGridOverlay } from "./maidenhead_grid"
export const RoverMap = {
mounted() {
this.stations = []
this.stationMarkers = L.layerGroup()
this.coverageLayer = L.layerGroup()
this.gridLayer = L.layerGroup()
this.gridVisible = true
this.scoreOverlay = null
this.gridLookup = new Map()
@ -29,15 +24,8 @@ export const RoverMap = {
maxZoom: 19
}).addTo(map)
// Custom pane so grid lines render above coverage fills
map.createPane("gridPane")
map.getPane("gridPane").style.zIndex = 450
map.getPane("gridPane").style.pointerEvents = "none"
this.map = map
this.stationMarkers.addTo(map)
this.coverageLayer.addTo(map)
this.gridLayer.addTo(map)
// Render pre-loaded propagation scores
const initialScores = JSON.parse(this.el.dataset.scores || "[]")
@ -54,10 +42,7 @@ export const RoverMap = {
})
}
// Draw grid and update on pan/zoom
updateGridOverlay(map, this.gridLayer, { pane: "gridPane" })
map.on("moveend", () => {
if (this.gridVisible) updateGridOverlay(map, this.gridLayer, { pane: "gridPane" })
pushBounds()
})
@ -69,22 +54,6 @@ export const RoverMap = {
this.stations = stations
this.renderStations()
})
this.handleEvent("coverage_updated", ({ grids }) => {
this.renderCoverage(grids)
if (this.gridVisible) updateGridOverlay(map, this.gridLayer, { pane: "gridPane" })
})
this.handleEvent("toggle_grids", ({ show }) => {
this.gridVisible = show
if (show) {
this.gridLayer.addTo(this.map)
updateGridOverlay(this.map, this.gridLayer, { pane: "gridPane" })
} else {
this.gridLayer.clearLayers()
this.map.removeLayer(this.gridLayer)
}
})
},
// --- Propagation score overlay (same as main map) ---
@ -150,9 +119,6 @@ export const RoverMap = {
this.scoreOverlay = new OverlayClass({ opacity: 1.0 })
this.scoreOverlay.addTo(this.map)
// Ensure coverage and grid layers stay above score overlay
if (this.coverageLayer._map) this.coverageLayer.bringToFront()
},
interpolateScore(lat, lon, step) {
@ -202,7 +168,7 @@ export const RoverMap = {
return { r: last.r, g: last.g, b: last.b }
},
// --- Station and coverage rendering ---
// --- Station rendering ---
renderStations() {
this.stationMarkers.clearLayers()
@ -231,98 +197,6 @@ export const RoverMap = {
this.fitBounds()
},
renderCoverage(grids) {
this.coverageLayer.clearLayers()
if (!grids || grids.length === 0) return
const maxScore = Math.max(...grids.map(g => g.coverage_score))
for (const g of grids) {
// Render as 0.25° cells centered on the candidate point
const half = 0.125
const bounds = [[g.lat - half, g.lon - half], [g.lat + half, g.lon + half]]
const normalized = maxScore > 0 ? g.coverage_score / maxScore : 0
const color = this.coverageColor(normalized)
const opacity = 0.15 + normalized * 0.45
const rect = L.rectangle(bounds, {
color: color,
weight: 1,
opacity: 0.5,
fillColor: color,
fillOpacity: opacity,
interactive: true
})
const pctStations = Math.round(g.stations_in_range / g.total_stations * 100)
rect.bindTooltip(
`<b>${g.grid}</b><br>` +
`Coverage: ${g.coverage_score}/100<br>` +
`Stations: ${g.stations_in_range}/${g.total_stations} (${pctStations}%)<br>` +
`Propagation: ${g.prop_score}/100` +
(g.best_hour ? `<br>Best: ${g.best_hour} UTC` : ""),
{ sticky: true }
)
rect.on("click", () => {
this.pushEvent("select_grid", { grid: g.grid })
})
this.coverageLayer.addLayer(rect)
}
// Add rank labels to top 5
const top5 = grids.slice(0, 5)
for (let i = 0; i < top5.length; i++) {
const g = top5[i]
const icon = L.divIcon({
html: `<div style="
background: ${this.coverageColor(1 - i * 0.15)};
color: #000;
width: 24px; height: 24px;
border-radius: 50%;
border: 2px solid white;
display: flex; align-items: center; justify-content: center;
font-weight: bold; font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.4);
">${i + 1}</div>`,
className: "",
iconSize: [24, 24],
iconAnchor: [12, 12]
})
L.marker([g.lat, g.lon], { icon, interactive: false, pane: "markerPane" })
.addTo(this.coverageLayer)
}
},
gridBounds(grid) {
// Decode 4-char Maidenhead to lat/lon bounds
if (grid.length < 4) return null
const lonField = grid.charCodeAt(0) - 65 // A=0
const latField = grid.charCodeAt(1) - 65
const lonSq = parseInt(grid[2])
const latSq = parseInt(grid[3])
const lon1 = lonField * 20 - 180 + lonSq * 2
const lat1 = latField * 10 - 90 + latSq * 1
const lon2 = lon1 + 2
const lat2 = lat1 + 1
return [[lat1, lon1], [lat2, lon2]]
},
coverageColor(normalized) {
// Green (best) → Yellow → Red (worst)
if (normalized >= 0.7) return "#00ffa3"
if (normalized >= 0.5) return "#7dffd4"
if (normalized >= 0.3) return "#ffe566"
if (normalized >= 0.15) return "#ff9044"
return "#ff4f4f"
},
fitBounds() {
const points = this.stations.map(s => [s.lat, s.lon])
if (points.length >= 2) {

View file

@ -1,204 +0,0 @@
defmodule Microwaveprop.Rover.Coverage do
@moduledoc """
Computes coverage scores for candidate roving locations.
Given a list of stationary stations and a band, ranks grid squares
by how many stations a rover can work from each, weighted by
terrain, propagation forecast, and distance.
"""
alias Microwaveprop.Geo
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
@doc """
Compute ranked coverage for all candidate grids.
Returns a list of grid results sorted by coverage_score descending.
Phase 1: Fast pass score all grids by distance + propagation (no terrain).
Phase 2: Detailed pass run SRTM terrain analysis for top 20 candidates.
"""
def compute(stations, band_mhz) when length(stations) >= 2 do
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
max_range = band_config.extended_range_km || 500
freq_ghz = band_mhz / 1000
candidates = generate_candidates(stations, max_range)
# Phase 1: Fast scoring
fast_scored =
candidates
|> Enum.map(fn {grid, lat, lon} ->
fast_score_grid(grid, lat, lon, stations, band_mhz, max_range)
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort_by(& &1.coverage_score, :desc)
# Phase 2: Terrain detail for top 20
top = Enum.take(fast_scored, 20)
# Terrain analysis may fail in test (no SRTM data) or timeout — fall back to fast scores
detailed =
top
|> Task.async_stream(
fn candidate ->
try do
add_terrain_detail(candidate, stations, freq_ghz, max_range)
rescue
_ -> candidate
end
end,
max_concurrency: 4,
timeout: 60_000,
on_timeout: :kill_task
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, _} -> nil
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort_by(& &1.coverage_score, :desc)
if detailed == [], do: fast_scored, else: detailed
end
def compute(_stations, _band_mhz), do: []
defp generate_candidates(stations, max_range) do
lats = Enum.map(stations, & &1.lat)
lons = Enum.map(stations, & &1.lon)
# Cap search radius to keep candidate count reasonable
# For the rover planner, focus on the area between and around the stations
capped_range = min(max_range, 300)
range_deg = capped_range / 111.0
min_lat = max(Enum.min(lats) - range_deg, 25.0)
max_lat = min(Enum.max(lats) + range_deg, 50.0)
min_lon = max(Enum.min(lons) - range_deg, -125.0)
max_lon = min(Enum.max(lons) + range_deg, -66.0)
# Generate candidates at 0.25° resolution (between HRRR 0.125° and Maidenhead 1-2°)
# This gives ~16x more candidates than Maidenhead but stays manageable
step = 0.25
for lat <- float_range(min_lat, max_lat, step),
lon <- float_range(min_lon, max_lon, step) do
grid = Geo.latlon_to_grid4(lat, lon)
{grid, Float.round(lat, 3), Float.round(lon, 3)}
end
end
defp float_range(min, max, step) do
(Float.ceil(min / step) * step)
|> Stream.iterate(&(&1 + step))
|> Enum.take_while(&(&1 <= max))
end
defp fast_score_grid(grid, lat, lon, stations, band_mhz, max_range) do
station_distances =
Enum.map(stations, fn s ->
dist = Geo.haversine_km(lat, lon, s.lat, s.lon)
%{label: s.label, lat: s.lat, lon: s.lon, dist_km: dist, in_range: dist <= max_range}
end)
in_range = Enum.filter(station_distances, & &1.in_range)
if in_range == [] do
nil
else
station_pct = length(in_range) / length(stations)
distance_factor =
in_range
|> Enum.map(fn s -> max(0, 1.0 - s.dist_km / max_range) end)
|> then(&(Enum.sum(&1) / length(stations)))
forecast = Propagation.point_forecast(band_mhz, lat, lon)
{prop_score, best_hour} =
case forecast do
[_ | _] ->
best = Enum.max_by(forecast, & &1.score)
{best.score, Calendar.strftime(best.valid_time, "%H:%M")}
_ ->
{50, nil}
end
coverage_score = round(prop_score / 100 * 40 + distance_factor * 30 + station_pct * 30)
%{
grid: grid,
lat: lat,
lon: lon,
coverage_score: coverage_score,
stations_in_range: length(in_range),
workable_count: length(in_range),
prop_score: prop_score,
best_hour: best_hour,
station_details: station_distances,
forecast: forecast
}
end
end
defp add_terrain_detail(candidate, stations, freq_ghz, max_range) do
station_analyses =
Enum.map(candidate.station_details, fn s ->
if s.in_range do
{verdict, diffraction_db} =
try do
case ElevationClient.fetch_elevation_profile(candidate.lat, candidate.lon, s.lat, s.lon, 64) do
{:ok, profile} ->
analysis = TerrainAnalysis.analyse(profile, s.dist_km, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0)
{analysis.verdict, analysis.diffraction_db}
{:error, _} ->
{nil, 0}
end
rescue
_ -> {nil, 0}
end
path_quality =
case verdict do
"CLEAR" -> 1.0
"FRESNEL_MINOR" -> 0.9
"FRESNEL_PARTIAL" -> 0.7
"BLOCKED" when diffraction_db < 10 -> 0.5
"BLOCKED" when diffraction_db < 20 -> 0.35
"BLOCKED" when diffraction_db < 40 -> 0.2
"BLOCKED" -> 0.1
_ -> 0.4
end
Map.merge(s, %{
verdict: verdict,
diffraction_db: diffraction_db && Float.round(diffraction_db, 1),
path_quality: path_quality
})
else
Map.merge(s, %{verdict: nil, diffraction_db: nil, path_quality: 0})
end
end)
in_range = Enum.filter(station_analyses, & &1.in_range)
path_score = Enum.sum(Enum.map(in_range, &Map.get(&1, :path_quality, 0.4))) / max(length(stations), 1)
workable_count = Enum.count(in_range, &(Map.get(&1, :path_quality, 0) >= 0.2))
prop_boost = candidate.prop_score / 100
boosted = min(1.0, path_score + prop_boost * 0.3)
station_pct = length(in_range) / max(length(stations), 1)
distance_factor =
in_range
|> Enum.map(fn s -> max(0, 1.0 - s.dist_km / max_range) end)
|> then(&(Enum.sum(&1) / max(length(stations), 1)))
coverage_score = round(boosted * 30 + prop_boost * 30 + distance_factor * 20 + station_pct * 20)
%{candidate | station_details: station_analyses, workable_count: workable_count, coverage_score: coverage_score}
end
end

View file

@ -1,8 +1,7 @@
defmodule MicrowavepropWeb.RoverLive do
@moduledoc """
Rover planner: enter stationary stations you want to work, select a band,
and the map colors areas by coverage quality how many stations you can
reach from each point given current terrain and propagation conditions.
and the map shows the HRRR propagation heatmap with your stations plotted.
"""
use MicrowavepropWeb, :live_view
@ -10,7 +9,6 @@ defmodule MicrowavepropWeb.RoverLive do
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Rover.Coverage
@band_options BandConfig.band_options()
@ -37,12 +35,8 @@ defmodule MicrowavepropWeb.RoverLive do
band: @default_band,
stations: [],
station_input: "",
coverage: [],
selected_grid: nil,
resolving: false,
computing: false,
url_loaded: false,
show_grids: true,
initial_scores_json: Jason.encode!(initial_scores),
bounds: @initial_bounds
)}
@ -87,23 +81,18 @@ defmodule MicrowavepropWeb.RoverLive do
def handle_event("remove_station", %{"index" => idx_str}, socket) do
idx = String.to_integer(idx_str)
stations = List.delete_at(socket.assigns.stations, idx)
socket = assign(socket, stations: stations, coverage: [], selected_grid: nil)
socket = assign(socket, stations: stations)
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)})
{:noreply, push_url(socket)}
end
def handle_event("toggle_grids", _params, socket) do
show = !socket.assigns.show_grids
{:noreply, socket |> assign(show_grids: show) |> push_event("toggle_grids", %{show: show})}
end
def handle_event("select_band", %{"band" => band_str}, socket) do
band = String.to_integer(band_str)
scores = Propagation.latest_scores(band, socket.assigns.bounds)
socket =
socket
|> assign(band: band, coverage: [], selected_grid: nil)
|> assign(band: band)
|> push_event("update_scores", %{scores: scores})
{:noreply, push_url(socket)}
@ -120,20 +109,6 @@ defmodule MicrowavepropWeb.RoverLive do
{:noreply, socket}
end
def handle_event("calculate", _params, socket) do
if length(socket.assigns.stations) >= 2 and not socket.assigns.computing do
send(self(), :compute_coverage)
{:noreply, assign(socket, computing: true)}
else
{:noreply, socket}
end
end
def handle_event("select_grid", %{"grid" => grid}, socket) do
detail = Enum.find(socket.assigns.coverage, &(&1.grid == grid))
{:noreply, assign(socket, selected_grid: detail)}
end
# ── Async ──
def handle_info({:resolve_station_list, calls}, socket) do
@ -172,58 +147,6 @@ defmodule MicrowavepropWeb.RoverLive do
{:noreply, push_event(socket, "update_scores", %{scores: scores})}
end
def handle_info(:compute_coverage, socket) do
stations = socket.assigns.stations
band_mhz = socket.assigns.band
if length(stations) < 2 do
{:noreply, assign(socket, computing: false)}
else
pid = self()
Task.start(fn ->
try do
result = Coverage.compute(stations, band_mhz)
send(pid, {:coverage_result, result})
rescue
e ->
require Logger
Logger.error("Rover coverage computation failed: #{Exception.message(e)}")
send(pid, {:coverage_result, []})
end
end)
{:noreply, assign(socket, computing: true)}
end
end
def handle_info({:coverage_result, coverage}, socket) do
stations = socket.assigns.stations
grid_data =
Enum.map(coverage, fn c ->
%{
grid: c.grid,
lat: c.lat,
lon: c.lon,
coverage_score: c.coverage_score,
stations_in_range: c.stations_in_range,
workable_count: c.workable_count,
total_stations: length(stations),
prop_score: c.prop_score,
best_hour: c.best_hour
}
end)
socket =
socket
|> assign(coverage: coverage, computing: false, selected_grid: nil)
|> push_event("coverage_updated", %{grids: grid_data})
{:noreply, socket}
end
# ── Helpers ──
defp resolve_station(input) do
@ -263,18 +186,6 @@ defmodule MicrowavepropWeb.RoverLive do
Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end)
end
defp verdict_badge("CLEAR"), do: "badge badge-success badge-sm"
defp verdict_badge("FRESNEL_MINOR"), do: "badge badge-warning badge-sm"
defp verdict_badge("FRESNEL_PARTIAL"), do: "badge badge-warning badge-sm"
defp verdict_badge("BLOCKED"), do: "badge badge-error badge-sm"
defp verdict_badge(_), do: "badge badge-sm"
defp tier_color(score) when score >= 80, do: "#00ffa3"
defp tier_color(score) when score >= 65, do: "#7dffd4"
defp tier_color(score) when score >= 50, do: "#ffe566"
defp tier_color(score) when score >= 33, do: "#ff9044"
defp tier_color(_), do: "#ff4f4f"
# ── Render ──
@impl true
@ -295,22 +206,11 @@ defmodule MicrowavepropWeb.RoverLive do
<div class="absolute top-2 left-2 z-[1000] w-80 max-h-[calc(100vh-1rem)] overflow-y-auto bg-base-100/95 shadow-lg rounded-box border border-base-300 p-3 flex flex-col gap-3">
<div class="font-bold text-sm">Rover Planner</div>
<div class="flex gap-2">
<select phx-change="select_band" name="band" class="select select-bordered select-sm flex-1">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == to_string(@band)}>{label}</option>
<% end %>
</select>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@show_grids}
phx-click="toggle_grids"
/>
<span class="text-sm">Grid squares</span>
</label>
</div>
<select phx-change="select_band" name="band" class="select select-bordered select-sm w-full">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == to_string(@band)}>{label}</option>
<% end %>
</select>
<form phx-submit="add_station" class="flex gap-1">
<input
@ -347,106 +247,6 @@ defmodule MicrowavepropWeb.RoverLive do
<% end %>
</div>
<% end %>
<%= if length(@stations) >= 2 do %>
<button
type="button"
phx-click="calculate"
class="btn btn-primary btn-sm w-full"
disabled={@computing}
>
<%= if @computing do %>
<span class="loading loading-spinner loading-sm"></span>
Finding best roving locations...
<% else %>
Find Best Roving Locations
<% end %>
</button>
<% end %>
<%= if length(@stations) == 1 do %>
<div class="text-xs opacity-50 text-center py-2">
Add at least 2 stations to calculate
</div>
<% end %>
<%!-- Top grids ranked --%>
<%= if @coverage != [] do %>
<div class="divider my-0"></div>
<div class="text-xs opacity-60">Best Operating Grids</div>
<%= for {grid, rank} <- @coverage |> Enum.take(10) |> Enum.with_index(1) do %>
<div
class={[
"bg-base-200 rounded-lg p-2 text-xs cursor-pointer hover:bg-base-300 transition-colors",
@selected_grid && @selected_grid.grid == grid.grid && "ring-2 ring-primary"
]}
phx-click="select_grid"
phx-value-grid={grid.grid}
>
<div class="flex justify-between items-center">
<div class="font-bold">
<span
class="inline-block w-5 h-5 rounded-full text-center leading-5 text-[10px] font-bold mr-1"
style={"background-color: #{tier_color(grid.coverage_score)}; color: #000"}
>
{rank}
</span>
{grid.grid}
</div>
<div class="font-mono font-bold" style={"color: #{tier_color(grid.coverage_score)}"}>
{grid.coverage_score}
</div>
</div>
<div class="mt-1 flex justify-between opacity-70">
<span>{grid.workable_count}/{length(@stations)} workable</span>
<span>Prop: {grid.prop_score}</span>
</div>
<%= if grid.best_hour do %>
<div class="opacity-50">Best at {grid.best_hour} UTC</div>
<% end %>
<%= if grid.forecast != [] do %>
<div class="flex items-end gap-px h-3 mt-1">
<%= for point <- grid.forecast do %>
<div
class="flex-1 rounded-t-sm"
style={"height: #{max(point.score, 5)}%; background-color: #{tier_color(point.score)}; min-width: 2px"}
title={"#{Calendar.strftime(point.valid_time, "%H:%M")} UTC: #{point.score}"}
>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% end %>
<%!-- Selected grid detail --%>
<%= if @selected_grid do %>
<div class="divider my-0"></div>
<div class="text-xs opacity-60">
{@selected_grid.grid} Station Distances
</div>
<%= for s <- Enum.sort_by(@selected_grid.station_details, & &1.dist_km) do %>
<div class="flex justify-between items-center text-xs bg-base-200 rounded px-2 py-1">
<div>
<span class="font-bold">{s.label}</span>
<span class="opacity-50 ml-1">{Float.round(s.dist_km, 0)} km</span>
</div>
<div class="flex gap-1 items-center">
<%= if s.verdict do %>
<span class={verdict_badge(s.verdict)}>{s.verdict}</span>
<% end %>
<%= if s.diffraction_db && s.diffraction_db > 0 do %>
<span class="opacity-50 text-[10px]">{s.diffraction_db} dB</span>
<% end %>
<%= if !s.in_range do %>
<span class="badge badge-error badge-sm">out of range</span>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
"""

View file

@ -1,95 +0,0 @@
defmodule Microwaveprop.Rover.CoverageTest do
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Rover.Coverage
describe "compute/2" do
test "returns ranked grids for stations within range" do
stations = [
%{label: "STA1", lat: 33.0, lon: -97.0},
%{label: "STA2", lat: 33.5, lon: -97.5}
]
result = Coverage.compute(stations, 10_000)
assert is_list(result)
assert length(result) > 0
scores = Enum.map(result, & &1.coverage_score)
assert scores == Enum.sort(scores, :desc)
first = hd(result)
assert is_binary(first.grid)
assert byte_size(first.grid) == 4
assert is_number(first.lat)
assert is_number(first.lon)
assert is_integer(first.coverage_score)
assert first.coverage_score >= 0 and first.coverage_score <= 100
assert is_integer(first.stations_in_range)
assert first.stations_in_range > 0
assert is_integer(first.workable_count)
assert is_integer(first.prop_score)
assert is_list(first.station_details)
assert is_list(first.forecast)
end
test "returns empty list with fewer than 2 stations" do
assert Coverage.compute([%{label: "STA1", lat: 33.0, lon: -97.0}], 10_000) == []
assert Coverage.compute([], 10_000) == []
end
test "station_details includes distance and in_range" do
stations = [
%{label: "STA1", lat: 33.0, lon: -97.0},
%{label: "STA2", lat: 33.5, lon: -97.0}
]
[first | _] = Coverage.compute(stations, 10_000)
assert length(first.station_details) == 2
for detail <- first.station_details do
assert Map.has_key?(detail, :label)
assert Map.has_key?(detail, :dist_km)
assert Map.has_key?(detail, :in_range)
assert is_number(detail.dist_km)
assert is_boolean(detail.in_range)
end
end
test "higher frequency bands have shorter range so fewer grids cover both stations" do
far_stations = [
%{label: "STA1", lat: 33.0, lon: -97.0},
%{label: "STA2", lat: 35.0, lon: -97.0}
]
result_10g = Coverage.compute(far_stations, 10_000)
result_241g = Coverage.compute(far_stations, 241_000)
# 10 GHz has 500 km range, 241 GHz has 50 km — far fewer grids reach both at 241
grids_reaching_both_10g = Enum.count(result_10g, &(&1.stations_in_range == 2))
grids_reaching_both_241g = Enum.count(result_241g, &(&1.stations_in_range == 2))
assert grids_reaching_both_10g >= grids_reaching_both_241g
end
test "top results have station details with expected fields" do
stations = [
%{label: "STA1", lat: 33.0, lon: -97.0},
%{label: "STA2", lat: 33.2, lon: -97.2}
]
[first | _] = Coverage.compute(stations, 10_000)
# Each station detail should have core fields
for detail <- first.station_details do
assert Map.has_key?(detail, :label)
assert Map.has_key?(detail, :dist_km)
assert Map.has_key?(detail, :in_range)
end
# At least one station should be in range
assert Enum.any?(first.station_details, & &1.in_range)
end
end
end