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)}
>
-