prop/lib/microwaveprop/beacons/range_estimate.ex
Graham McIntire a5b3f1f3da Beacon detail map with range estimate and on_the_air flag
- 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.
2026-04-08 13:29:58 -05:00

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