NATS Aurora AIP Aerodrome Data API Documentation¶
Overview¶
NATS (National Air Traffic Services) publishes the UK Aeronautical Information Publication (AIP) through the Aurora platform, providing comprehensive aerodrome data including coordinates, ATC frequencies, operating hours, and contact information. This data is essential for identifying nearby aerodromes with ATC services and understanding airspace coordination requirements for drone operations.
Data Source: NATS UK - Aurora AIP (Aeronautical Information Publication)
URL Pattern: https://www.aurora.nats.co.uk/htmlAIP/Publications/{AIRAC_DATE}/html/eAIP/EG-AD-2.{ICAO}-en-GB.html
Format: HTML (XHTML with structured tables)
Update Frequency: Every 28 days (AIRAC cycle)
Coverage: UK aerodromes (civilian + some military)
Discovery Method: OpenStreetMap Overpass API for aerodrome locations
Purpose in Drone Operations Application¶
We use NATS AIP aerodrome data to: 1. Identify nearby aerodromes within 50km of flight locations 2. Display ATC contact information (frequencies and phone numbers) for coordination 3. Warn about controlled airspace around aerodromes requiring permissions 4. Distinguish civilian and military aerodromes based on data availability 5. Cache aerodrome data for fast lookups without repeated scraping
This complements the airspace classification by providing specific aerodrome contact details for ATC coordination.
1. NATS Aurora AIP Overview¶
AIP Structure¶
Provider: NATS UK (National Air Traffic Services) Platform: Aurora AIP (Aeronautical Information Publication) Format: XHTML with embedded tables and structured data Coordinate System: DMS (Degrees, Minutes, Seconds) format Update Cycle: AIRAC (28-day cycles)
What is the Aurora AIP?¶
The Aurora AIP is NATS UK's electronic Aeronautical Information Publication system, providing authoritative aeronautical data for the UK. It replaces traditional paper AIP documents with a searchable, hyperlinked web interface.
Key Features: - AD 2 Section: Aerodrome-specific information (our focus) - Structured HTML: Consistent layout per aerodrome - AIRAC Versioning: Historical data available via dated URLs - Public Access: No authentication required for civilian aerodromes
AIRAC Cycle Characteristics¶
AIRAC (Aeronautical Information Regulation And Control):
- Base Date: 2025-12-25 (used for calculation)
- Cycle Length: Exactly 28 days
- Calculation Formula: base_date + (28 × cycle_number)
- URL Format: YYYY-MM-DD-AIRAC (e.g., 2026-01-22-AIRAC)
Example AIRAC Dates (2026):
2026-01-22 → Cycle 2601
2026-02-19 → Cycle 2602
2026-03-19 → Cycle 2603
2026-04-16 → Cycle 2604
URL Structure¶
Base URL Pattern:
https://www.aurora.nats.co.uk/htmlAIP/Publications/{AIRAC_DATE}/html/eAIP/EG-AD-2.{ICAO}-en-GB.html
Components:
- {AIRAC_DATE}: Format YYYY-MM-DD-AIRAC (e.g., 2026-01-22-AIRAC)
- {ICAO}: 4-character ICAO code (e.g., EGPH for Edinburgh)
- EG-AD-2: UK Aerodromes section
- en-GB: English (UK) language
Example URLs:
# Edinburgh Airport
https://www.aurora.nats.co.uk/htmlAIP/Publications/2026-01-22-AIRAC/html/eAIP/EG-AD-2.EGPH-en-GB.html
# Glasgow Airport
https://www.aurora.nats.co.uk/htmlAIP/Publications/2026-01-22-AIRAC/html/eAIP/EG-AD-2.EGPF-en-GB.html
# RAF Lossiemouth (Military - returns 404)
https://www.aurora.nats.co.uk/htmlAIP/Publications/2026-01-22-AIRAC/html/eAIP/EG-AD-2.EGQS-en-GB.html
2. HTML Structure and Data Extraction¶
Document Structure¶
NATS AIP pages are XHTML documents with consistent structure:
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-GB">
<head>
<title>Aedrome/Heliport EGPH</title>
<meta name="DC.title" content="Aedrome/Heliport EGPH"/>
</head>
<body>
<div id="AD-2.EGPH">
<h3 class="TitleAD">EGPH — EDINBURGH EDINBURGH</h3>
<!-- AD 2.1: Aerodrome Location -->
<h4>AD 2.1 AERODROME LOCATION INDICATOR AND NAME</h4>
<!-- AD 2.2: Aerodrome Geographic and Administrative Data -->
<h4>AD 2.2 AERODROME GEOGRAPHIC AND ADMINISTRATIVE DATA</h4>
<table>...</table>
<!-- AD 2.18: ATC Communication Facilities -->
<h4>AD 2.18 ATC AIRSPACE COMMUNICATION FACILITIES AND TRAFFIC SERVICES</h4>
<table>...</table>
</div>
</body>
</html>
Coordinate Extraction¶
Challenge: Coordinates appear in DMS format with potential hidden HTML text between latitude and longitude.
Common Patterns:
Pattern 1: 572850N 0072150W (standard spacing)
Pattern 2: 572850NTAIRSPACE_VERTEX;GEO_LAT_ARC;4630 0072150W (hidden text)
Pattern 3: 55 57 00 N 007 21 50 W (spaced format)
Regex Pattern (flexible to handle hidden text):
# Allow up to 200 characters between lat/lon for hidden HTML content
coord_pattern = r'(\d{6})([NS]).{0,200}?(\d{7})([EW])'
Conversion Formula (DMS to Decimal Degrees):
def dms_to_decimal(coord_str, direction):
"""
Convert DDMMSS to decimal degrees
Args:
coord_str: '572850' (6 digits for lat) or '0072150' (7 digits for lon)
direction: 'N', 'S', 'E', or 'W'
Returns:
float: Decimal degrees
"""
if len(coord_str) == 6: # Latitude
degrees = int(coord_str[0:2])
minutes = int(coord_str[2:4])
seconds = int(coord_str[4:6])
else: # Longitude (7 digits)
degrees = int(coord_str[0:3])
minutes = int(coord_str[3:5])
seconds = int(coord_str[5:7])
decimal = degrees + minutes/60 + seconds/3600
if direction in ['S', 'W']:
decimal = -decimal
return decimal
# Example
lat_str = '572850' # 57°28'50"N
lon_str = '0072150' # 007°21'50"W
latitude = dms_to_decimal(lat_str, 'N') # 57.480556
longitude = dms_to_decimal(lon_str, 'W') # -7.363889
Name Extraction¶
Multiple Methods (fallback chain):
Method 1: Page Title (most reliable)
<title>Aedrome/Heliport EGPH</title>
<h3 class="TitleAD">EGPH — EDINBURGH EDINBURGH</h3>
Extract pattern: ICAO — CITY/LOCATION or ICAO — CITY CITY
Parsing Logic:
# Handle "CITY/LOCATION" format
if '/' in name_part:
city, location = name_part.split('/')
if city == location:
return f"{city.title()} Airport"
else:
return f"{city.title()} {location.title()} Airport"
Method 2: Section Headings
<h4>AD 2.1 EDINBURGH</h4>
Method 3: Tables
<td>Aerodrome name</td>
<td>Edinburgh Airport</td>
Method 4: Fallback Dictionary
KNOWN_NAMES = {
'EGPH': 'Edinburgh Airport',
'EGPF': 'Glasgow Airport',
'EGPK': 'Glasgow Prestwick Airport',
'EGPL': 'Benbecula Airport',
'EGPR': 'Barra Airport',
# ... 17 more common UK aerodromes
}
Frequency Extraction¶
Location: AD 2.18 - ATC Communication Facilities section
Table Structure:
<h4>AD 2.18 ATC AIRSPACE COMMUNICATION FACILITIES AND TRAFFIC SERVICES</h4>
<table>
<tr>
<td>Service</td>
<td>Callsign</td>
<td>Frequency</td>
</tr>
<tr>
<td>Tower</td>
<td>Edinburgh Tower</td>
<td>118.700</td>
</tr>
<tr>
<td>Approach</td>
<td>Edinburgh Approach</td>
<td>121.200</td>
</tr>
</table>
Extraction Logic:
def extract_frequencies(soup):
"""Extract ATC frequencies from tables"""
frequencies = {}
# Find AD 2.18 section
for heading in soup.find_all(['h4', 'h5']):
if 'AD 2.18' in heading.text or 'ATC' in heading.text:
# Find following table
table = heading.find_next('table')
if table:
for row in table.find_all('tr'):
cells = row.find_all('td')
if len(cells) >= 3:
service = cells[0].text.strip().lower()
frequency = cells[2].text.strip()
# Validate frequency format (XXX.XXX MHz)
if re.match(r'^\d{3}\.\d{3}$', frequency):
if service not in frequencies:
frequencies[service] = []
frequencies[service].append(frequency)
return frequencies
# Example Result
{
'tower': ['118.700'],
'approach': ['121.200'],
'ground': ['121.750'],
'atis': ['125.650']
}
Phone Number Extraction¶
Location: AD 2.2 - Aerodrome Geographic Data section or contact tables
Pattern Matching:
def extract_phone_numbers(soup):
"""Extract phone numbers from text content"""
phone_numbers = {}
text = soup.get_text()
# UK phone number patterns
patterns = [
r'\+44\s*\d{3,4}\s*\d{6,7}', # +44 131 333 1000
r'0\d{3,4}\s*\d{6,7}', # 0131 333 1000
r'\d{4,5}\s*\d{5,6}' # 03300-271262
]
for pattern in patterns:
matches = re.findall(pattern, text)
if matches:
phone_numbers['main'] = matches[0]
break
return phone_numbers
3. Aerodrome Discovery via OpenStreetMap¶
Overpass API Query¶
Purpose: Discover aerodromes within radius before scraping NATS data
Query Structure:
[out:json][timeout:15];
(
node["aeroway"="aerodrome"](around:50000,55.9533,-3.1883);
way["aeroway"="aerodrome"](around:50000,55.9533,-3.1883);
relation["aeroway"="aerodrome"](around:50000,55.9533,-3.1883);
);
out body centre;
Parameters:
- around:50000 - 50km radius in meters
- 55.9533,-3.1883 - Centre coordinates (Edinburgh Napier)
- ["aeroway"="aerodrome"] - Filter for aerodromes only
Response Example:
{
"elements": [
{
"type": "way",
"id": 274656610,
"centre": {
"lat": 57.4811145,
"lon": -7.362038
},
"tags": {
"aeroway": "aerodrome",
"icao": "EGPL",
"name": "Port Adhair Bheinn na Faoghla",
"name:en": "Benbecula Airport"
}
},
{
"type": "node",
"id": 123456789,
"lat": 57.7076,
"lon": -3.3312,
"tags": {
"aeroway": "aerodrome",
"icao": "EGQS",
"military": "airfield",
"landuse": "military",
"name": "RAF Lossiemouth"
}
}
]
}
Military Aerodrome Detection¶
OSM Tags Used:
is_military = (
tags.get('military') in ['airfield', 'airbase', 'yes'] or
tags.get('aerodrome:type') == 'military' or
tags.get('landuse') == 'military' or
'RAF' in name or
'Royal Air Force' in name
)
Military Aerodrome Examples: - EGQS - RAF Lossiemouth - EGQL - RAF Leuchars - EGVN - RAF Brize Norton
Handling: Military aerodromes typically return 404 from NATS (civilian AIP), so placeholder records are created with OSM data only.
4. Two-Tier Data Strategy¶
Hybrid Approach¶
Discovery Phase (OpenStreetMap): 1. Query Overpass API for aerodromes within 50km 2. Extract ICAO codes, names, coordinates from OSM 3. Detect military status from tags
Enrichment Phase (NATS AIP):
1. For each discovered aerodrome:
- Check database cache
- If missing or stale (>60 days), scrape NATS AIP
- Store enriched data (frequencies, phones)
2. If NATS scraping fails (404):
- Store placeholder with OSM data only
- Mark as is_placeholder: true
- Flag if military
Data Completeness Levels¶
Level 1: Full Data (NATS AIP successful)
{
"icao_code": "EGPH",
"name": "Edinburgh Airport",
"latitude": 55.9050,
"longitude": -3.5017,
"frequencies": {
"tower": ["118.705"],
"approach": ["121.200"]
},
"phone_numbers": {
"main": "03300-271262"
},
"is_placeholder": false,
"data_quality_score": 85
}
Level 2: Placeholder Data (NATS AIP failed - OSM only)
{
"icao_code": "EGQS",
"name": "RAF Lossiemouth [Military]",
"latitude": 57.7076,
"longitude": -3.3312,
"frequencies": {},
"phone_numbers": {},
"is_military": true,
"is_placeholder": true,
"placeholder_reason": "NATS AIP scraping failed - data from OSM only",
"data_source": "osm_only",
"data_quality_score": 20
}
5. Caching and Staleness¶
Database Storage¶
Table: PlaceOfInterest
- osm_id: "aerodrome/{ICAO_CODE}"
- place_type: "aerodrome"
- place_category: "aviation"
- last_updated: Timestamp of last NATS scrape
- data_quality_score: 20-100 based on completeness
- extra_data: JSON with ATC details
extra_data Structure:
{
"icao_code": "EGPH",
"aerodrome_name": "Edinburgh Airport",
"atc_callsign": "",
"frequencies": {"tower": ["118.705"]},
"phone_numbers": {"main": "03300-271262"},
"airac_cycle": "2026-01-22-AIRAC",
"data_source": "nats_aip",
"is_military": false,
"is_placeholder": false,
"last_scraped": "2026-01-13T10:00:00Z"
}
Staleness Detection¶
Threshold: 60 days Calculation:
age_delta = datetime.now(timezone.utc) - last_updated
data_age_days = age_delta.days
is_stale = data_age_days > 60 # STALENESS_THRESHOLD_DAYS
Staleness Handling: - Display warning in UI: "Data is X days old. Verify with current AIP." - Auto-refresh attempted on next lookup - If refresh fails, keep using stale data (better than none)
6. Data Quality Scoring¶
Calculation Logic¶
def calculate_data_quality_score(data):
"""
Calculate quality score (0-100) based on data completeness
Components:
- Has name: +25 points
- Has coordinates: +25 points (always present)
- Has phone: +15 points
- Has frequencies: +30 points (10 per frequency type, max 30)
- Has ATC callsign: +5 points
"""
score = 0
# Name (25 points)
if data.get('name') and data['name'] != 'UNKNOWN':
score += 25
# Coordinates (25 points - always present from OSM)
score += 25
# Phone (15 points)
if data.get('phone_numbers'):
score += 15
# Frequencies (30 points max)
frequencies = data.get('frequencies', {})
freq_score = min(len(frequencies) * 10, 30)
score += freq_score
# ATC Callsign (5 points)
if data.get('atc_callsign'):
score += 5
return min(score, 100)
# Examples
# Full data (EGPH): 85-95
# Basic data (EGPL): 55-65
# Placeholder (EGQS): 20
7. Error Handling and Fallbacks¶
HTTP Errors¶
| Error Code | Meaning | Action |
|---|---|---|
| 200 | Success | Parse HTML normally |
| 404 | Aerodrome not in civilian AIP | Store placeholder with OSM data |
| 403 | Access forbidden | Log error, skip aerodrome |
| 500 | NATS server error | Retry once, then skip |
| Timeout | Network timeout (30s) | Skip aerodrome, log warning |
Parsing Failures¶
Coordinate Extraction Failed:
# Fallback to known coordinates dictionary
FALLBACK_COORDINATES = {
'EGPH': (55.9500, -3.3725),
'EGPF': (55.8719, -4.4331),
# ... 13 more
}
if icao_code in FALLBACK_COORDINATES:
latitude, longitude = FALLBACK_COORDINATES[icao_code]
else:
raise ValueError("Could not extract coordinates")
Name Extraction Failed:
# Fallback to known names or generic format
if icao_code in KNOWN_NAMES:
return KNOWN_NAMES[icao_code]
else:
return f"{icao_code} Airport"
8. Example Test Locations¶
1. Edinburgh Area¶
Coordinates: 55.9533, -3.1883 (Edinburgh Napier University)
Expected Aerodromes: - EGPH - Edinburgh Airport (~8 km west) - Classification: Full data - Frequencies: Tower 118.705, Approach 121.200 - Phone: 03300-271262
2. Outer Hebrides (RAF Benbecula)¶
Coordinates: 57.4669, -7.3704 (near Benbecula)
Expected Aerodromes: - EGPL - Benbecula Airport (~1.6 km) - Classification: Full data (civilian operations despite RAF presence) - Frequencies: Available - EGPR - Barra Airport (~49.6 km) - Classification: Full data - Unique: Beach runway
3. Moray Firth (Military Base)¶
Coordinates: 57.7172, -3.3389 (near RAF Lossiemouth)
Expected Aerodromes: - EGQS - RAF Lossiemouth (~1.2 km) - Classification: Placeholder (military - 404 from NATS) - Name: "RAF Lossiemouth [Military]" - is_military: true - Quality score: 20
9. Python Implementation Example¶
Complete Scraping Function¶
import requests
from bs4 import BeautifulSoup
import re
from datetime import datetime, timezone
def scrape_aerodrome_page(icao_code, airac_date):
"""
Scrape NATS AIP page for aerodrome data
Args:
icao_code: 4-char ICAO code (e.g., 'EGPH')
airac_date: AIRAC date string (e.g., '2026-01-22-AIRAC')
Returns:
dict: Success result with aerodrome data or error
"""
# Construct URL
url = f"https://www.aurora.nats.co.uk/htmlAIP/Publications/{airac_date}/html/eAIP/EG-AD-2.{icao_code}-en-GB.html"
try:
# Fetch page (30 second timeout)
response = requests.get(url, timeout=30)
# Handle 404 (military/non-existent aerodromes)
if response.status_code == 404:
return {
'success': False,
'error': f'NATS AIP page not found for {icao_code}'
}
response.raise_for_status()
# Parse HTML
soup = BeautifulSoup(response.content, 'lxml')
# Extract data
name = extract_name(soup, icao_code)
latitude, longitude = extract_coordinates(soup)
frequencies = extract_frequencies(soup)
phone_numbers = extract_phone_numbers(soup)
return {
'success': True,
'data': {
'name': name,
'latitude': latitude,
'longitude': longitude,
'frequencies': frequencies,
'phone_numbers': phone_numbers
}
}
except requests.exceptions.Timeout:
return {
'success': False,
'error': 'Request timeout after 30 seconds'
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def extract_coordinates(soup):
"""Extract coordinates with flexible regex"""
text = soup.get_text()
# Pattern handles hidden text between lat/lon
pattern = r'(\d{6})([NS]).{0,200}?(\d{7})([EW])'
match = re.search(pattern, text, re.IGNORECASE)
if match:
lat_str, lat_dir, lon_str, lon_dir = match.groups()
latitude = dms_to_decimal(lat_str, lat_dir.upper())
longitude = dms_to_decimal(lon_str, lon_dir.upper())
return latitude, longitude
# Fallback to known coordinates
if icao_code in FALLBACK_COORDINATES:
return FALLBACK_COORDINATES[icao_code]
raise ValueError("Could not extract coordinates")
10. References¶
Official Documentation¶
- NATS Aurora AIP: aurora.nats.co.uk
- ICAO Codes: ICAO Location Indicators
- AIRAC Cycles: EUROCONTROL AIS
- UK Drone Regulations: CAA CAP 722
External APIs¶
- OpenStreetMap Overpass API: overpass-api.de
- Overpass Turbo (Query Builder): overpass-turbo.eu
Python Libraries¶
- requests: HTTP client for page fetching
- beautifulsoup4: HTML parsing
- lxml: Fast XML/HTML parser backend
- shapely: Spatial calculations (distance)
Version History¶
Version 1.0 - 2026-01-13 - Initial documentation for NATS AIP aerodrome scraping - OpenStreetMap discovery integration - Military aerodrome detection - Placeholder data handling - Coordinate extraction with flexible regex for hidden HTML text