277 lines
12 KiB
Elixir
277 lines
12 KiB
Elixir
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"""
|
||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-4xl">
|
||
<.header>
|
||
About NTMS Propagation Prediction
|
||
<:subtitle>What we're building and why.</:subtitle>
|
||
</.header>
|
||
|
||
<section class="space-y-8 text-sm leading-relaxed">
|
||
<div class="space-y-3">
|
||
<p>
|
||
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.
|
||
</p>
|
||
<p>
|
||
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.
|
||
</p>
|
||
<div role="alert" class="alert alert-warning">
|
||
<p>
|
||
<strong>Heads up:</strong>
|
||
the scoring you see is hand-calibrated from the data we have so far.
|
||
It isn't updated in real time — I (Graham) re-fit the weights
|
||
manually when there's enough new data to move the needle. The more
|
||
contacts and beacon reports we collect, the better it gets. See the
|
||
<.link navigate={~p"/algo"} class="link link-hover font-semibold">algorithm page</.link>
|
||
for what's actually under the hood, and please <.link
|
||
navigate={~p"/submit"}
|
||
class="link link-hover font-semibold"
|
||
>submit your contacts</.link>.
|
||
</p>
|
||
</div>
|
||
<div role="alert" class="alert alert-info">
|
||
<p>
|
||
<strong>Coverage:</strong>
|
||
right now this only covers the continental US. Every upstream source
|
||
we're using — NOAA HRRR, ASOS, IEMRE, the ITU terrain data — is
|
||
CONUS-only. Hawaii, Alaska, Canada, and Mexico will need different
|
||
data sources and aren't wired up yet.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<h2 class="text-xl font-bold">How it works, roughly</h2>
|
||
<p>
|
||
Two things, paired up. Most propagation tools keep them separate.
|
||
</p>
|
||
<ol class="list-decimal list-outside pl-5 space-y-2">
|
||
<li>
|
||
<strong>The weather.</strong>
|
||
Hourly 3 km HRRR forecasts give surface fields and pressure-level
|
||
profiles. 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 out for every 0.125° cell
|
||
across CONUS, for each hour of an 18-hour forecast.
|
||
</li>
|
||
<li>
|
||
<strong>The contacts.</strong>
|
||
{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?"
|
||
</li>
|
||
</ol>
|
||
<p>
|
||
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.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<h2 class="text-xl font-bold">What we've collected so far</h2>
|
||
<p>Straight from the production database:</p>
|
||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">Contacts</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.contacts)}</div>
|
||
</div>
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">Weather stations</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.weather_stations)}</div>
|
||
</div>
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">Surface observations</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.surface_observations)}</div>
|
||
</div>
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">Upper-air soundings</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.soundings)}</div>
|
||
</div>
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">HRRR profiles</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.hrrr_profiles)}</div>
|
||
</div>
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">IEMRE gridded obs</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.iemre_observations)}</div>
|
||
</div>
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">Terrain profiles</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.terrain_profiles)}</div>
|
||
</div>
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">Beacons</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.beacons)}</div>
|
||
</div>
|
||
<div class="stat bg-base-200 rounded-box">
|
||
<div class="stat-title">Commercial link samples</div>
|
||
<div class="stat-value text-xl">{format_count(@stats.commercial_samples)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<h2 class="text-xl font-bold">How it's built</h2>
|
||
<ul class="list-disc list-outside pl-5 space-y-2">
|
||
<li>
|
||
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).
|
||
</li>
|
||
<li>
|
||
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.
|
||
</li>
|
||
<li>
|
||
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.
|
||
</li>
|
||
<li>
|
||
Data sources: NOAA HRRR (AWS S3, hourly analysis + 18 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.
|
||
</li>
|
||
<li>
|
||
There's Nx/Axon/EXLA scaffolding for a small feed-forward model
|
||
(13 features → 64 → 32 → 1 sigmoid). Not trained yet — waiting
|
||
on enough calibrated data to not overfit.
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<h2 class="text-xl font-bold">What's next</h2>
|
||
<ul class="list-disc list-outside pl-5 space-y-2">
|
||
<li>
|
||
<strong>Beacon calibration.</strong>
|
||
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.
|
||
</li>
|
||
<li>
|
||
<strong>Weight calibration.</strong>
|
||
Fit the 9 factor weights against recorded distances / QSO counts
|
||
per band so the score reflects real propagation, not my
|
||
intuition.
|
||
</li>
|
||
<li>
|
||
<strong>Training the model.</strong>
|
||
Once there are enough clean (contact, conditions) pairs, train
|
||
the Axon model to augment or replace the hand-tuned scoring.
|
||
</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>
|
||
<div role="alert" class="alert alert-info text-center">
|
||
<p>
|
||
If this tool is useful to you, consider
|
||
<a
|
||
href="https://www.paypal.com/ncp/payment/53VLBD2E67JAE"
|
||
target="_blank"
|
||
class="link font-semibold underline"
|
||
>
|
||
donating to NTMS
|
||
</a>
|
||
to help keep the project running.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
</Layouts.app>
|
||
"""
|
||
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
|