prop/lib/microwaveprop/beacons/range_estimate.ex
Graham McIntire 1174ecd9e5 Fix all 12 dialyzer warnings
- 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)
2026-04-11 18:08:18 -05:00

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