Non-admin users can now submit beacons but they are held in an unapproved state until an admin approves them. Only approved beacons appear in the public list; pending submissions are shown in a separate admin-only section on /beacons with Approve and Delete actions. The show page surfaces a pending badge and an admin-only Approve button when viewing an unapproved beacon. Also formats EIRP (mW) on the index page without scientific notation.
213 lines
7.3 KiB
Elixir
213 lines
7.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: "#00ffa3"},
|
|
%{label: "Good", min_dbm: -115, color: "#7dffd4"},
|
|
%{label: "Marginal", min_dbm: -125, color: "#ffe566"},
|
|
%{label: "Weak CW", min_dbm: -135, color: "#ff9044"},
|
|
%{label: "Detection", min_dbm: -145, color: "#ff4f4f"}
|
|
]
|
|
|
|
# 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
|
|
|
|
@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(Microwaveprop.Beacons.Beacon.t()) :: map()
|
|
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
|
|
|
|
# --- 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
|