Simplify rover planner: remove grid toggle and coverage calculation
This commit is contained in:
parent
b705609329
commit
75fd05a6d1
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 = {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
@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>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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