defmodule MicrowavepropWeb.AboutLive do @moduledoc "Static `/about` page — project overview, credits, contact info." use MicrowavepropWeb, :live_view alias Microwaveprop.Repo @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:page_title, "About") |> assign(:stats, fetch_stats())} end @impl true def render(assigns) do ~H""" <.header> About NTMS Propagation Prediction <:subtitle>What we're building and why.

Line-of-sight is wrong for the microwave bands. Every operator on 902 MHz and up has made contacts over hills, through trees, and 200 km past the horizon — contacts that shouldn't have worked if LOS were the whole story. What's actually going on is the lower atmosphere: temperature inversions, humidity gradients, ducting layers, and rain cells that bend, trap, and absorb signals in ways that change by the hour. The window for a good contact is often minutes, not days.

Jim KM5PO started this project to try to predict those windows from weather data. Graham W5ISP picked it up and is iterating on it from there. The goal: a map that tells you when to get on the air, per band, based on real atmospheric conditions and what we've learned from the contacts you and everyone else have already made.

How it works, roughly

Two things, paired up. Most propagation tools keep them separate.

  1. The weather. Hourly 3 km NOAA HRRR forecasts give surface fields and pressure-level profiles across CONUS, while the Canadian HRDPS model (0.125° resolution, 4× daily) extends coverage north to 60°N. We derive refractivity profiles, minimum refractivity gradient, ducting detection, boundary-layer depth, and precipitable water for every grid cell. ASOS surface obs, 12-hourly upper-air soundings, and gridded IEMRE reanalysis fill in the gaps. A 10-factor composite score is written for every 0.125° cell (~95k across CONUS) across 23 microwave bands (902 MHz–241 GHz), for each hour of a 48-hour forecast.
  2. The contacts. {format_count(@stats.contacts)}+ amateur microwave QSOs, each tagged with the atmosphere at both ends of the path (and along it) at the time the contact happened. That's a ground-truth dataset we can actually fit against — "when you had this score, how far did the contact go, and did it happen at all?"

The scoring is a weighted sum of ten factors: rain, humidity, precipitable water (PWAT), season, refractivity gradient, pressure, T–Td depression, sky cover, wind, and time of day — weights recalibrated via gradient descent against the contact dataset. The physics changes by frequency: humidity helps at 10 GHz (more refractivity) and hurts at 24+ GHz (absorption). Refractivity uses native HRRR hybrid-sigma levels (10–50 m resolution) for duct detection, with a pressure-level fallback (~250 m) when the native data isn't available.

What we've collected so far

Straight from the production database:

Contacts
{format_count(@stats.contacts)}
Weather stations
{format_count(@stats.weather_stations)}
Surface observations
{format_count(@stats.surface_observations)}
Upper-air soundings
{format_count(@stats.soundings)}
HRRR profiles
{format_count(@stats.hrrr_profiles)}
IEMRE gridded obs
{format_count(@stats.iemre_observations)}
Terrain profiles
{format_count(@stats.terrain_profiles)}
Beacons
{format_count(@stats.beacons)}
Commercial link samples
{format_count(@stats.commercial_samples)}

How it's built

  • Elixir / Phoenix 1.8 + LiveView for everything web-facing. Ecto on Postgres for storage. Oban runs the background pipelines (HRRR pulls, terrain, ASOS, soundings, IEMRE, solar indices, commercial links).
  • A Rust pipeline (prop-grid-rs) handles the heavy lifting: fetching GRIB2 data from NOAA S3, decoding via wgrib2, deriving weather scalars, and scoring all 23 bands in a single fused pass over ~95k cells. Writes dense binary artifacts (.pgrid profiles, .sgrid weather scalars, and per-band .prop score files) to NFS for sub-millisecond single-cell reads from Elixir. A full forecast-hour chain step runs in ~6 seconds — ~3× faster than the previous Elixir-only pipeline.
  • Leaflet + Canvas tile layers for the propagation heatmap — we render 0.125° cells at interactive frame rates. Tailwind v4 + daisyUI for layout, esbuild for JS bundling.
  • Physics: 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, FSPL with frequency-dependent O₂ and H₂O absorption.
  • Data sources: NOAA HRRR (AWS S3, hourly analysis + 18 h forecasts), Canadian HRDPS (MSC Datamart, 4× daily 48 h forecasts), Iowa Environmental Mesonet (ASOS and upper-air soundings), IEMRE gridded reanalysis, SRTM 90 m terrain, SNMP polling on seven commercial microwave links near DFW at 5-minute intervals, and NCEP NARR for pre-2014 contact enrichment.

What's next

  • Beacon calibration. A distributed network of amateur receivers reporting CW beacon signal levels on a schedule. Beacon submissions are already open to anyone via the Beacons page.
  • Weight calibration. Re-fit the 10 factor weights against recorded distances / QSO counts per band so the score reflects real propagation, not hand tuning. The PSKR spot ingestion pipeline (sampling every 5 min) is building the calibration dataset automatically.
  • Better inputs. MRMS for precipitation at 24+ GHz where rain attenuation dominates, RTMA/URMA surface analysis blending, GOES total precipitable water.
  • Expanded coverage. The Canadian HRDPS model gives us southern Canada (to 60°N). Alaska, Hawaii, Mexico, and the Caribbean need their own data sources and scoring pipelines.
""" 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 surface_observations) 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"), beacons: count_exact("beacons"), commercial_samples: count_exact("commercial_samples") } end # Each count is wrapped individually so a missing table in dev (or a # transient query failure) never blanks the entire dashboard. The # previous outer try/rescue zeroed all 10 numbers if any single one # raised — every stat read 0 on environments lacking hrrr_profiles # or propagation_scores. defp count_exact(table) do case Repo.query("SELECT count(*) FROM #{table}") do {:ok, %Postgrex.Result{rows: [[count]]}} -> count _ -> 0 end end defp count_estimate(table) when table in @estimate_tables do case Repo.query("SELECT reltuples::bigint FROM pg_class WHERE relname = $1", [table]) do {:ok, %Postgrex.Result{rows: [[estimate]]}} -> max(estimate || 0, 0) _ -> 0 end 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