- Replace MapSet with plain list + `in` (features.ex, scorer_diff.ex) - Remove undefined Beacon.t() type reference (range_estimate.ex) - Remove dead else branch in find_region (inversion.ex) - Handle Nx special values in to_float catch-all (recalibrator.ex) - Remove unreachable catch-all clauses (hrrr_native_client.ex, ncei_metar_client.ex) - Remove unnecessary nil guards on always-typed values (show.ex) - Remove dead sky_note/wind_note non-nil clauses (show.ex) - Remove dead if-guard on always-truthy derive result (hrrr_native_derive.ex) - Add @spec to path_integrated_conditions (scorer.ex)
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: "#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
|
|
|
|
@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(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
|