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 # Below this frequency free-space loss dominates over atmospheric # loss, so `rx_dbm` exceeds the detection floor across tens of # thousands of km². On a 1 W / 432 MHz beacon the estimate produced # ~126k cells — Jason-encoding that into the map's data attribute # locked up browsers in prod. Return an empty estimate below the # threshold so the coverage toggle (also gated at 5760 MHz in the # LiveView) never has a payload to render. @min_supported_mhz 5760 @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(%{frequency_mhz: freq_mhz} = beacon) when freq_mhz < @min_supported_mhz do empty_estimate(beacon) end 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 defp empty_estimate(beacon) do band_mhz = nearest_band_mhz(beacon.frequency_mhz) band_config = BandConfig.get(band_mhz) %{ beacon_id: beacon.id, band_mhz: band_mhz, band_label: band_config && band_config.label, center_score: 50, valid_time: nil, eirp_dbm: Float.round(mw_to_dbm(beacon.power_mw || 0.0), 1), atm_per_km: 0.0, grid_step: @grid_step, max_range_km: 0.0, 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