Beacon detail map with range estimate and on_the_air flag

- Add on_the_air boolean to beacons (default true); surfaced as a
  checkbox on the form, a badge on the index, and in the detail list.
- Render a Leaflet map on the beacon show page with a marker at the
  beacon's lat/lon tooltipped with callsign + frequency. Marker is
  green when on air, gray when off.
- Compute and draw a reception-range estimate as concentric signal-
  strength rings. New Microwaveprop.Beacons.RangeEstimate solves a
  link budget (FSPL + O2/H2O absorption from BandConfig) at five RX
  thresholds (-100 to -145 dBm), then scales by 0.5 + score/100 from
  the latest Propagation.point_detail at the beacon's grid square, so
  current HRRR conditions shift the rings in or out.
- Re-enable the hourly PropagationGridWorker cron and freshness
  monitor in dev.exs so dev actually has HRRR-backed scores to feed
  the new estimator.
This commit is contained in:
Graham McIntire 2026-04-08 13:29:44 -05:00
parent 374f9b106a
commit a5b3f1f3da
11 changed files with 391 additions and 7 deletions

View file

@ -37,6 +37,7 @@ import {ElevationProfile} from "./elevation_profile_hook"
import {WeatherMap} from "./weather_map_hook"
import {LocateMe, CopyLink} from "./locate_me_hook"
import {RoverMap} from "./rover_map_hook"
import {BeaconMap} from "./beacon_map_hook"
const UtcClock = {
mounted() {
@ -58,7 +59,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: initLiveStash({_csrf_token: csrfToken}),
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap},
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap},
})
// Show progress bar on live navigation and form submits

View file

@ -0,0 +1,60 @@
export const BeaconMap = {
mounted() {
const lat = parseFloat(this.el.dataset.lat)
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 map = L.map(this.el, {
zoomControl: true,
attributionControl: false,
scrollWheelZoom: false
})
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
})
drawn.push(circle)
}
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
L.circleMarker([lat, lon], {
radius: 7,
color: markerColor,
fillColor: markerFill,
fillOpacity: 1.0,
weight: 2
}).addTo(map).bindTooltip(label, {
permanent: true,
direction: "top",
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)
}
}
}

View file

@ -87,7 +87,7 @@ config :microwaveprop, Oban,
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
{Oban.Plugins.Cron,
crontab: [
# {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker}
]}
]
@ -106,8 +106,8 @@ config :microwaveprop, load_ml_model: true
# Use local SRTM1 tiles for elevation lookups instead of the Open-Meteo API
config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles")
# Freshness monitor disabled in dev (no hourly propagation grid running)
config :microwaveprop, start_freshness_monitor: false
# Freshness monitor enabled (hourly PropagationGridWorker runs in dev)
config :microwaveprop, start_freshness_monitor: true
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime

View file

@ -21,6 +21,7 @@ defmodule Microwaveprop.Beacons.Beacon do
field :lon, :float
field :power_mw, :float
field :height_ft, :float
field :on_the_air, :boolean, default: true
field :user_id, :binary_id
timestamps(type: :utc_datetime)
@ -30,7 +31,7 @@ defmodule Microwaveprop.Beacons.Beacon do
def changeset(beacon, attrs) do
beacon
|> cast(attrs, [:frequency_mhz, :callsign, :grid, :lat, :lon, :power_mw, :height_ft])
|> cast(attrs, [:frequency_mhz, :callsign, :grid, :lat, :lon, :power_mw, :height_ft, :on_the_air])
|> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
|> maybe_fill_latlon()
|> maybe_fill_grid()

View file

@ -0,0 +1,145 @@
defmodule Microwaveprop.Beacons.RangeEstimate do
@moduledoc """
Estimates a beacon's reception range at several signal-strength tiers.
For each tier we solve a link budget of
Rx_dBm = EIRP_dBm + Rx_gain_dBi - FSPL(d, f) - atm_loss_per_km * d
for the distance `d` at which the received power equals the tier threshold.
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.
"""
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"}
]
# Assume the receiving station is an average amateur microwave station
# (dish/horn + low-noise preamp).
@rx_gain_dbi 20.0
# Beacons are typically omnidirectional.
@tx_gain_dbi 0.0
@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
BandConfig.all_freqs()
|> Enum.min_by(fn b -> abs(b - freq_mhz) end)
end
@doc """
Estimate a beacon's reception range.
Returns a map with band info, current score, and a list of rings sorted
weakest-distance first (strongest RX tier shortest radius).
"""
@spec estimate(Microwaveprop.Beacons.Beacon.t()) :: 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)
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) + @tx_gain_dbi
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)
%{
label: tier.label,
rx_dbm: tier.rx_dbm,
color: tier.color,
radius_km: radius
}
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),
valid_time: valid_time,
eirp_dbm: Float.round(eirp_dbm, 1),
atm_per_km: Float.round(atm_per_km, 3),
rings: rings
}
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.
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
# 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
cond do
budget <= log_f_const ->
# Even at 1 km the budget is already negative → ring is effectively 0.
0.0
true ->
bisect(0.01, 5000.0, budget, log_f_const, atm_per_km, 50)
end
end
defp bisect(lo, hi, _budget, _log_f, _atm, 0), do: (lo + hi) / 2.0
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
if val > budget do
bisect(lo, mid, budget, log_f, atm, iters - 1)
else
bisect(mid, hi, budget, log_f, atm, iters - 1)
end
end
end

View file

@ -43,6 +43,7 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
step="any"
required
/>
<.input field={@form[:on_the_air]} type="checkbox" label="On the air" />
<footer>
<.button phx-disable-with="Saving..." variant="primary">Save Beacon</.button>
<.button navigate={return_path(@return_to, @beacon)}>Cancel</.button>

View file

@ -30,6 +30,11 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
<:col :let={{_id, beacon}} label="Lon">{beacon.lon}</:col>
<:col :let={{_id, beacon}} label="Power (mW)">{beacon.power_mw}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="On air">
<span class={["badge badge-sm", beacon.on_the_air && "badge-success" || "badge-ghost"]}>
{if beacon.on_the_air, do: "Yes", else: "No"}
</span>
</:col>
<:action :let={{_id, beacon}}>
<div class="sr-only">
<.link navigate={~p"/beacons/#{beacon}"}>Show</.link>

View file

@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Beacons.RangeEstimate
@impl true
def render(assigns) do
@ -26,6 +27,52 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
</:actions>
</.header>
<div
id={"beacon-map-" <> @beacon.id}
phx-hook="BeaconMap"
phx-update="ignore"
class="h-96 w-full rounded-lg border border-base-300 mb-2"
data-lat={@beacon.lat}
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)}
>
</div>
<div class="text-xs opacity-70 mb-4 flex flex-wrap items-center gap-3">
<span>
Band <strong>{@estimate.band_label}</strong> &middot;
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> &middot;
Atm loss <strong>{@estimate.atm_per_km} dB/km</strong>
</span>
<span>
Prop score <strong>{@estimate.score}</strong>
(<strong>{@estimate.score_mult}×</strong> range)
<%= if @estimate.valid_time do %>
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
(no HRRR data using default 50)
<% end %>
</span>
</div>
<div class="flex flex-wrap gap-3 text-xs mb-4">
<%= for ring <- @estimate.rings do %>
<div class="flex items-center gap-1.5">
<span
class="inline-block w-3 h-3 rounded-full border"
style={"background-color: #{ring.color}; border-color: #{ring.color}"}
>
</span>
<span>
<strong>{ring.label}</strong>
({ring.rx_dbm} dBm) &rarr; {ring.radius_km} km
</span>
</div>
<% end %>
</div>
<.list>
<:item title="Frequency (MHz)">{@beacon.frequency_mhz}</:item>
<:item title="Callsign">{@beacon.callsign}</:item>
@ -34,6 +81,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
<:item title="Longitude">{@beacon.lon}</:item>
<:item title="Power (mW)">{@beacon.power_mw}</:item>
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item>
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
</.list>
</Layouts.app>
"""
@ -43,15 +91,21 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
def mount(%{"id" => id}, _session, socket) do
if connected?(socket), do: Beacons.subscribe_beacons()
beacon = Beacons.get_beacon!(id)
{:ok,
socket
|> assign(:page_title, "Beacon")
|> assign(:beacon, Beacons.get_beacon!(id))}
|> assign(:beacon, beacon)
|> assign(:estimate, RangeEstimate.estimate(beacon))}
end
@impl true
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
{:noreply, assign(socket, :beacon, beacon)}
{:noreply,
socket
|> assign(:beacon, beacon)
|> assign(:estimate, RangeEstimate.estimate(beacon))}
end
def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do

View file

@ -0,0 +1,9 @@
defmodule Microwaveprop.Repo.Migrations.AddOnTheAirToBeacons do
use Ecto.Migration
def change do
alter table(:beacons) do
add :on_the_air, :boolean, null: false, default: true
end
end
end

View file

@ -0,0 +1,96 @@
defmodule Microwaveprop.Beacons.RangeEstimateTest do
use Microwaveprop.DataCase
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Beacons.RangeEstimate
defp beacon(overrides \\ %{}) do
base = %Beacon{
id: "00000000-0000-0000-0000-000000000000",
frequency_mhz: 10_368.1,
callsign: "W5HN",
grid: "EM12",
lat: 32.897,
lon: -97.038,
power_mw: 10_000.0,
height_ft: 100.0,
on_the_air: true
}
struct!(base, overrides)
end
describe "mw_to_dbm/1" do
test "1 mW is 0 dBm" do
assert_in_delta RangeEstimate.mw_to_dbm(1.0), 0.0, 0.001
end
test "1 W (1000 mW) is 30 dBm" do
assert_in_delta RangeEstimate.mw_to_dbm(1000.0), 30.0, 0.001
end
test "10 W (10_000 mW) is 40 dBm" do
assert_in_delta RangeEstimate.mw_to_dbm(10_000.0), 40.0, 0.001
end
end
describe "nearest_band_mhz/1" do
test "10368.1 MHz maps to 10000 (10 GHz band)" do
assert RangeEstimate.nearest_band_mhz(10_368.1) == 10_000
end
test "24192.1 MHz maps to 24000" do
assert RangeEstimate.nearest_band_mhz(24_192.1) == 24_000
end
test "47088 MHz maps to 47000" do
assert RangeEstimate.nearest_band_mhz(47_088.0) == 47_000
end
end
describe "estimate/1" do
test "returns a map with band info, score, and a list of rings" do
result = RangeEstimate.estimate(beacon())
assert result.band_mhz == 10_000
assert is_integer(result.score) or is_float(result.score)
assert is_float(result.eirp_dbm)
assert is_list(result.rings)
assert length(result.rings) > 0
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
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
end
end
test "a more powerful beacon produces larger rings" 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
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
assert result.valid_time == nil
end
end
end

View file

@ -85,6 +85,18 @@ defmodule Microwaveprop.BeaconsTest do
assert "is not a valid Maidenhead grid" in errors_on(changeset).grid
end
test "defaults on_the_air to true" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.on_the_air == true
end
test "accepts on_the_air: false" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(on_the_air: false))
assert beacon.on_the_air == false
end
test "upcases the callsign" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))