Skip to content

Repository files navigation

HF Propagation Map

A real-time HF skywave propagation visualizer for amateur radio operators. Shows estimated band openness from your QTH to every point on the globe, driven by live solar indices and a physics-based ionospheric model.

🌐 Live site: propagation.ggcloud.us — free to use. Anyone can browse bands; a free account unlocks your own QTH and antenna settings.

Stack Flask AWS Lambda DynamoDB License

Working on this project? This project is developed with Claude Code. See CLAUDE.md for setup instructions, including how to install the project memory files so Claude has full context on any machine.


What It Does

  • Fetches live solar data (SFI, K-index, A-index, sunspot number) from hamqsl.com with a NOAA fallback
  • Caches solar data in DynamoDB, shared across all Lambda instances and auto-refreshed when over 2 hours old. History rows expire after 7 days via DynamoDB TTL.
  • Computes a global heatmap of the probability each amateur band is open, using a multi-hop F2 ionospheric model calibrated against real ionosonde and WSPR data
  • Renders the heatmap over a Winkel Tripel world map using D3.js and an HTML5 Canvas, with an optional greyline overlay
  • Supports four antenna models (λ/4 vertical, dipole, hex beam, and the NEC2++-modeled Zero Five 10–80m elevated ground plane) plus a soil setting. The applied dB is shown on screen.
  • Lets you set your QTH by Maidenhead grid square, lat/lon, or US ZIP code (free account)
  • Accounts by callsign: sign in, register, password reset by email (SES), and admin user management
  • Remembers your QTH, antenna and display settings across sessions via browser localStorage
  • Search-engine ready — meta description, Open Graph tags, schema.org JSON-LD, /robots.txt, and /sitemap.xml; CloudFront edge-caches the root page and SEO endpoints so crawler traffic rarely invokes Lambda

Project Structure

propagation/
├── app.py              # Flask app — routes, DynamoDB helpers, Lambda WSGI adapter
├── propagation.py      # Ionospheric model — foF2, MUF, antenna factors
├── templates/
│   └── index.html      # Single-page UI — D3 map, panel, all JavaScript
├── antennas/           # Antenna gain tables (NEC2++-generated JSON) — packaged with the Lambda
├── tools/validate/     # Dev-only: score the model against ionosondes and WSPR (see its README)
├── tools/antenna/      # Dev-only: NEC2++ generators for antennas/ (see its README)
├── requirements.txt    # flask, numpy  (boto3 is pre-installed in the Lambda runtime)
├── LOCAL_INSTALL.md    # Running the app on your own machine
└── AWS_INSTALL.md      # Deploying to AWS Lambda with DynamoDB and CloudFront

AWS Architecture

AWS Architecture Diagram

Browser → Cloudflare DNS → CloudFront (TLS via ACM) → Lambda Function URL → Flask app → DynamoDB. SES handles auth token emails. All resources tagged app=hf_propagation and collected in an AWS Resource Group. CloudFront serves /, /robots.txt, /sitemap.xml and /BingSiteAuth.xml from its edge cache (driven by origin Cache-Control headers); every other route passes through uncached.


Installation

Environment Guide
Local / development LOCAL_INSTALL.md
AWS Lambda + CloudFront AWS_INSTALL.md

How to Use

Account

Bands, solar data and the greyline overlay work for everyone. Setting your own QTH and antenna needs a free account: click Sign In / Register in the panel header and register with your callsign, a password and an email address. The email is used only for password resets. You stay signed in for 30 days.

Setting your QTH

In the My QTH section of the panel, choose one of three entry methods and click Set QTH:

Method Input Example
Grid Maidenhead locator EM38ab
Lat/Lon Decimal degrees 39.8, -98.6
ZIP US ZIP code 90210

Your QTH is saved in browser localStorage and restored automatically on every return visit.

Selecting a band

Band Frequency range
80m 3.5 – 4.0 MHz
60m 5.33 – 5.404 MHz
40m 7.0 – 7.3 MHz
30m 10.1 – 10.15 MHz
20m 14.0 – 14.35 MHz
17m 18.068 – 18.168 MHz
15m 21.0 – 21.45 MHz
10m 28.0 – 29.7 MHz

Reading the heatmap

Color Meaning
Bright green Band wide open — prime operating range
Yellow Good conditions
Orange Marginal — noisy but workable
Deep red Very low probability
No color Band closed to that area

The dashed circle marks the skip zone — too close for reliable skywave on the selected band.

Tick Show greyline (under the band selector) to overlay the twilight band in violet, the day/night terminator as a dashed line, and the sub-solar point as a yellow dot. The band is the same one the model's greyline boost uses (sun −12° to +3°). The setting is remembered in the browser.

Antenna model

Check Use antenna to apply antenna pattern to the heatmap. Unchecked = baseline (no directional weighting).

Antenna Description
Vertical A resonant λ/4 vertical cut for the selected band, with a good radial field (≈32 on-ground radials, ~10 Ω loss). Omnidirectional, no height setting. Over average soil it is the 0 dB reference, the same as leaving "Use antenna" unticked.
Dipole Figure-8 pattern. Signal radiates broadside (90° to wire). Elevation lobes follow the height through the ground reflection (fixed in 2609.008: the model previously treated every dipole as half its height).
Hex Beam ~60° beamwidth, ~6 dBd gain, ~19 dB F/B. 20m–10m only.
Elevated GP (Zero Five 10–80m) 43 ft radiator with six 130" elevated radials, base 4–12 ft, 4:1 UnUn and 100 ft RG-213 to a shack tuner. Modeled in NEC2++ band by band over the chosen Soil, against the reference at every takeoff angle. Over average soil: about −7 dB on 80m (coax loss at high SWR), about −2 dB on 60m, even to about +2 dB on 40m–17m at low angles (20m is best), and high-angle lobes on 12m–10m. Base height changes it by only 0.5–2 dB. See tools/antenna/.

Heights: 10–100 ft for dipole/hex beam (antenna height), 4–12 ft for the elevated GP (base/radial height). Settings are remembered in the browser.

Soil applies to every antenna: very poor (0.001 S/m, city), poor (0.002, desert), average (0.005, clay), good (0.03, farmland) and salt water (5 S/m). Every antenna factor is measured against one fixed reference, a λ/4 vertical with good radials over average soil (antennas/vertical_lambda4.json, from tools/antenna/ground_reference.py). So poor ground makes every antenna worse, not just relatively better or worse than a vertical. Verticals are strongly soil-dependent: a λ/4 at 10° takeoff runs from about −3 dB on very poor ground to +7 dB at the shoreline. Horizontal dipoles and beams use the real ground reflection for horizontal polarization and change by only tenths of a dB.

The line under the band name at the top of the map shows the antenna, height, soil and the dB the map applies at 10° and 20° takeoff (in the antenna's best direction) against that reference. The band plan starts hidden; ☰ shows it.

Solar indices panel

Displays Solar Flux Index, K-index, A-index, and Sunspot Number pulled from DynamoDB. The data is refreshed automatically when the cached value is more than 2 hours old. Hover each card for a plain-English explanation.

Refresh button

The Refresh Now button is only shown when your callsign is WB0Z. It forces an immediate fetch from hamqsl.com regardless of cache age, writes a new row to the hf_solar history table (recording the callsign that triggered it), and updates the shared DynamoDB cache so all users see the new data.


API Reference

GET /heatmap/<band>

Returns heatmap data for the specified band.

Query parameters:

Parameter Default Description
lat 39.8 Station latitude
lon -98.6 Station longitude
antenna vertical vertical, dipole, hex_beam or egp_zf80 (Zero Five 10–80m)
height_ft 30 Antenna height in feet (base/radial height for egp_zf80; unused for vertical)
azimuth 0 Hex beam pointing direction (degrees)
dipole_orient 0 Dipole wire azimuth (0 = N–S, 90 = E–W)
soil average very_poor, poor, average, good or salt_water

Response: [[lat, lon, strength], ...], where strength (0.0–1.0) is the probability the band is open to that cell, after antenna and soil.


GET /antenna/<band>

Takes the same antenna parameters. Returns the dB the map applies for that antenna and soil against the reference (λ/4 vertical, good radials, average soil), in the antenna's best direction:

{"antenna": "egp_zf80", "soil": {"key": "poor", "label": "Poor", "sigma": 0.002, "er": 10.0, "examples": "desert, dry sand, rocky"},
 "gains": [{"elev": 10, "db": 2.1}, {"elev": 20, "db": 0.1}], "reference": "λ/4 vertical with good radials over average soil"}

Accounts — /auth/* and /admin/*

GET /auth/me; POST /auth/login, /auth/register, /auth/logout; POST /auth/reset/request (emails a 6-character code via SES) and /auth/reset/confirm. The session is the hf_auth cookie (30 days); passwords are PBKDF2-SHA256 hashed. Admin-only: GET /admin/users, POST /admin/users/deactivate, POST /admin/users/reset-password.


GET /solar

Returns current solar indices. Reads from the DynamoDB "current" row; fetches fresh if over 2 hours old.

{
  "SFI": 152.0,
  "K-index": 2.0,
  "A-index": 8.0,
  "Sunspot Number": 112.0,
  "source": "hamqsl.com",
  "last_update": 1750000000.0,
  "refreshed_by": "auto",
  "band_conditions": { "80m-40m_day": "Good", "20m-17m_day": "Fair" }
}

POST /solar/refresh

Forces a fresh solar fetch regardless of cache age. Updates DynamoDB. Returns the same shape as /GET /solar.

Body: {"callsign": "WB0Z"} — stored in the history row as refreshed_by.


GET /zip/<zipcode>

Geocodes a US ZIP code.

Response: {"zipcode": "90210", "city": "Beverly Hills", "state": "CA", "lat": 34.09, "lon": -118.41}


GET /robots.txt · GET /sitemap.xml

Static SEO endpoints for search-engine crawlers (plus /BingSiteAuth.xml for Bing verification). They are sent with Cache-Control: public, max-age=86400, and the root page with max-age=600. CloudFront has dedicated cache behaviors (CachingOptimized) for exactly those paths that honor the TTLs. Every other route uses the CachingDisabled default and always reaches the app. robots.txt disallows the API prefixes (/auth/, /admin/, /track/, /solar, /heatmap/, /antenna/, /zip/).


POST /track/visit

Body: {"callsign": "W1AW"}. Upserts the visitor row, increments access_count, updates last_seen and ip_address. No-op if callsign is empty (anonymous visitors are not tracked).

POST /track/callsign

Body: {"callsign": "W1AW", "session_id": "<uuid>"}. Creates or updates the callsign row; stores the current browser session_id as a reference attribute.

POST /track/qth

Body: {"callsign": "W1AW", "lat": 39.8, "lon": -98.6, "method": "grid"}. Updates QTH fields on the callsign row.


DynamoDB Schema

hf_solar

Two kinds of rows coexist in this table:

Fast-lookup row — always present, updated on every refresh:

Attribute Type Description
record_id String (PK) Always "current"
SFI Number Solar flux index
K-index Number Geomagnetic K-index
A-index Number Geomagnetic A-index
Sunspot Number Number Daily sunspot count
source String "hamqsl.com" or "NOAA"
band_conditions Map Per-band condition strings
timestamp String ISO 8601 UTC write time
timestamp_epoch Number Unix epoch — used for the 2-hour freshness check
refreshed_by String Callsign or "auto"

History rows: one new row per refresh, with the same attributes. record_id is a UTC timestamp string (e.g. 2026-06-22T14:30:00.123456Z), and an expire_at epoch lets DynamoDB TTL delete the row after 7 days.


hf_users

Attribute Type Description
callsign String (PK) Amateur callsign — stable cross-browser identity
session_id String Most recent browser localStorage UUID
ip_address String Last seen IP address
first_seen String ISO 8601 UTC — set once, never overwritten
last_seen String ISO 8601 UTC — updated on every visit
access_count Number Atomically incremented on every page load
qth_lat Number Station latitude
qth_lon Number Station longitude
qth_method String "grid", "latlon", or "zip"
password_hash etc. String Account fields: PBKDF2 hash, login token + expiry, email, reset code, active and admin flags

Propagation Model

Implemented in propagation.py with numpy-vectorized grid math; solar data is fetched with the stdlib urllib.

Path geometry — paths are split into equal hops of at most 3,500 km, with reflection points at the true great-circle hop midpoints.

foF2 — daytime peak 2.85 + 0.052×SFI (~8.1 MHz at SFI 100 at mid-latitudes), shaped by the solar zenith angle at each reflection point, so time of day, season and latitude all count. The layer starts ionizing when the sun is 20° below the horizon (at ~300 km it is sunlit before ground sunrise), lags the sun by 1.5 h, and fades after sunset with a 2 h time constant down to a night floor of 43% of the peak. Near the geomagnetic equator (weight cos(maglat)^8) the peak is up to 40% higher, the evening fade up to 4 h slower, and foF2 gets a further lift of up to 30% around 20:00 local time. This is the equatorial anomaly and its post-sunset "pre-reversal enhancement". Above 45° latitude the peak tapers down by up to 20% (trough/auroral zone). The equatorial terms were re-fitted on 29 ionosondes: tropical RMSE 2.34 → 1.82 MHz, and evening bias −2.1 → +0.2 MHz.

Calibration and validation (Sep 2026) — the foF2 constants were fitted to 16,200 GIRO ionosonde soundings (12 stations, one week, SFI 101–121) via KC2G's API: mid-latitude RMSE 1.26 → 0.83 MHz with no time-of-day bias, tropics 3.11 → 1.70 MHz, MUF(3000) bias +0.1 MHz. The whole map was then scored against a week of 20m WSPR reception from Southern California (106,000 receiver-hours from wspr.live): ranking AUC 0.80 → 0.83 with the fit, and 0.865 after auroral absorption. Heard paths shown dark fell from 16% to about 4%.

The ionosonde and WSPR checks can be re-run with the scripts in tools/validate/.

Auroral absorption — hop ground points (D-layer crossings) near the auroral zone lose 20 dB × exp(−½((|geomag lat| − (72 − 2·Kp)) / 4)²) per crossing at 20m, scaled by (f₂₀/f)^0.5, using a centred-dipole geomagnetic latitude (pole 80.8°N, 72.7°W). Tuned on WSPR with a 4-day/3-day train/test split: held-out AUC 0.814 → 0.859 on 20m and 0.789 → 0.887 on 40m. The West Coast ↔ Europe polar route drops from ~0.17 to ~0.01 mean strength, matching the 0.2% of European receivers that heard Southern California.

Greyline — when both ends of a path are in twilight (sun −12° to +3°, within ±60° latitude), the MUF is raised 15% and strength by 30%, a heuristic for the low absorption and terminator tilt of greyline paths.

MUF — foF2 × M-factor, where the M-factor comes from curved-earth hop geometry (300 km layer, 3° minimum takeoff): ~1 for short hops, ~3.4 for a 3,500 km hop. The weakest hop limits the path.

Strength — the probability the band is open on the path today: the MUF is a median, and measured day-to-day foF2 scatter is lognormal with σ ≈ 0.14, so strength = Φ(−ln(f/MUF) / 0.14) (0.95 at 0.8×MUF, 0.5 at the MUF, ~0.1 at 1.2×). The map hides strengths below 0.12 and fades in 0.12–0.35, where WSPR hearing rates jump from ~7% to ~29%.

D-layer absorption — per hop, 677·(1+0.0037·SSN)·cos(χ)^0.75 / (f+1.4)² · M dB (George–Bradley form), applied as strength × 10^(−dB/40). This is why 80m/40m fade on long daytime paths while 20m stays open.

Geomagnetic penalty — 1.0 − (K-index / 9) × 0.75 multiplied into all strengths.

Antenna factor — a power ratio against one fixed reference: a λ/4 vertical with good radials over average soil, at each path's takeoff angle (same curved-earth per-hop geometry). The vertical and the Zero Five use precomputed tables per band, soil and angle (antennas/). The dipole and hex beam combine their azimuth pattern with the direct plus ground-reflected wave for horizontal polarization, over the chosen soil.

Skip circle — the browser repeats the same foF2/M-factor math for 24 bearings and draws the shortest single-hop distance whose median MUF reaches the band.


Dependencies

Backend (requirements.txt):

Package Purpose
flask Web framework and template rendering
numpy Vectorized heatmap grid computation

Solar data fetches use the stdlib urllib (the requests dependency was removed to shrink the Lambda zip).

Lambda runtime (pre-installed — do not add to zip):

Package Purpose
boto3 AWS SDK — DynamoDB read/write, SES email

Development (requirements-dev.txt, never packaged): ruff (lint) and boto3 (for local runs). Optional: the NEC2++ command-line tool, only for regenerating antenna tables (see tools/antenna/).

Frontend (CDN, no install):

Library Purpose
D3.js v7 SVG world map, Winkel Tripel projection
d3-geo-projection v4 Winkel Tripel support
TopoJSON client v3 World geometry data

License

GPL-3.0 — you are free to use, modify, and distribute this software, but any derivative work must also be released under GPL-3.0.

About

Web GUI to show your propagation

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages