- Add on_the_air boolean to beacons (default true); surfaced as a checkbox on the form, a badge on the index, and in the detail list. - Render a Leaflet map on the beacon show page with a marker at the beacon's lat/lon tooltipped with callsign + frequency. Marker is green when on air, gray when off. - Compute and draw a reception-range estimate as concentric signal- strength rings. New Microwaveprop.Beacons.RangeEstimate solves a link budget (FSPL + O2/H2O absorption from BandConfig) at five RX thresholds (-100 to -145 dBm), then scales by 0.5 + score/100 from the latest Propagation.point_detail at the beacon's grid square, so current HRRR conditions shift the rings in or out. - Re-enable the hourly PropagationGridWorker cron and freshness monitor in dev.exs so dev actually has HRRR-backed scores to feed the new estimator.
145 lines
4.8 KiB
Elixir
145 lines
4.8 KiB
Elixir
defmodule Microwaveprop.Beacons.RangeEstimate do
|
|
@moduledoc """
|
|
Estimates a beacon's reception range at several signal-strength tiers.
|
|
|
|
For each tier we solve a link budget of
|
|
|
|
Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d
|
|
|
|
for the distance `d` at which the received power equals the tier threshold.
|
|
Free-space path loss uses the standard formula and atmospheric absorption
|
|
comes from the band's O₂/H₂O coefficients in `BandConfig`.
|
|
|
|
The result is then multiplied by a propagation-score factor derived from the
|
|
latest `propagation_scores` row at the beacon's lat/lon — score 50 → 1.0x,
|
|
score 0 → 0.5x, score 100 → 1.5x — so current HRRR conditions shift the
|
|
rings in or out.
|
|
"""
|
|
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
|
|
# Signal-strength tiers and their RX sensitivity thresholds (dBm).
|
|
# Colors match the map_live / propagation_map_hook palette.
|
|
@tiers [
|
|
%{label: "Excellent", rx_dbm: -100, color: "#00ffa3"},
|
|
%{label: "Good", rx_dbm: -115, color: "#7dffd4"},
|
|
%{label: "Marginal", rx_dbm: -125, color: "#ffe566"},
|
|
%{label: "Weak CW", rx_dbm: -135, color: "#ff9044"},
|
|
%{label: "Detection", rx_dbm: -145, color: "#ff4f4f"}
|
|
]
|
|
|
|
# Assume the receiving station is an average amateur microwave station
|
|
# (dish/horn + low-noise preamp).
|
|
@rx_gain_dbi 20.0
|
|
# Beacons are typically omnidirectional.
|
|
@tx_gain_dbi 0.0
|
|
|
|
@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
|
|
BandConfig.all_freqs()
|
|
|> Enum.min_by(fn b -> abs(b - freq_mhz) end)
|
|
end
|
|
|
|
@doc """
|
|
Estimate a beacon's reception range.
|
|
|
|
Returns a map with band info, current score, and a list of rings sorted
|
|
weakest-distance first (strongest RX tier → shortest radius).
|
|
"""
|
|
@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)
|
|
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) + @tx_gain_dbi
|
|
|
|
atm_per_km = atm_loss_per_km(band_config)
|
|
score_mult = 0.5 + score / 100.0
|
|
|
|
rings =
|
|
@tiers
|
|
|> Enum.map(fn tier ->
|
|
d_phys = solve_range(eirp_dbm, @rx_gain_dbi, tier.rx_dbm, f_mhz, atm_per_km)
|
|
radius = Float.round(d_phys * score_mult, 1)
|
|
|
|
%{
|
|
label: tier.label,
|
|
rx_dbm: tier.rx_dbm,
|
|
color: tier.color,
|
|
radius_km: radius
|
|
}
|
|
end)
|
|
|> Enum.filter(fn ring -> ring.radius_km > 0.5 end)
|
|
|> Enum.sort_by(& &1.radius_km)
|
|
|
|
%{
|
|
beacon_id: beacon.id,
|
|
band_mhz: band_mhz,
|
|
band_label: band_config && band_config.label,
|
|
score: score,
|
|
score_mult: Float.round(score_mult, 2),
|
|
valid_time: valid_time,
|
|
eirp_dbm: Float.round(eirp_dbm, 1),
|
|
atm_per_km: Float.round(atm_per_km, 3),
|
|
rings: rings
|
|
}
|
|
end
|
|
|
|
# Total dB/km atmospheric attenuation from O2 + water vapor. Uses a moderate
|
|
# absolute humidity of 10 g/m³ as a default — the HRRR-derived score already
|
|
# captures humidity variability, so using a fixed value here keeps the
|
|
# physics 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
|
|
|
|
# Solve `FSPL(d) + atm_per_km * d = budget_db` for d via bisection.
|
|
# `budget_db = EIRP + Rx_gain - threshold`.
|
|
defp solve_range(eirp_dbm, rx_gain, threshold_dbm, f_mhz, atm_per_km) do
|
|
budget = eirp_dbm + rx_gain - threshold_dbm
|
|
log_f_const = 20.0 * :math.log10(f_mhz) + 32.44
|
|
|
|
cond do
|
|
budget <= log_f_const ->
|
|
# Even at 1 km the budget is already negative → ring is effectively 0.
|
|
0.0
|
|
|
|
true ->
|
|
bisect(0.01, 5000.0, budget, log_f_const, atm_per_km, 50)
|
|
end
|
|
end
|
|
|
|
defp bisect(lo, hi, _budget, _log_f, _atm, 0), do: (lo + hi) / 2.0
|
|
|
|
defp bisect(lo, hi, budget, log_f, atm, iters) do
|
|
mid = (lo + hi) / 2.0
|
|
val = 20.0 * :math.log10(mid) + log_f + atm * mid
|
|
|
|
if val > budget do
|
|
bisect(lo, mid, budget, log_f, atm, iters - 1)
|
|
else
|
|
bisect(mid, hi, budget, log_f, atm, iters - 1)
|
|
end
|
|
end
|
|
end
|