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
-
- - -
+
<% end %> - - <%= if length(@stations) >= 2 do %> - - <% 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