The power_mw field represents the beacon's effective radiated power, not just transmitter output. Relabel it as "TX power (EIRP)" on the form and detail list, "EIRP (mW)" in the index, and drop the now- meaningless tx_gain_dbi constant in RangeEstimate since the stored value already includes antenna gain.
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.
|
||
`EIRP_dBm` comes directly from the beacon's stored `power_mw` — the field
|
||
already represents EIRP (TX power × antenna gain). 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
|
||
|
||
@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)
|
||
|
||
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
|