/ eu-air-traffic / scripts / fetch_eurostat.py
swadhinbiswas Add the Eurostat official airport-traffic benchmark to Gold · 9/13/2026
06b3c8c
140 lines · 4.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""Fetch Eurostat monthly airport traffic (official benchmark layer).

`avia_paoa` reports passenger numbers for ~870 European airports, monthly,
~2 months behind. It is a *benchmark*, not live data: the Gold layer joins it
to our counted movements so the airport leaderboard can be cross-checked
against the official figures.

One request covers every month in the dataset, so this runs at most monthly:

    python -m scripts.fetch_eurostat
"""

from __future__ import annotations

import argparse
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import polars as pl
import requests

from config.logging import logger
from config.settings import settings

EUROSTAT_URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/avia_paoa"
# Monthly boarded passengers, all schedule types and coverage.
FILTERS = {
    "format": "JSON",
    "lang": "EN",
    "freq": "M",
    "unit": "PAS",
    "tra_meas": "PAS_BRD",
    "schedule": "TOTAL",
}


def _decode(payload: dict[str, Any]) -> list[dict[str, Any]]:
    """Decode JSON-stat 2.0 into rows filtered to the requested dimensions."""
    order: list[str] = payload.get("id") or []
    size: list[int] = payload.get("size") or []
    if not order or not size:
        return []
    dimensions = payload.get("dimension") or {}
    categories: dict[str, list[str]] = {}
    for name in order:
        index = (dimensions.get(name) or {}).get("category", {}).get("index") or {}
        if isinstance(index, dict):
            categories[name] = [code for code, _ in sorted(index.items(), key=lambda kv: kv[1])]
        else:
            categories[name] = [str(entry) for entry in index]

    airport_at = order.index("rep_airp")
    time_at = order.index("time")
    rows: list[dict[str, Any]] = []
    for flat, value in (payload.get("value") or {}).items():
        if value is None:
            continue
        remaining = int(flat)
        indices = [0] * len(order)
        for position in range(len(order) - 1, -1, -1):
            indices[position] = remaining % size[position]
            remaining //= size[position]
        airport = categories["rep_airp"][indices[airport_at]]
        period = categories["time"][indices[time_at]]
        # Eurostat airport codes look like "DE_EDDF"; the ICAO part joins dim_airport.
        icao = airport.split("_")[-1].upper()
        if len(icao) != 4 or not icao.isalnum():
            continue
        try:
            passengers = int(float(value))
        except (TypeError, ValueError):
            continue
        rows.append({"period": str(period), "airport_icao": icao, "passengers": passengers})
    return rows


def _recent_months(count: int) -> list[str]:
    """The last ``count`` calendar months as YYYY-MM (Eurostat rejects bulk pulls)."""
    today = datetime.now(UTC).date()
    months: list[str] = []
    year, month = today.year, today.month
    for _ in range(count):
        months.append(f"{year:04d}-{month:02d}")
        month -= 1
        if month == 0:
            year, month = year - 1, 12
    return list(reversed(months))


def fetch(months: int = 12) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for period in _recent_months(months):
        response = requests.get(EUROSTAT_URL, params={**FILTERS, "time": period}, timeout=60)
        response.raise_for_status()
        rows.extend(_decode(response.json()))
    return rows


def write_parquet(app_settings=None, months: int = 12) -> Path:
    cfg = app_settings or settings
    rows = fetch(months)
    if not rows:
        raise RuntimeError("[eurostat] no observations returned — refusing to write an empty file")
    fetched_at = datetime.now(UTC).isoformat()
    frame = (
        pl.DataFrame(rows)
        .with_columns(
            pl.lit("eurostat").alias("source"),
            pl.lit(fetched_at).alias("fetched_at"),
        )
        .unique(subset=["period", "airport_icao"], keep="last")
        .sort(["period", "airport_icao"])
    )
    target = Path(cfg.project_root) / "services" / "data" / "eurostat_airport_traffic.parquet"
    target.parent.mkdir(parents=True, exist_ok=True)
    frame.write_parquet(target, compression="zstd")
    logger.info(
        "[eurostat] %s rows · %s airports · %s..%s → %s",
        frame.height,
        frame["airport_icao"].n_unique(),
        frame["period"].min(),
        frame["period"].max(),
        target,
    )
    return target


def main() -> int:
    parser = argparse.ArgumentParser(description="Fetch Eurostat airport traffic")
    parser.parse_args()
    target = write_parquet()
    print(json.dumps({"file": str(target)}))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())