Simplify rover planner: remove grid toggle and coverage calculation
This commit is contained in:
parent
cafa1c1a34
commit
803516f0b0
5 changed files with 9 additions and 761 deletions
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import { updateGridOverlay } from "./maidenhead_grid"
|
|
||||||
|
|
||||||
export const RoverMap = {
|
export const RoverMap = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.stations = []
|
this.stations = []
|
||||||
this.stationMarkers = L.layerGroup()
|
this.stationMarkers = L.layerGroup()
|
||||||
this.coverageLayer = L.layerGroup()
|
|
||||||
this.gridLayer = L.layerGroup()
|
|
||||||
this.gridVisible = true
|
|
||||||
this.scoreOverlay = null
|
this.scoreOverlay = null
|
||||||
this.gridLookup = new Map()
|
this.gridLookup = new Map()
|
||||||
|
|
||||||
|
|
@ -29,15 +24,8 @@ export const RoverMap = {
|
||||||
maxZoom: 19
|
maxZoom: 19
|
||||||
}).addTo(map)
|
}).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.map = map
|
||||||
this.stationMarkers.addTo(map)
|
this.stationMarkers.addTo(map)
|
||||||
this.coverageLayer.addTo(map)
|
|
||||||
this.gridLayer.addTo(map)
|
|
||||||
|
|
||||||
// Render pre-loaded propagation scores
|
// Render pre-loaded propagation scores
|
||||||
const initialScores = JSON.parse(this.el.dataset.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", () => {
|
map.on("moveend", () => {
|
||||||
if (this.gridVisible) updateGridOverlay(map, this.gridLayer, { pane: "gridPane" })
|
|
||||||
pushBounds()
|
pushBounds()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -69,22 +54,6 @@ export const RoverMap = {
|
||||||
this.stations = stations
|
this.stations = stations
|
||||||
this.renderStations()
|
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) ---
|
// --- Propagation score overlay (same as main map) ---
|
||||||
|
|
@ -150,9 +119,6 @@ export const RoverMap = {
|
||||||
|
|
||||||
this.scoreOverlay = new OverlayClass({ opacity: 1.0 })
|
this.scoreOverlay = new OverlayClass({ opacity: 1.0 })
|
||||||
this.scoreOverlay.addTo(this.map)
|
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) {
|
interpolateScore(lat, lon, step) {
|
||||||
|
|
@ -202,7 +168,7 @@ export const RoverMap = {
|
||||||
return { r: last.r, g: last.g, b: last.b }
|
return { r: last.r, g: last.g, b: last.b }
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Station and coverage rendering ---
|
// --- Station rendering ---
|
||||||
|
|
||||||
renderStations() {
|
renderStations() {
|
||||||
this.stationMarkers.clearLayers()
|
this.stationMarkers.clearLayers()
|
||||||
|
|
@ -231,98 +197,6 @@ export const RoverMap = {
|
||||||
this.fitBounds()
|
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() {
|
fitBounds() {
|
||||||
const points = this.stations.map(s => [s.lat, s.lon])
|
const points = this.stations.map(s => [s.lat, s.lon])
|
||||||
if (points.length >= 2) {
|
if (points.length >= 2) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
defmodule MicrowavepropWeb.RoverLive do
|
defmodule MicrowavepropWeb.RoverLive do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Rover planner: enter stationary stations you want to work, select a band,
|
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
|
and the map shows the HRRR propagation heatmap with your stations plotted.
|
||||||
reach from each point given current terrain and propagation conditions.
|
|
||||||
"""
|
"""
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
|
@ -10,7 +9,6 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
alias Microwaveprop.Radio.CallsignClient
|
alias Microwaveprop.Radio.CallsignClient
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Rover.Coverage
|
|
||||||
|
|
||||||
@band_options BandConfig.band_options()
|
@band_options BandConfig.band_options()
|
||||||
|
|
||||||
|
|
@ -37,12 +35,8 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
band: @default_band,
|
band: @default_band,
|
||||||
stations: [],
|
stations: [],
|
||||||
station_input: "",
|
station_input: "",
|
||||||
coverage: [],
|
|
||||||
selected_grid: nil,
|
|
||||||
resolving: false,
|
resolving: false,
|
||||||
computing: false,
|
|
||||||
url_loaded: false,
|
url_loaded: false,
|
||||||
show_grids: true,
|
|
||||||
initial_scores_json: Jason.encode!(initial_scores),
|
initial_scores_json: Jason.encode!(initial_scores),
|
||||||
bounds: @initial_bounds
|
bounds: @initial_bounds
|
||||||
)}
|
)}
|
||||||
|
|
@ -87,23 +81,18 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
def handle_event("remove_station", %{"index" => idx_str}, socket) do
|
def handle_event("remove_station", %{"index" => idx_str}, socket) do
|
||||||
idx = String.to_integer(idx_str)
|
idx = String.to_integer(idx_str)
|
||||||
stations = List.delete_at(socket.assigns.stations, idx)
|
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)})
|
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)})
|
||||||
{:noreply, push_url(socket)}
|
{:noreply, push_url(socket)}
|
||||||
end
|
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
|
def handle_event("select_band", %{"band" => band_str}, socket) do
|
||||||
band = String.to_integer(band_str)
|
band = String.to_integer(band_str)
|
||||||
scores = Propagation.latest_scores(band, socket.assigns.bounds)
|
scores = Propagation.latest_scores(band, socket.assigns.bounds)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(band: band, coverage: [], selected_grid: nil)
|
|> assign(band: band)
|
||||||
|> push_event("update_scores", %{scores: scores})
|
|> push_event("update_scores", %{scores: scores})
|
||||||
|
|
||||||
{:noreply, push_url(socket)}
|
{:noreply, push_url(socket)}
|
||||||
|
|
@ -120,20 +109,6 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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 ──
|
# ── Async ──
|
||||||
|
|
||||||
def handle_info({:resolve_station_list, calls}, socket) do
|
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})}
|
{:noreply, push_event(socket, "update_scores", %{scores: scores})}
|
||||||
end
|
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 ──
|
# ── Helpers ──
|
||||||
|
|
||||||
defp resolve_station(input) do
|
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)
|
Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end)
|
||||||
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 ──
|
# ── Render ──
|
||||||
|
|
||||||
@impl true
|
@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="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="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 w-full">
|
||||||
<select phx-change="select_band" name="band" class="select select-bordered select-sm flex-1">
|
<%= for {label, value} <- @band_options do %>
|
||||||
<%= for {label, value} <- @band_options do %>
|
<option value={value} selected={value == to_string(@band)}>{label}</option>
|
||||||
<option value={value} selected={value == to_string(@band)}>{label}</option>
|
<% end %>
|
||||||
<% end %>
|
</select>
|
||||||
</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>
|
|
||||||
|
|
||||||
<form phx-submit="add_station" class="flex gap-1">
|
<form phx-submit="add_station" class="flex gap-1">
|
||||||
<input
|
<input
|
||||||
|
|
@ -347,106 +247,6 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Add table
Reference in a new issue