Skip to content

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

External APIs

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