Extract Coverage module, fix computation, add tests
- Extract scoring logic to Microwaveprop.Rover.Coverage for testability - Two-phase computation: fast pass (distance + propagation) for all grids, then SRTM terrain analysis for top 20 candidates only - Cap search radius to 300 km to keep candidate count reasonable - Run terrain analysis in Task with rescue/fallback for resilience - Background Task doesn't block LiveView process - 5 tests covering: ranked results, empty inputs, station details, band range differences, field presence
This commit is contained in:
parent
89898893fc
commit
6aec3eeea4
3 changed files with 310 additions and 174 deletions
203
lib/microwaveprop/rover/coverage.ex
Normal file
203
lib/microwaveprop/rover/coverage.ex
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
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 at 4-char Maidenhead resolution (1° lat × 2° lon)
|
||||||
|
lat_range = Enum.to_list(trunc(min_lat)..trunc(max_lat))
|
||||||
|
lon_range = Enum.to_list(trunc(min_lon)..trunc(max_lon)//2)
|
||||||
|
|
||||||
|
# Use center of grid square
|
||||||
|
for_result =
|
||||||
|
for lat <- lat_range, lon <- lon_range do
|
||||||
|
clat = lat + 0.5
|
||||||
|
clon = lon + 1.0
|
||||||
|
grid = Geo.latlon_to_grid4(clat, clon)
|
||||||
|
{grid, clat, clon}
|
||||||
|
end
|
||||||
|
|
||||||
|
Enum.uniq_by(for_result, fn {grid, _, _} -> grid end)
|
||||||
|
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
|
||||||
|
|
@ -6,13 +6,9 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
"""
|
"""
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
alias Microwaveprop.Geo
|
|
||||||
alias Microwaveprop.Propagation
|
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
|
||||||
alias Microwaveprop.Radio.CallsignClient
|
alias Microwaveprop.Radio.CallsignClient
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Terrain.ElevationClient
|
alias Microwaveprop.Rover.Coverage
|
||||||
alias Microwaveprop.Terrain.TerrainAnalysis
|
|
||||||
|
|
||||||
@band_options [
|
@band_options [
|
||||||
{"10 GHz", "10000"},
|
{"10 GHz", "10000"},
|
||||||
|
|
@ -141,14 +137,21 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
band_mhz = socket.assigns.band
|
band_mhz = socket.assigns.band
|
||||||
|
|
||||||
if length(stations) < 2 do
|
if length(stations) < 2 do
|
||||||
{:noreply, socket}
|
{:noreply, assign(socket, computing: false)}
|
||||||
else
|
else
|
||||||
# Run in a background task so the LV stays responsive
|
|
||||||
pid = self()
|
pid = self()
|
||||||
|
|
||||||
Task.start(fn ->
|
Task.start(fn ->
|
||||||
result = do_compute_coverage(stations, band_mhz)
|
try do
|
||||||
send(pid, {:coverage_result, result})
|
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)
|
end)
|
||||||
|
|
||||||
{:noreply, assign(socket, computing: true)}
|
{:noreply, assign(socket, computing: true)}
|
||||||
|
|
@ -181,171 +184,6 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_compute_coverage(stations, band_mhz) do
|
|
||||||
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
|
||||||
max_range = band_config.extended_range_km || 500
|
|
||||||
|
|
||||||
# Find the bounding box around all stations, expanded by max range
|
|
||||||
lats = Enum.map(stations, & &1.lat)
|
|
||||||
lons = Enum.map(stations, & &1.lon)
|
|
||||||
# ~1 degree ≈ 111 km
|
|
||||||
range_deg = max_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 candidate grids (4-char Maidenhead = 2° lon × 1° lat)
|
|
||||||
for_result =
|
|
||||||
for lat <- Stream.iterate(Float.ceil(min_lat), &(&1 + 0.5)),
|
|
||||||
lat <= max_lat,
|
|
||||||
lon <- Stream.iterate(Float.ceil(min_lon), &(&1 + 1.0)),
|
|
||||||
lon <= max_lon do
|
|
||||||
grid = Geo.latlon_to_grid4(lat, lon)
|
|
||||||
{grid, lat, lon}
|
|
||||||
end
|
|
||||||
|
|
||||||
candidates =
|
|
||||||
Enum.uniq_by(for_result, fn {grid, _, _} -> grid end)
|
|
||||||
|
|
||||||
# Score each candidate: how many stations in range × propagation quality
|
|
||||||
coverage =
|
|
||||||
candidates
|
|
||||||
|> Task.async_stream(
|
|
||||||
fn {grid, lat, lon} ->
|
|
||||||
score_grid(grid, lat, lon, stations, band_mhz, band_config, max_range)
|
|
||||||
end,
|
|
||||||
max_concurrency: System.schedulers_online(),
|
|
||||||
timeout: 30_000
|
|
||||||
)
|
|
||||||
|> Enum.flat_map(fn
|
|
||||||
{:ok, nil} -> []
|
|
||||||
{:ok, result} -> [result]
|
|
||||||
_ -> []
|
|
||||||
end)
|
|
||||||
|> Enum.sort_by(& &1.coverage_score, :desc)
|
|
||||||
|
|
||||||
coverage
|
|
||||||
end
|
|
||||||
|
|
||||||
# ── Coverage Scoring ──
|
|
||||||
|
|
||||||
defp score_grid(grid, lat, lon, stations, band_mhz, _band_config, max_range) do
|
|
||||||
freq_ghz = band_mhz / 1000
|
|
||||||
|
|
||||||
# Analyze terrain + distance to each station
|
|
||||||
station_analyses =
|
|
||||||
Enum.map(stations, fn s ->
|
|
||||||
dist = Geo.haversine_km(lat, lon, s.lat, s.lon)
|
|
||||||
in_range = dist <= max_range
|
|
||||||
|
|
||||||
# Run SRTM terrain analysis for in-range stations
|
|
||||||
{verdict, diffraction_db} =
|
|
||||||
if in_range do
|
|
||||||
case ElevationClient.fetch_elevation_profile(lat, lon, s.lat, s.lon, 64, download: true) do
|
|
||||||
{:ok, profile} ->
|
|
||||||
analysis = TerrainAnalysis.analyse(profile, dist, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0)
|
|
||||||
{analysis.verdict, analysis.diffraction_db}
|
|
||||||
|
|
||||||
{:error, _} ->
|
|
||||||
{nil, 0}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
{nil, 0}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Path quality combines terrain AND propagation conditions.
|
|
||||||
# BLOCKED paths are still workable with ducting/enhanced propagation —
|
|
||||||
# that's the whole point of this app. Higher propagation score at this
|
|
||||||
# grid means atmospheric conditions can overcome terrain blockage.
|
|
||||||
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
|
|
||||||
|
|
||||||
%{
|
|
||||||
label: s.label,
|
|
||||||
lat: s.lat,
|
|
||||||
lon: s.lon,
|
|
||||||
dist_km: dist,
|
|
||||||
in_range: in_range,
|
|
||||||
verdict: verdict,
|
|
||||||
diffraction_db: diffraction_db && Float.round(diffraction_db, 1),
|
|
||||||
path_quality: path_quality
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
in_range = Enum.filter(station_analyses, & &1.in_range)
|
|
||||||
|
|
||||||
if in_range == [] do
|
|
||||||
nil
|
|
||||||
else
|
|
||||||
# Path-quality-weighted station score: combines terrain + atmospheric potential
|
|
||||||
path_score = Enum.sum(Enum.map(in_range, & &1.path_quality)) / length(stations)
|
|
||||||
|
|
||||||
# Distance factor
|
|
||||||
distance_factor =
|
|
||||||
in_range
|
|
||||||
|> Enum.map(fn s -> max(0, 1.0 - s.dist_km / max_range) end)
|
|
||||||
|> then(&(Enum.sum(&1) / length(stations)))
|
|
||||||
|
|
||||||
# Propagation + ducting: best score in next 12 hours from HRRR forecast
|
|
||||||
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
|
|
||||||
|
|
||||||
# Combined: path quality (terrain + ducting potential) 30%
|
|
||||||
# + propagation forecast 30%
|
|
||||||
# + distance factor 20%
|
|
||||||
# + station count 20%
|
|
||||||
# Propagation score amplifies blocked-path viability: score 80+ means
|
|
||||||
# ducting conditions that can overcome 30+ dB of terrain blockage.
|
|
||||||
station_pct = length(in_range) / length(stations)
|
|
||||||
workable_count = Enum.count(in_range, &(&1.path_quality >= 0.2))
|
|
||||||
|
|
||||||
# Boost path_quality by propagation: good conditions make blocked paths viable
|
|
||||||
prop_boost = prop_score / 100
|
|
||||||
boosted_path_score = min(1.0, path_score + prop_boost * 0.3)
|
|
||||||
|
|
||||||
coverage_score =
|
|
||||||
round(
|
|
||||||
boosted_path_score * 30 +
|
|
||||||
prop_boost * 30 +
|
|
||||||
distance_factor * 20 +
|
|
||||||
station_pct * 20
|
|
||||||
)
|
|
||||||
|
|
||||||
%{
|
|
||||||
grid: grid,
|
|
||||||
lat: lat,
|
|
||||||
lon: lon,
|
|
||||||
coverage_score: coverage_score,
|
|
||||||
stations_in_range: length(in_range),
|
|
||||||
workable_count: workable_count,
|
|
||||||
prop_score: prop_score,
|
|
||||||
best_hour: best_hour,
|
|
||||||
station_details: station_analyses,
|
|
||||||
forecast: forecast
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ── Helpers ──
|
# ── Helpers ──
|
||||||
|
|
||||||
defp resolve_station(input) do
|
defp resolve_station(input) do
|
||||||
|
|
|
||||||
95
test/microwaveprop/rover/coverage_test.exs
Normal file
95
test/microwaveprop/rover/coverage_test.exs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
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