defmodule MicrowavepropWeb.AboutLive do @moduledoc false 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.

What we're trying to do

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.

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.

The approach

We pair two things that most propagation tools keep separate:

  1. Atmospheric state — 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.
  2. Historical contacts — 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.

The current scoring algorithm is a weighted sum of nine factors (humidity, time of day, T–Td 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.

What we've collected

Live counts from the production database:

<.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} />

The stack

Where this goes next

Built by and for the North Texas Microwave Society.

""" end attr :label, :string, required: true attr :value, :integer, required: true defp stat_card(assigns) do ~H"""
{@label}
{format_count(@value)}
""" 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