Map UX polish and populated About page

- /map now centers on the visitor's Cloudflare geolocation when the
  cf-iplatitude/cf-iplongitude headers are present, falling back to DFW.
  Initial HRRR score bounds track the chosen center.
- Added /about link to the /map side navigation.
- Renamed "Propagation Map" label to "Propagation Prediction Map" in
  the /map page title, sidebars, and the /weather back-link.
- Populated /about with mission, approach, stack, roadmap, and a live
  stat grid fed from DB counts (estimates via pg_class.reltuples for
  the multi-million-row tables).
This commit is contained in:
Graham McIntire 2026-04-08 17:06:43 -05:00
parent f684555d7c
commit e327ff584e
5 changed files with 278 additions and 18 deletions

View file

@ -353,9 +353,18 @@ function buildPopupHTML(detail, viewshedLoading) {
export const PropagationMap = {
mounted() {
const centerLat = parseFloat(this.el.dataset.centerLat)
const centerLon = parseFloat(this.el.dataset.centerLon)
const zoom = parseInt(this.el.dataset.zoom, 10)
const center =
Number.isFinite(centerLat) && Number.isFinite(centerLon)
? [centerLat, centerLon]
: [32.897, -97.038]
this.map = L.map(this.el, {
center: [32.897, -97.038],
zoom: 7,
center,
zoom: Number.isFinite(zoom) ? zoom : 7,
minZoom: 4,
maxZoom: 10
})

View file

@ -2,19 +2,223 @@ defmodule MicrowavepropWeb.AboutLive do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Repo
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, page_title: "About")}
{:ok,
socket
|> assign(:page_title, "About")
|> assign(:stats, fetch_stats())}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-4xl">
<.header>
About
About NTMS Propagation Prediction
<: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} />
</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>
<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>
<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>.
</p>
</section>
</Layouts.app>
"""
end
attr :label, :string, required: true
attr :value, :integer, required: true
defp stat_card(assigns) do
~H"""
<div class="rounded-lg border border-base-300 bg-base-200/40 p-3">
<div class="text-xs opacity-70 uppercase tracking-wide">{@label}</div>
<div class="text-xl font-bold tabular-nums">{format_count(@value)}</div>
</div>
"""
end
# For tables that get huge (tens of millions of rows), using count(*) on
# every About page load is wasteful. We use PostgreSQL's pg_class.reltuples
# estimate — it's maintained by ANALYZE and is accurate to within a few
# percent, which is plenty for "big number on a marketing page."
@estimate_tables ~w(hrrr_profiles propagation_scores surface_observations)
@stat_keys ~w(
contacts weather_stations surface_observations soundings hrrr_profiles
iemre_observations terrain_profiles propagation_scores beacons
commercial_samples
)a
defp fetch_stats do
%{
contacts: count_exact("contacts"),
weather_stations: count_exact("weather_stations"),
surface_observations: count_estimate("surface_observations"),
soundings: count_exact("soundings"),
hrrr_profiles: count_estimate("hrrr_profiles"),
iemre_observations: count_exact("iemre_observations"),
terrain_profiles: count_exact("terrain_profiles"),
propagation_scores: count_estimate("propagation_scores"),
beacons: count_exact("beacons"),
commercial_samples: count_exact("commercial_samples")
}
rescue
_ -> Map.new(@stat_keys, fn k -> {k, 0} end)
end
defp count_exact(table) do
%Postgrex.Result{rows: [[count]]} =
Repo.query!("SELECT count(*) FROM #{table}")
count
end
defp count_estimate(table) when table in @estimate_tables do
%Postgrex.Result{rows: [[estimate]]} =
Repo.query!("SELECT reltuples::bigint FROM pg_class WHERE relname = $1", [table])
max(estimate || 0, 0)
end
defp format_count(n) when is_integer(n) and n >= 1_000_000 do
"#{Float.round(n / 1_000_000, 1)}M"
end
defp format_count(n) when is_integer(n) and n >= 1_000 do
"#{Float.round(n / 1_000, 1)}k"
end
defp format_count(n) when is_integer(n), do: Integer.to_string(n)
defp format_count(_), do: ""
end

View file

@ -10,15 +10,12 @@ defmodule MicrowavepropWeb.MapLive do
require Logger
@default_band 10_000
@initial_bounds %{
"south" => 29.5,
"north" => 36.3,
"west" => -101.5,
"east" => -92.5
}
# Default map center when no visitor geolocation is available — DFW (EM12).
@default_center %{lat: 32.897, lon: -97.038}
@default_zoom 7
@impl true
def mount(_params, _session, socket) do
def mount(_params, session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
@ -31,23 +28,46 @@ defmodule MicrowavepropWeb.MapLive do
bands = BandConfig.all_bands()
valid_times = Propagation.available_valid_times(@default_band)
selected_time = closest_to_now(valid_times)
initial_scores = Propagation.scores_at(@default_band, selected_time, @initial_bounds)
center = initial_center(session)
bounds = bounds_around(center)
initial_scores = Propagation.scores_at(@default_band, selected_time, bounds)
{:ok,
assign(socket,
page_title: "Propagation Map",
page_title: "Propagation Prediction Map",
bands: bands,
selected_band: @default_band,
initial_scores_json: Jason.encode!(initial_scores),
valid_times: valid_times,
selected_time: selected_time,
bounds: @initial_bounds,
bounds: bounds,
initial_center: center,
initial_zoom: @default_zoom,
grid_visible: false,
antenna_height_ft: 33
)}
end
end
# Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.
defp initial_center(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do
%{lat: lat, lon: lon}
end
defp initial_center(_session), do: @default_center
# Build a bounding box roughly matching the hardcoded default (~3.4° × 9°)
# around a given center so the initial HRRR score query still returns a
# useful tile set for the visible area.
defp bounds_around(%{lat: lat, lon: lon}) do
%{
"south" => lat - 3.4,
"north" => lat + 3.4,
"west" => lon - 4.5,
"east" => lon + 4.5
}
end
@impl true
def handle_event("select_band", %{"value" => band}, socket) do
band = if is_binary(band), do: String.to_integer(band), else: band
@ -274,6 +294,9 @@ defmodule MicrowavepropWeb.MapLive do
)
}
data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
data-center-lat={@initial_center.lat}
data-center-lon={@initial_center.lon}
data-zoom={@initial_zoom}
class="absolute inset-0 z-0"
>
</div>
@ -287,7 +310,7 @@ defmodule MicrowavepropWeb.MapLive do
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Propagation Map</div>
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
@ -377,6 +400,9 @@ defmodule MicrowavepropWeb.MapLive do
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
@ -423,7 +449,7 @@ defmodule MicrowavepropWeb.MapLive do
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Propagation Map</div>
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
@ -517,6 +543,9 @@ defmodule MicrowavepropWeb.MapLive do
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<%!-- Auth --%>

View file

@ -282,7 +282,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
<%!-- Links --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
Propagation Map
Propagation Prediction Map
</.link>
</div>
</div>

View file

@ -8,6 +8,7 @@ defmodule MicrowavepropWeb.Router do
plug :accepts, ["html"]
plug :fetch_session
plug :store_remote_ip
plug :store_cf_geo
plug :fetch_live_flash
plug :put_root_layout, html: {MicrowavepropWeb.Layouts, :root}
plug :protect_from_forgery
@ -20,6 +21,23 @@ defmodule MicrowavepropWeb.Router do
Plug.Conn.put_session(conn, :remote_ip, ip_str)
end
# Extract Cloudflare visitor geolocation headers (set by a Cloudflare
# Managed Transform) and stash them in the session so LiveViews can use
# them to center maps on the visitor's approximate location.
defp store_cf_geo(conn, _opts) do
lat = conn |> Plug.Conn.get_req_header("cf-iplatitude") |> List.first()
lon = conn |> Plug.Conn.get_req_header("cf-iplongitude") |> List.first()
with {lat_f, ""} when is_float(lat_f) <- lat && Float.parse(lat),
{lon_f, ""} when is_float(lon_f) <- lon && Float.parse(lon) do
conn
|> Plug.Conn.put_session(:cf_lat, lat_f)
|> Plug.Conn.put_session(:cf_lon, lon_f)
else
_ -> conn
end
end
pipeline :api do
plug :accepts, ["json"]
end