diff --git a/assets/js/maidenhead_grid.js b/assets/js/maidenhead_grid.js
deleted file mode 100644
index c70248eb..00000000
--- a/assets/js/maidenhead_grid.js
+++ /dev/null
@@ -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: `
${text}
`,
- 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)
- }
- }
-}
diff --git a/assets/js/rover_map_hook.js b/assets/js/rover_map_hook.js
index f176e5fe..94bcf1f7 100644
--- a/assets/js/rover_map_hook.js
+++ b/assets/js/rover_map_hook.js
@@ -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(
- `${g.grid} ` +
- `Coverage: ${g.coverage_score}/100 ` +
- `Stations: ${g.stations_in_range}/${g.total_stations} (${pctStations}%) ` +
- `Propagation: ${g.prop_score}/100` +
- (g.best_hour ? ` 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: `${i + 1}
`,
- 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) {
diff --git a/lib/microwaveprop/rover/coverage.ex b/lib/microwaveprop/rover/coverage.ex
deleted file mode 100644
index e44993af..00000000
--- a/lib/microwaveprop/rover/coverage.ex
+++ /dev/null
@@ -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
diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex
index 873216b6..f68e4c27 100644
--- a/lib/microwaveprop_web/live/rover_live.ex
+++ b/lib/microwaveprop_web/live/rover_live.ex
@@ -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
Rover Planner
-
-
- <%= for {label, value} <- @band_options do %>
- {label}
- <% end %>
-
-
-
- Grid squares
-
-
+
+ <%= for {label, value} <- @band_options do %>
+ {label}
+ <% end %>
+
<% end %>
-
- <%= if length(@stations) >= 2 do %>
-
- <%= if @computing do %>
-
- Finding best roving locations...
- <% else %>
- Find Best Roving Locations
- <% end %>
-
- <% end %>
-
- <%= if length(@stations) == 1 do %>
-
- Add at least 2 stations to calculate
-
- <% end %>
-
- <%!-- Top grids ranked --%>
- <%= if @coverage != [] do %>
-
- Best Operating Grids
-
- <%= for {grid, rank} <- @coverage |> Enum.take(10) |> Enum.with_index(1) do %>
-
-
-
-
- {rank}
-
- {grid.grid}
-
-
- {grid.coverage_score}
-
-
-
- {grid.workable_count}/{length(@stations)} workable
- Prop: {grid.prop_score}
-
- <%= if grid.best_hour do %>
-
Best at {grid.best_hour} UTC
- <% end %>
- <%= if grid.forecast != [] do %>
-
- <%= for point <- grid.forecast do %>
-
-
- <% end %>
-
- <% end %>
-
- <% end %>
- <% end %>
-
- <%!-- Selected grid detail --%>
- <%= if @selected_grid do %>
-
-
- {@selected_grid.grid} — Station Distances
-
- <%= for s <- Enum.sort_by(@selected_grid.station_details, & &1.dist_km) do %>
-
-
- {s.label}
- {Float.round(s.dist_km, 0)} km
-
-
- <%= if s.verdict do %>
- {s.verdict}
- <% end %>
- <%= if s.diffraction_db && s.diffraction_db > 0 do %>
- {s.diffraction_db} dB
- <% end %>
- <%= if !s.in_range do %>
- out of range
- <% end %>
-
-
- <% end %>
- <% end %>
"""
diff --git a/test/microwaveprop/rover/coverage_test.exs b/test/microwaveprop/rover/coverage_test.exs
deleted file mode 100644
index ee7b43a3..00000000
--- a/test/microwaveprop/rover/coverage_test.exs
+++ /dev/null
@@ -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