For a 1 W / 432 MHz beacon, RangeEstimate.estimate/1 was returning ~126 k cells because free-space loss dominates over atmospheric loss at low UHF, letting the rx_dbm filter pass almost the whole bbox. Jason-encoding that payload into the map's data-cells attribute was locking up browsers on the beacon detail page for low-band beacons even though the coverage toggle is already disabled for them. Return an empty estimate below the same 5760 MHz threshold the UI already enforces.
245 lines
8.3 KiB
Elixir
245 lines
8.3 KiB
Elixir
defmodule Microwaveprop.Beacons.RangeEstimate do
|
|
@moduledoc """
|
|
Estimates a beacon's reception pattern across the HRRR propagation grid.
|
|
|
|
For every 0.125° grid cell within range of the beacon we solve a link budget
|
|
|
|
Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d - score_adj_db
|
|
|
|
where `d` is the great-circle distance between the beacon and the cell,
|
|
`EIRP_dBm` comes from the beacon's stored `power_mw` (the field already
|
|
represents EIRP), and `score_adj_db` is derived from the HRRR propagation
|
|
score at the cell: score 50 = no adjustment, score 100 = -15 dB (ducting
|
|
boost), score 0 = +15 dB (absorption / poor conditions). This yields a
|
|
realistic per-cell reception map instead of idealised concentric circles.
|
|
"""
|
|
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
|
|
# Signal-strength tiers and their RX sensitivity thresholds (dBm).
|
|
@tiers [
|
|
%{label: "Excellent", min_dbm: -100, color: "#059669"},
|
|
%{label: "Good", min_dbm: -115, color: "#0d9488"},
|
|
%{label: "Marginal", min_dbm: -125, color: "#ca8a04"},
|
|
%{label: "Weak CW", min_dbm: -135, color: "#ea580c"},
|
|
%{label: "Detection", min_dbm: -145, color: "#dc2626"}
|
|
]
|
|
|
|
# Minimum received signal to render a cell.
|
|
@detection_floor_dbm -145
|
|
|
|
# Assume the receiving station is an average amateur microwave station.
|
|
@rx_gain_dbi 20.0
|
|
|
|
# HRRR grid step (degrees).
|
|
@grid_step 0.125
|
|
|
|
# Below this frequency free-space loss dominates over atmospheric
|
|
# loss, so `rx_dbm` exceeds the detection floor across tens of
|
|
# thousands of km². On a 1 W / 432 MHz beacon the estimate produced
|
|
# ~126k cells — Jason-encoding that into the map's data attribute
|
|
# locked up browsers in prod. Return an empty estimate below the
|
|
# threshold so the coverage toggle (also gated at 5760 MHz in the
|
|
# LiveView) never has a payload to render.
|
|
@min_supported_mhz 5760
|
|
|
|
@doc """
|
|
Convert a power in milliwatts to dBm. Returns `-999.9` for non-positive input.
|
|
"""
|
|
@spec mw_to_dbm(number()) :: float()
|
|
def mw_to_dbm(mw) when is_number(mw) and mw > 0, do: 10.0 * :math.log10(mw)
|
|
def mw_to_dbm(_), do: -999.9
|
|
|
|
@doc """
|
|
Returns the closest configured band frequency (in MHz) to the given beacon
|
|
frequency. e.g. `nearest_band_mhz(10368.1) == 10_000`.
|
|
"""
|
|
@spec nearest_band_mhz(number()) :: integer()
|
|
def nearest_band_mhz(freq_mhz) when is_number(freq_mhz) do
|
|
Enum.min_by(BandConfig.all_freqs(), fn b -> abs(b - freq_mhz) end)
|
|
end
|
|
|
|
@doc """
|
|
Estimate a beacon's reception footprint as a list of per-HRRR-cell samples.
|
|
|
|
Returns a map with band info, EIRP, the HRRR valid_time, the score at the
|
|
beacon's own grid cell (for display), plus a `cells` list of all grid
|
|
points within range where the estimated received signal exceeds the
|
|
detection floor.
|
|
"""
|
|
@spec estimate(struct()) :: map()
|
|
def estimate(%{frequency_mhz: freq_mhz} = beacon) when freq_mhz < @min_supported_mhz do
|
|
empty_estimate(beacon)
|
|
end
|
|
|
|
def estimate(beacon) do
|
|
band_mhz = nearest_band_mhz(beacon.frequency_mhz)
|
|
band_config = BandConfig.get(band_mhz)
|
|
|
|
detail = Propagation.point_detail(band_mhz, beacon.lat, beacon.lon)
|
|
center_score = (detail && detail.score) || 50
|
|
valid_time = detail && detail.valid_time
|
|
|
|
f_mhz = beacon.frequency_mhz * 1.0
|
|
eirp_dbm = mw_to_dbm(beacon.power_mw || 0.0)
|
|
atm_per_km = atm_loss_per_km(band_config)
|
|
|
|
# Pull scores within a bounding box big enough to cover the weakest tier
|
|
# under ideal conditions. Use the band's exceptional range * 1.5 with a
|
|
# hard floor so small beacons still get a sensible area.
|
|
max_range_km = max((band_config && band_config.exceptional_range_km * 1.5) || 600.0, 150.0)
|
|
|
|
bounds = bbox(beacon.lat, beacon.lon, max_range_km)
|
|
score_map = fetch_score_map(band_mhz, valid_time, bounds)
|
|
|
|
cells =
|
|
bounds
|
|
|> grid_points()
|
|
|> Enum.map(fn {lat, lon} ->
|
|
key = {Float.round(lat, 3), Float.round(lon, 3)}
|
|
score = Map.get(score_map, key, 50)
|
|
d_km = haversine_km(beacon.lat, beacon.lon, lat, lon)
|
|
rx_dbm = received_dbm(eirp_dbm, f_mhz, atm_per_km, d_km, score)
|
|
|
|
{lat, lon, d_km, score, rx_dbm}
|
|
end)
|
|
|> Enum.filter(fn {_lat, _lon, _d, _score, rx_dbm} ->
|
|
rx_dbm >= @detection_floor_dbm
|
|
end)
|
|
|> Enum.map(fn {lat, lon, d_km, score, rx_dbm} ->
|
|
tier = tier_for(rx_dbm)
|
|
|
|
%{
|
|
lat: Float.round(lat, 3),
|
|
lon: Float.round(lon, 3),
|
|
distance_km: Float.round(d_km, 1),
|
|
score: score,
|
|
rx_dbm: round(rx_dbm),
|
|
label: tier.label,
|
|
color: tier.color
|
|
}
|
|
end)
|
|
|
|
%{
|
|
beacon_id: beacon.id,
|
|
band_mhz: band_mhz,
|
|
band_label: band_config && band_config.label,
|
|
center_score: center_score,
|
|
valid_time: valid_time,
|
|
eirp_dbm: Float.round(eirp_dbm, 1),
|
|
atm_per_km: Float.round(atm_per_km, 3),
|
|
grid_step: @grid_step,
|
|
max_range_km: Float.round(max_range_km, 0),
|
|
cells: cells,
|
|
tiers: @tiers
|
|
}
|
|
end
|
|
|
|
defp empty_estimate(beacon) do
|
|
band_mhz = nearest_band_mhz(beacon.frequency_mhz)
|
|
band_config = BandConfig.get(band_mhz)
|
|
|
|
%{
|
|
beacon_id: beacon.id,
|
|
band_mhz: band_mhz,
|
|
band_label: band_config && band_config.label,
|
|
center_score: 50,
|
|
valid_time: nil,
|
|
eirp_dbm: Float.round(mw_to_dbm(beacon.power_mw || 0.0), 1),
|
|
atm_per_km: 0.0,
|
|
grid_step: @grid_step,
|
|
max_range_km: 0.0,
|
|
cells: [],
|
|
tiers: @tiers
|
|
}
|
|
end
|
|
|
|
# --- path loss ------------------------------------------------------------
|
|
|
|
defp received_dbm(_eirp, _f, _atm, +0.0, _score), do: 999.0
|
|
|
|
defp received_dbm(eirp_dbm, f_mhz, atm_per_km, d_km, score) do
|
|
fspl = 20.0 * :math.log10(d_km) + 20.0 * :math.log10(f_mhz) + 32.44
|
|
atm = atm_per_km * d_km
|
|
# Score 50 baseline, ±0.3 dB per score point away from 50.
|
|
# score 100 → -15 dB (ducting), score 0 → +15 dB (poor).
|
|
score_adj = (50 - score) * 0.3
|
|
eirp_dbm + @rx_gain_dbi - fspl - atm - score_adj
|
|
end
|
|
|
|
defp tier_for(rx_dbm) do
|
|
Enum.find(@tiers, fn t -> rx_dbm >= t.min_dbm end) || List.last(@tiers)
|
|
end
|
|
|
|
# --- atmosphere -----------------------------------------------------------
|
|
|
|
# dB per km atmospheric attenuation from O2 + water vapor. The HRRR score
|
|
# already captures humidity variability, so we use a fixed absolute humidity
|
|
# of 10 g/m³ here to keep the physics layer clean.
|
|
defp atm_loss_per_km(nil), do: 0.0
|
|
|
|
defp atm_loss_per_km(band_config) do
|
|
o2 = Map.get(band_config, :o2_db_km, 0.0)
|
|
h2o_coeff = Map.get(band_config, :h2o_coeff, 0.0)
|
|
o2 + h2o_coeff * 10.0
|
|
end
|
|
|
|
# --- geometry -------------------------------------------------------------
|
|
|
|
@earth_radius_km 6371.0
|
|
|
|
defp haversine_km(lat1, lon1, lat2, lon2) do
|
|
dlat = :math.pi() * (lat2 - lat1) / 180.0
|
|
dlon = :math.pi() * (lon2 - lon1) / 180.0
|
|
rlat1 = :math.pi() * lat1 / 180.0
|
|
rlat2 = :math.pi() * lat2 / 180.0
|
|
|
|
a =
|
|
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
|
|
:math.cos(rlat1) * :math.cos(rlat2) *
|
|
:math.sin(dlon / 2) * :math.sin(dlon / 2)
|
|
|
|
c = 2.0 * :math.atan2(:math.sqrt(a), :math.sqrt(1.0 - a))
|
|
@earth_radius_km * c
|
|
end
|
|
|
|
defp bbox(lat, lon, range_km) do
|
|
dlat = range_km / 111.0
|
|
dlon = range_km / (111.0 * :math.cos(:math.pi() * lat / 180.0))
|
|
|
|
%{
|
|
"south" => lat - dlat,
|
|
"north" => lat + dlat,
|
|
"west" => lon - dlon,
|
|
"east" => lon + dlon
|
|
}
|
|
end
|
|
|
|
defp grid_points(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
|
|
# Snap bounds to the HRRR grid step so cells line up with propagation_scores rows.
|
|
lat_start = Float.round(s / @grid_step) * @grid_step
|
|
lon_start = Float.round(w / @grid_step) * @grid_step
|
|
|
|
lat_count = max(round((n - lat_start) / @grid_step) + 1, 1)
|
|
lon_count = max(round((e - lon_start) / @grid_step) + 1, 1)
|
|
|
|
for i <- 0..(lat_count - 1),
|
|
j <- 0..(lon_count - 1) do
|
|
{Float.round(lat_start + i * @grid_step, 3), Float.round(lon_start + j * @grid_step, 3)}
|
|
end
|
|
end
|
|
|
|
# --- score lookup ---------------------------------------------------------
|
|
|
|
defp fetch_score_map(band_mhz, valid_time, bounds) do
|
|
scores =
|
|
if valid_time do
|
|
Propagation.scores_at(band_mhz, valid_time, bounds)
|
|
else
|
|
Propagation.latest_scores(band_mhz, bounds)
|
|
end
|
|
|
|
Map.new(scores, fn s -> {{Float.round(s.lat, 3), Float.round(s.lon, 3)}, s.score} end)
|
|
end
|
|
end
|