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.
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.
- 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
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
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.
| Environment | Guide |
|---|---|
| Local / development | LOCAL_INSTALL.md |
| AWS Lambda + CloudFront | AWS_INSTALL.md |
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.
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.
| 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 |
| 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.
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.
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.
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.
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.
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"}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.
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" }
}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.
Geocodes a US ZIP code.
Response: {"zipcode": "90210", "city": "Beverly Hills", "state": "CA", "lat": 34.09, "lon": -118.41}
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/).
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).
Body: {"callsign": "W1AW", "session_id": "<uuid>"}. Creates or updates the callsign row; stores the current browser session_id as a reference attribute.
Body: {"callsign": "W1AW", "lat": 39.8, "lon": -98.6, "method": "grid"}. Updates QTH fields on the callsign row.
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.
| 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 |
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.
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 |
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.