From cda54e351486c008ea4b828daf5405e3b03c06b6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 8 Apr 2026 13:42:44 -0500 Subject: [PATCH] Per-HRRR-cell beacon reception plot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the idealised concentric-circle range rings with a realistic per-HRRR-grid-cell reception map. For every 0.125° grid point within the band's exceptional range, the estimator computes great-circle distance, FSPL + atmospheric loss, and applies a cell-specific score adjustment of (50 - score) * 0.3 dB — score 100 gives a 15 dB ducting boost, score 0 a 15 dB penalty. Cells below the -145 dBm detection floor are dropped. The beacon map hook now renders each surviving cell as a 0.125° filled rectangle with a tooltip showing tier, Rx dBm, distance, and HRRR score, so the coverage footprint bulges where ducting conditions are good and cuts off where they aren't, instead of being a perfect circle. Falls back to score 50 for cells that don't have HRRR data yet, so the plot is always useful. Also fixes the prior all-grey-tiles regression by restoring an explicit setView() at map creation and adding a post-mount invalidateSize() for when the map is placed inside a flex container. --- assets/js/beacon_map_hook.js | 54 ++--- lib/microwaveprop/beacons/range_estimate.ex | 197 ++++++++++++------ .../live/beacon_live/show.ex | 21 +- .../beacons/range_estimate_test.exs | 68 +++--- 4 files changed, 218 insertions(+), 122 deletions(-) diff --git a/assets/js/beacon_map_hook.js b/assets/js/beacon_map_hook.js index 0afa8f98..4b6d4455 100644 --- a/assets/js/beacon_map_hook.js +++ b/assets/js/beacon_map_hook.js @@ -4,40 +4,46 @@ export const BeaconMap = { const lon = parseFloat(this.el.dataset.lon) const label = this.el.dataset.label || "" const onTheAir = this.el.dataset.onTheAir === "true" - const rings = JSON.parse(this.el.dataset.rings || "[]") + const cells = JSON.parse(this.el.dataset.cells || "[]") + const step = parseFloat(this.el.dataset.gridStep || "0.125") const map = L.map(this.el, { zoomControl: true, attributionControl: false, scrollWheelZoom: false - }) + }).setView([lat, lon], 9) L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19 }).addTo(map) - // Draw rings outermost-first so stronger tiers stack on top. - const sorted = [...rings].sort((a, b) => b.radius_km - a.radius_km) - const drawn = [] - for (const ring of sorted) { - if (!ring.radius_km || ring.radius_km <= 0) continue - const circle = L.circle([lat, lon], { - radius: ring.radius_km * 1000, - color: ring.color, - weight: 1.5, - opacity: 0.9, - fillColor: ring.color, - fillOpacity: 0.12 - }).addTo(map) - circle.bindTooltip(`${ring.label}: ${ring.rx_dbm} dBm · ${ring.radius_km} km`, { - sticky: true + // Draw each HRRR grid cell as a filled rectangle colored by its received + // signal tier. Use a single layerGroup so zoom/pan stays fast. + const cellLayer = L.layerGroup().addTo(map) + const half = step / 2.0 + + const allBounds = [] + for (const c of cells) { + const sw = [c.lat - half, c.lon - half] + const ne = [c.lat + half, c.lon + half] + const rect = L.rectangle([sw, ne], { + color: c.color, + weight: 0, + fillColor: c.color, + fillOpacity: 0.55, + interactive: true }) - drawn.push(circle) + rect.bindTooltip( + `${c.label}
${c.rx_dbm} dBm
${c.distance_km} km · score ${c.score}`, + {sticky: true} + ) + rect.addTo(cellLayer) + allBounds.push(sw, ne) } + // Beacon marker on top. const markerColor = onTheAir ? "#16a34a" : "#94a3b8" const markerFill = onTheAir ? "#22c55e" : "#cbd5e1" - L.circleMarker([lat, lon], { radius: 7, color: markerColor, @@ -50,11 +56,11 @@ export const BeaconMap = { offset: [0, -10] }) - // Fit to the largest ring, or fall back to a reasonable zoom around the point. - if (drawn.length > 0) { - map.fitBounds(drawn[0].getBounds(), {padding: [20, 20]}) - } else { - map.setView([lat, lon], 9) + // Fit to the rendered cells (initial view already set above). + if (allBounds.length > 0) { + map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]}) } + + setTimeout(() => map.invalidateSize(), 50) } } diff --git a/lib/microwaveprop/beacons/range_estimate.ex b/lib/microwaveprop/beacons/range_estimate.ex index 532cc408..edb59bf4 100644 --- a/lib/microwaveprop/beacons/range_estimate.ex +++ b/lib/microwaveprop/beacons/range_estimate.ex @@ -1,40 +1,40 @@ defmodule Microwaveprop.Beacons.RangeEstimate do @moduledoc """ - Estimates a beacon's reception range at several signal-strength tiers. + Estimates a beacon's reception pattern across the HRRR propagation grid. - For each tier we solve a link budget of + 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 + Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d - score_adj_db - 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. + 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). - # 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"} + %{label: "Excellent", min_dbm: -100, color: "#00ffa3"}, + %{label: "Good", min_dbm: -115, color: "#7dffd4"}, + %{label: "Marginal", min_dbm: -125, color: "#ffe566"}, + %{label: "Weak CW", min_dbm: -135, color: "#ff9044"}, + %{label: "Detection", min_dbm: -145, color: "#ff4f4f"} ] - # Assume the receiving station is an average amateur microwave station - # (dish/horn + low-noise preamp). + # 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. """ @@ -53,10 +53,12 @@ defmodule Microwaveprop.Beacons.RangeEstimate do end @doc """ - Estimate a beacon's reception range. + Estimate a beacon's reception footprint as a list of per-HRRR-cell samples. - Returns a map with band info, current score, and a list of rings sorted - weakest-distance first (strongest RX tier → shortest radius). + 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(Microwaveprop.Beacons.Beacon.t()) :: map() def estimate(beacon) do @@ -64,48 +66,86 @@ defmodule Microwaveprop.Beacons.RangeEstimate do band_config = BandConfig.get(band_mhz) detail = Propagation.point_detail(band_mhz, beacon.lat, beacon.lon) - score = (detail && detail.score) || 50 + 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) - 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) + # 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, - rx_dbm: tier.rx_dbm, - color: tier.color, - radius_km: radius + color: tier.color } 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), + center_score: center_score, valid_time: valid_time, eirp_dbm: Float.round(eirp_dbm, 1), atm_per_km: Float.round(atm_per_km, 3), - rings: rings + grid_step: @grid_step, + max_range_km: Float.round(max_range_km, 0), + cells: cells, + tiers: @tiers } 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. + # --- 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 @@ -114,32 +154,61 @@ defmodule Microwaveprop.Beacons.RangeEstimate do 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 + # --- geometry ------------------------------------------------------------- - cond do - budget <= log_f_const -> - # Even at 1 km the budget is already negative → ring is effectively 0. - 0.0 + @earth_radius_km 6371.0 - true -> - bisect(0.01, 5000.0, budget, log_f_const, atm_per_km, 50) + 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 - defp bisect(lo, hi, _budget, _log_f, _atm, 0), do: (lo + hi) / 2.0 + # --- score lookup --------------------------------------------------------- - 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 + 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 - if val > budget do - bisect(lo, mid, budget, log_f, atm, iters - 1) - else - bisect(mid, hi, budget, log_f, atm, iters - 1) - end + Map.new(scores, fn s -> {{Float.round(s.lat, 3), Float.round(s.lon, 3)}, s.score} end) end end diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex index f56e63b4..7b097ab1 100644 --- a/lib/microwaveprop_web/live/beacon_live/show.ex +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -36,38 +36,39 @@ defmodule MicrowavepropWeb.BeaconLive.Show do data-lon={@beacon.lon} data-label={"#{@beacon.callsign} · #{@beacon.frequency_mhz} MHz"} data-on-the-air={to_string(@beacon.on_the_air)} - data-rings={Jason.encode!(@estimate.rings)} + data-grid-step={@estimate.grid_step} + data-cells={Jason.encode!(@estimate.cells)} > -
+
Band {@estimate.band_label} · EIRP {@estimate.eirp_dbm} dBm · Atm loss {@estimate.atm_per_km} dB/km - Prop score {@estimate.score} - ({@estimate.score_mult}× range) + Beacon grid score {@estimate.center_score} <%= if @estimate.valid_time do %> at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")} <% else %> - (no HRRR data — using default 50) + (no HRRR data — defaulting cells to 50) <% end %> + · {length(@estimate.cells)} cells rendered
- <%= for ring <- @estimate.rings do %> + <%= for tier <- @estimate.tiers do %>
- {ring.label} - ({ring.rx_dbm} dBm) → {ring.radius_km} km + {tier.label} + (≥ {tier.min_dbm} dBm)
<% end %> diff --git a/test/microwaveprop/beacons/range_estimate_test.exs b/test/microwaveprop/beacons/range_estimate_test.exs index 3fdfd703..d34b5f53 100644 --- a/test/microwaveprop/beacons/range_estimate_test.exs +++ b/test/microwaveprop/beacons/range_estimate_test.exs @@ -49,48 +49,68 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do end describe "estimate/1" do - test "returns a map with band info, score, and a list of rings" do + test "returns a map with band info, center score, and per-grid cells" do result = RangeEstimate.estimate(beacon()) assert result.band_mhz == 10_000 - assert is_integer(result.score) or is_float(result.score) + assert is_integer(result.center_score) or is_float(result.center_score) assert is_float(result.eirp_dbm) - assert is_list(result.rings) - assert length(result.rings) > 0 + assert is_list(result.cells) + assert length(result.cells) > 0 + assert result.grid_step == 0.125 + assert is_list(result.tiers) end - test "rings are ordered strongest-to-weakest with increasing radius" do - result = RangeEstimate.estimate(beacon()) - radii = Enum.map(result.rings, & &1.radius_km) - assert radii == Enum.sort(radii) - end - - test "each ring carries a label, color, threshold, and radius_km" do + test "each cell carries lat/lon, distance, rx_dbm, tier label/color" do result = RangeEstimate.estimate(beacon()) - for ring <- result.rings do - assert is_binary(ring.label) - assert is_binary(ring.color) - assert is_number(ring.rx_dbm) - assert is_float(ring.radius_km) - assert ring.radius_km >= 0.0 + for cell <- result.cells do + assert is_float(cell.lat) + assert is_float(cell.lon) + assert is_float(cell.distance_km) + assert is_integer(cell.rx_dbm) + assert is_binary(cell.label) + assert is_binary(cell.color) + # Detection floor enforced + assert cell.rx_dbm >= -145 end end - test "a more powerful beacon produces larger rings" do + test "cells closer to the beacon have stronger rx_dbm" do + result = RangeEstimate.estimate(beacon()) + + [closest | _] = Enum.sort_by(result.cells, & &1.distance_km) + [farthest | _] = Enum.sort_by(result.cells, &(-&1.distance_km)) + + assert closest.rx_dbm > farthest.rx_dbm + end + + test "a more powerful beacon produces more cells" do weak = RangeEstimate.estimate(beacon(power_mw: 10.0)) strong = RangeEstimate.estimate(beacon(power_mw: 10_000.0)) - - weakest_weak = List.last(weak.rings).radius_km - weakest_strong = List.last(strong.rings).radius_km - assert weakest_strong > weakest_weak + assert length(strong.cells) > length(weak.cells) end test "uses a default score of 50 when no propagation data exists" do result = RangeEstimate.estimate(beacon()) - # No scores in test DB → score defaults - assert result.score == 50 + # No scores in test DB → center defaults, cells all use 50. + assert result.center_score == 50 assert result.valid_time == nil + + # All cells should have score 50 when there's no HRRR data. + assert Enum.all?(result.cells, fn c -> c.score == 50 end) + end + + test "cells align with the 0.125° HRRR grid" do + result = RangeEstimate.estimate(beacon()) + + for cell <- result.cells do + # lat and lon should both be multiples of 0.125 + lat_mod = abs(cell.lat / 0.125 - round(cell.lat / 0.125)) + lon_mod = abs(cell.lon / 0.125 - round(cell.lon / 0.125)) + assert lat_mod < 0.01 + assert lon_mod < 0.01 + end end end end