Compact beacon detail page, toggleable coverage, About page formatting

- Beacon map defaults to just the marker at zoom 11. A daisyUI toggle
  switch shows/hides the estimated-coverage cell layer; the coverage
  info line and tier legend are hidden unless the toggle is on.
- Beacon detail list replaced with a compact dl grid (2-4 columns).
  Lat/lon rounded to 5 decimals, power/height/beamwidth formatted via
  Beacon.format_mw/1 so no more "2.5e3" scientific notation or "280.0".
  format_mw/1 hoisted onto the schema so index and show share it.
- About page no longer uses prose classes (this project doesn't load
  @tailwindcss/typography). Headings, lists, and paragraphs now use
  explicit utility classes so they render as intended.
This commit is contained in:
Graham McIntire 2026-04-09 09:12:21 -05:00
parent db39f94fc3
commit a95d2ada52
5 changed files with 264 additions and 179 deletions

View file

@ -6,23 +6,24 @@ export const BeaconMap = {
const onTheAir = this.el.dataset.onTheAir === "true"
const cells = JSON.parse(this.el.dataset.cells || "[]")
const step = parseFloat(this.el.dataset.gridStep || "0.125")
const initialShowCoverage = this.el.dataset.showCoverage === "true"
const map = L.map(this.el, {
zoomControl: true,
attributionControl: false,
scrollWheelZoom: false
}).setView([lat, lon], 9)
}).setView([lat, lon], 11)
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map)
// 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)
// Build the coverage cell layer up-front but keep it off the map until
// the user toggles it on. Using a single layerGroup keeps pan/zoom fast.
const cellLayer = L.layerGroup()
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]
@ -56,13 +57,29 @@ export const BeaconMap = {
offset: [0, -10]
})
// Fit to the rendered cells (initial view already set above), then zoom
// in two steps so the beacon's immediate surroundings are visible.
if (allBounds.length > 0) {
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
map.setZoom(map.getZoom() + 2)
const showLayer = () => {
if (!map.hasLayer(cellLayer)) cellLayer.addTo(map)
if (allBounds.length > 0) {
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
map.setZoom(map.getZoom() + 2)
}
}
const hideLayer = () => {
if (map.hasLayer(cellLayer)) map.removeLayer(cellLayer)
map.setView([lat, lon], 11)
}
if (initialShowCoverage) showLayer()
this.handleEvent("toggle_coverage", ({show}) => {
if (show) {
showLayer()
} else {
hideLayer()
}
})
setTimeout(() => map.invalidateSize(), 50)
}
}

View file

@ -45,6 +45,31 @@ defmodule Microwaveprop.Beacons.Beacon do
def keying_label(key), do: Map.get(@keying_labels, key, key)
@doc """
Formats a milliwatt power value as a plain decimal string, never scientific
notation. Integers are printed without a decimal; floats keep up to three
decimals with trailing zeros trimmed.
"""
def format_mw(nil), do: ""
def format_mw(mw) when is_float(mw) do
if mw == trunc(mw) do
Integer.to_string(trunc(mw))
else
mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
end
end
def format_mw(mw), do: to_string(mw)
defp trim_trailing_zeros(str) do
if String.contains?(str, ".") do
str |> String.trim_trailing("0") |> String.trim_trailing(".")
else
str
end
end
@doc """
Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a
cleaner UI when dozens of weak-signal modes are present.

View file

@ -21,135 +21,145 @@ defmodule MicrowavepropWeb.AboutLive do
<:subtitle>What we're building and why.</:subtitle>
</.header>
<section class="prose prose-sm max-w-none dark:prose-invert">
<h2>What we're trying to do</h2>
<p>
Microwave propagation above 10 GHz is dominated by the lower atmosphere:
humidity gradients, temperature inversions, ducting layers, rain cells,
and hyper-local refractivity structure. At 10, 24, 47, 76, 122, and 241 GHz
these effects decide whether a contact happens at 50 km or 500 km and the
window often lasts minutes, not hours.
</p>
<p>
Traditionally microwave contacts are ruled by line of sight, but we've seen that is incorrect. This project is an attempt to build a data-driven
propagation prediction model specifically for the amateur microwave bands,
using hourly numerical weather forecasts, historical contact records, and
eventually calibrated beacon measurements.
</p>
<h2>The approach</h2>
<p>
We pair two things that most propagation tools keep separate:
</p>
<ol>
<li>
<strong>Atmospheric state</strong> hourly 3 km HRRR forecasts
(surface fields plus pressure-level profiles), hourly surface
observations from ASOS, 12-hourly upper-air soundings, and gridded
IEMRE reanalysis. From these we derive refractivity profiles,
ducting detection, minimum refractivity gradient, precipitable water,
boundary-layer depth, and a 9-factor composite score for every
0.125° cell on a CONUS grid, for each hour of an 18-hour forecast.
</li>
<li>
<strong>Historical contacts</strong> 58k+ amateur microwave QSOs
tagged with the atmospheric conditions at both ends and along the
path at the exact time they happened. That gives us a ground-truth
dataset we can use to calibrate the scoring weights and, eventually,
train a machine learning model that predicts contact success given
conditions.
</li>
</ol>
<p>
The current scoring algorithm is a weighted sum of nine factors
(humidity, time of day, TTd depression, refractivity gradient, sky
cover, season, rain, wind, pressure) with band-dependent weights
humidity helps at 10 GHz via enhanced refractivity but hurts at
24+ GHz via absorption, for example. Every coefficient in that
formula is a hypothesis waiting to be tested against the contact
and beacon data.
</p>
<h2>What we've collected</h2>
<p>Live counts from the production database:</p>
<div class="not-prose my-4 grid grid-cols-2 md:grid-cols-3 gap-3">
<.stat_card label="Contacts" value={@stats.contacts} />
<.stat_card label="Weather stations" value={@stats.weather_stations} />
<.stat_card label="Surface observations" value={@stats.surface_observations} />
<.stat_card label="Upper-air soundings" value={@stats.soundings} />
<.stat_card label="HRRR profiles" value={@stats.hrrr_profiles} />
<.stat_card label="IEMRE gridded obs" value={@stats.iemre_observations} />
<.stat_card label="Terrain profiles" value={@stats.terrain_profiles} />
<.stat_card label="Propagation scores" value={@stats.propagation_scores} />
<.stat_card label="Beacons" value={@stats.beacons} />
<.stat_card label="Commercial link samples" value={@stats.commercial_samples} />
<section class="space-y-8 text-sm leading-relaxed">
<div class="space-y-3">
<h2 class="text-xl font-bold">What we're trying to do</h2>
<p>
Microwave propagation above 10 GHz is dominated by the lower atmosphere:
humidity gradients, temperature inversions, ducting layers, rain cells,
and hyper-local refractivity structure. At 10, 24, 47, 76, 122, and 241 GHz
these effects decide whether a contact happens at 50 km or 500 km and the
window often lasts minutes, not hours.
</p>
<p>
Traditionally microwave contacts are ruled by line of sight, but we've
seen that is incorrect. This project is an attempt to build a data-driven
propagation prediction model specifically for the amateur microwave bands,
using hourly numerical weather forecasts, historical contact records, and
eventually calibrated beacon measurements.
</p>
</div>
<h2>The stack</h2>
<ul>
<li>
<strong>Backend:</strong> Elixir / Phoenix 1.8 with LiveView for the
real-time map, Ecto on PostgreSQL for storage, Oban for the
background data pipelines (HRRR fetch, terrain, ASOS, soundings,
IEMRE, solar indices, commercial links), Bandit as the HTTP server.
</li>
<li>
<strong>Frontend:</strong> LiveView with Leaflet for maps, Canvas
tile layers for the HRRR heatmap (we render 0.125° cells at
interactive frame rates), Tailwind v4 + daisyUI for layout, esbuild
for JS bundling.
</li>
<li>
<strong>Physics:</strong> ITU-R P.526-16 knife-edge + Deygout
3-edge terrain diffraction, ITU-R P.838-3 rain attenuation,
dynamic k-factor from live HRRR refractivity gradients, great-circle
geometry for link budgets, Free-Space Path Loss with frequency-
dependent O and HO absorption per band.
</li>
<li>
<strong>Machine learning:</strong> Nx / Axon / EXLA scaffolding for
a 13-feature (8 atmospheric + 4 cyclical temporal + 1 log-frequency)
feed-forward network, 64 32 1 sigmoid. Not trained yet
waiting on a larger calibration dataset.
</li>
<li>
<strong>Data sources:</strong> NOAA HRRR model (AWS S3, hourly
analysis + 18 h forecasts), Iowa Environmental Mesonet (ASOS &amp;
upper-air soundings), IEMRE gridded reanalysis, SRTM 90 m terrain
tiles, SNMP polling of seven commercial microwave links near DFW
at 5-minute intervals, Copernicus ERA5 for pre-2014 contact
enrichment.
</li>
</ul>
<div class="space-y-3">
<h2 class="text-xl font-bold">The approach</h2>
<p>We pair two things that most propagation tools keep separate:</p>
<ol class="list-decimal list-outside pl-5 space-y-2">
<li>
<strong>Atmospheric state</strong> hourly 3 km HRRR forecasts
(surface fields plus pressure-level profiles), hourly surface
observations from ASOS, 12-hourly upper-air soundings, and gridded
IEMRE reanalysis. From these we derive refractivity profiles,
ducting detection, minimum refractivity gradient, precipitable water,
boundary-layer depth, and a 9-factor composite score for every
0.125° cell on a CONUS grid, for each hour of an 18-hour forecast.
</li>
<li>
<strong>Historical contacts</strong> 58k+ amateur microwave QSOs
tagged with the atmospheric conditions at both ends and along the
path at the exact time they happened. That gives us a ground-truth
dataset we can use to calibrate the scoring weights and, eventually,
train a machine learning model that predicts contact success given
conditions.
</li>
</ol>
<p>
The current scoring algorithm is a weighted sum of nine factors
(humidity, time of day, TTd depression, refractivity gradient, sky
cover, season, rain, wind, pressure) with band-dependent weights
humidity helps at 10 GHz via enhanced refractivity but hurts at
24+ GHz via absorption, for example. Every coefficient in that
formula is a hypothesis waiting to be tested against the contact
and beacon data.
</p>
</div>
<h2>Where this goes next</h2>
<ul>
<li>
<strong>Beacon calibration.</strong> A distributed network of
amateur receivers continuously reporting CW beacon signal levels,
feeding ground-truth measurements back into the scoring algorithm.
Beacon submissions are now open to anyone via the Beacons page.
</li>
<li>
<strong>Scoring weight calibration.</strong> Fit the 9-factor
weights against recorded QSO distances / counts per band so the
composite score reflects real propagation rather than intuition.
</li>
<li>
<strong>ML model training.</strong> Once we have enough labeled
contact+condition pairs, train the Axon model to replace or augment
the hand-tuned scoring function.
</li>
<li>
<strong>Better inputs.</strong> MRMS for precipitation at
24+ GHz (where rain attenuation dominates), RTMA/URMA surface
analysis blending, GOES total precipitable water.
</li>
</ul>
<div class="space-y-3">
<h2 class="text-xl font-bold">What we've collected</h2>
<p>Live counts from the production database:</p>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<.stat_card label="Contacts" value={@stats.contacts} />
<.stat_card label="Weather stations" value={@stats.weather_stations} />
<.stat_card label="Surface observations" value={@stats.surface_observations} />
<.stat_card label="Upper-air soundings" value={@stats.soundings} />
<.stat_card label="HRRR profiles" value={@stats.hrrr_profiles} />
<.stat_card label="IEMRE gridded obs" value={@stats.iemre_observations} />
<.stat_card label="Terrain profiles" value={@stats.terrain_profiles} />
<.stat_card label="Propagation scores" value={@stats.propagation_scores} />
<.stat_card label="Beacons" value={@stats.beacons} />
<.stat_card label="Commercial link samples" value={@stats.commercial_samples} />
</div>
</div>
<p class="text-xs opacity-60 mt-8">
Built by and for the <a href="https://www.ntms.org" target="_blank">North Texas Microwave Society</a>.
<div class="space-y-3">
<h2 class="text-xl font-bold">The stack</h2>
<ul class="list-disc list-outside pl-5 space-y-2">
<li>
<strong>Backend:</strong> Elixir / Phoenix 1.8 with LiveView for the
real-time map, Ecto on PostgreSQL for storage, Oban for the
background data pipelines (HRRR fetch, terrain, ASOS, soundings,
IEMRE, solar indices, commercial links), Bandit as the HTTP server.
</li>
<li>
<strong>Frontend:</strong> LiveView with Leaflet for maps, Canvas
tile layers for the HRRR heatmap (we render 0.125° cells at
interactive frame rates), Tailwind v4 + daisyUI for layout, esbuild
for JS bundling.
</li>
<li>
<strong>Physics:</strong> ITU-R P.526-16 knife-edge + Deygout
3-edge terrain diffraction, ITU-R P.838-3 rain attenuation,
dynamic k-factor from live HRRR refractivity gradients, great-circle
geometry for link budgets, Free-Space Path Loss with frequency-
dependent O and HO absorption per band.
</li>
<li>
<strong>Machine learning:</strong> Nx / Axon / EXLA scaffolding for
a 13-feature (8 atmospheric + 4 cyclical temporal + 1 log-frequency)
feed-forward network, 64 32 1 sigmoid. Not trained yet
waiting on a larger calibration dataset.
</li>
<li>
<strong>Data sources:</strong> NOAA HRRR model (AWS S3, hourly
analysis + 18 h forecasts), Iowa Environmental Mesonet (ASOS &amp;
upper-air soundings), IEMRE gridded reanalysis, SRTM 90 m terrain
tiles, SNMP polling of seven commercial microwave links near DFW
at 5-minute intervals, Copernicus ERA5 for pre-2014 contact
enrichment.
</li>
</ul>
</div>
<div class="space-y-3">
<h2 class="text-xl font-bold">Where this goes next</h2>
<ul class="list-disc list-outside pl-5 space-y-2">
<li>
<strong>Beacon calibration.</strong> A distributed network of
amateur receivers continuously reporting CW beacon signal levels,
feeding ground-truth measurements back into the scoring algorithm.
Beacon submissions are now open to anyone via the Beacons page.
</li>
<li>
<strong>Scoring weight calibration.</strong> Fit the 9-factor
weights against recorded QSO distances / counts per band so the
composite score reflects real propagation rather than intuition.
</li>
<li>
<strong>ML model training.</strong> Once we have enough labeled
contact+condition pairs, train the Axon model to replace or augment
the hand-tuned scoring function.
</li>
<li>
<strong>Better inputs.</strong> MRMS for precipitation at
24+ GHz (where rain attenuation dominates), RTMA/URMA surface
analysis blending, GOES total precipitable water.
</li>
</ul>
</div>
<p class="text-xs opacity-60 pt-4 border-t border-base-300">
Built by and for the <a href="https://www.ntms.org" target="_blank" class="link link-hover">
North Texas Microwave Society
</a>.
</p>
</section>
</Layouts.app>

View file

@ -29,7 +29,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="Lat">{beacon.lat}</:col>
<:col :let={{_id, beacon}} label="Lon">{beacon.lon}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)}</:col>
<:col :let={{_id, beacon}} label="On air">
@ -68,7 +68,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
<:col :let={{_id, beacon}} label="Frequency (MHz)">{beacon.frequency_mhz}</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Submitted">
{Calendar.strftime(beacon.inserted_at, "%Y-%m-%d %H:%M UTC")}
@ -162,26 +162,4 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
# Format a milliwatt power value without scientific notation.
# Keeps up to 3 decimal places, drops trailing zeros.
defp format_mw(nil), do: ""
defp format_mw(mw) when is_float(mw) do
if mw == trunc(mw) do
Integer.to_string(trunc(mw))
else
mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
end
end
defp format_mw(mw), do: to_string(mw)
defp trim_trailing_zeros(str) do
if String.contains?(str, ".") do
str |> String.trim_trailing("0") |> String.trim_trailing(".")
else
str
end
end
end

View file

@ -50,10 +50,23 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
data-on-the-air={to_string(@beacon.on_the_air)}
data-grid-step={@estimate.grid_step}
data-cells={Jason.encode!(@estimate.cells)}
data-show-coverage={to_string(@show_coverage)}
>
</div>
<div class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3">
<div class="mb-2 flex items-center justify-between flex-wrap gap-2">
<label class="label cursor-pointer gap-2 py-0">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@show_coverage}
phx-click="toggle_coverage"
/>
<span class="label-text text-sm">Show estimated current coverage</span>
</label>
</div>
<div :if={@show_coverage} class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3">
<span>
Band <strong>{@estimate.band_label}</strong> &middot;
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> &middot;
@ -71,7 +84,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
</span>
</div>
<div class="flex flex-wrap gap-3 text-xs mb-4">
<div :if={@show_coverage} class="flex flex-wrap gap-3 text-xs mb-4">
<%= for tier <- @estimate.tiers do %>
<div class="flex items-center gap-1.5">
<span
@ -86,24 +99,39 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
<% end %>
</div>
<.list>
<:item title="Frequency (MHz)">{@beacon.frequency_mhz}</:item>
<:item title="Callsign">{@beacon.callsign}</:item>
<:item title="Grid">{@beacon.grid}</:item>
<:item title="Latitude">{@beacon.lat}</:item>
<:item title="Longitude">{@beacon.lon}</:item>
<:item title="TX power (EIRP) (mW)">{@beacon.power_mw}</:item>
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item>
<:item title="Bearing">{bearing_label(@beacon.bearing)}</:item>
<:item :if={@beacon.beamwidth_deg} title="Beamwidth">
{@beacon.beamwidth_deg}°
</:item>
<:item title="Keying">{Beacon.keying_label(@beacon.keying)}</:item>
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
<:item :if={@beacon.notes && @beacon.notes != ""} title="Notes">
<div class="whitespace-pre-wrap">{@beacon.notes}</div>
</:item>
</.list>
<div class="rounded-lg border border-base-300 bg-base-200/40 p-3 text-sm">
<dl class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-4 gap-y-2">
<.stat_field label="Frequency">{@beacon.frequency_mhz} MHz</.stat_field>
<.stat_field label="Callsign">{@beacon.callsign}</.stat_field>
<.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)}</.stat_field>
<.stat_field label="On the air">
<span class={[
"badge badge-sm",
(@beacon.on_the_air && "badge-success") || "badge-ghost"
]}>
{if @beacon.on_the_air, do: "Yes", else: "No"}
</span>
</.stat_field>
<.stat_field label="Grid">{@beacon.grid}</.stat_field>
<.stat_field label="Latitude">{format_coord(@beacon.lat)}</.stat_field>
<.stat_field label="Longitude">{format_coord(@beacon.lon)}</.stat_field>
<.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW</.stat_field>
<.stat_field label="Height AGL">{Beacon.format_mw(@beacon.height_ft)} ft</.stat_field>
<.stat_field label="Bearing">{bearing_label(@beacon.bearing)}</.stat_field>
<.stat_field :if={@beacon.beamwidth_deg} label="Beamwidth">
{Beacon.format_mw(@beacon.beamwidth_deg)}°
</.stat_field>
</dl>
<div :if={@beacon.notes && @beacon.notes != ""} class="mt-3 pt-3 border-t border-base-300">
<div class="font-semibold text-[11px] uppercase tracking-wider opacity-60 mb-1">
Notes
</div>
<div class="whitespace-pre-wrap text-sm">{@beacon.notes}</div>
</div>
</div>
</Layouts.app>
"""
end
@ -118,10 +146,20 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
socket
|> assign(:page_title, "Beacon")
|> assign(:beacon, beacon)
|> assign(:show_coverage, false)
|> assign(:estimate, RangeEstimate.estimate(beacon))}
end
@impl true
def handle_event("toggle_coverage", _params, socket) do
show = not socket.assigns.show_coverage
{:noreply,
socket
|> assign(:show_coverage, show)
|> push_event("toggle_coverage", %{show: show})}
end
def handle_event("approve", _params, socket) do
if admin?(socket.assigns.current_scope) do
{:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon)
@ -160,4 +198,21 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
defp bearing_label(nil), do: "Omni"
defp bearing_label("omni"), do: "Omni"
defp bearing_label(value), do: "#{value}°"
defp format_coord(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 5)
defp format_coord(value), do: to_string(value)
attr :label, :string, required: true
slot :inner_block, required: true
defp stat_field(assigns) do
~H"""
<div class="min-w-0">
<dt class="font-semibold text-[11px] uppercase tracking-wider opacity-60">
{@label}
</dt>
<dd class="truncate">{render_slot(@inner_block)}</dd>
</div>
"""
end
end