/ eu-air-traffic / services / emissions.py
swadhinbiswas Estimate emissions from OpenAP kinematics, not a flat per-type rate · 9/13/2026
6762ee2
155 lines · 5.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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
"""Aircraft emissions — the one derived data product.

There is no emissions API; CO₂ is estimated from an aircraft's live telemetry.
When the airframe is in the precomputed OpenAP table (TU Delft's open aircraft
performance model), fuel flow comes from actual kinematics — mass, altitude,
speed and vertical rate — instead of a flat per-type rate. Unknown types and
rows without telemetry keep the ICAO Carbon Emissions Calculator table, and
unknown-airframe fallbacks stay flagged as estimates.
"""

from __future__ import annotations

import json
from functools import lru_cache
from pathlib import Path
from typing import Any

from ingestion.emissions.collector import (
    EMISSION_FACTOR_KG_PER_LITER,
    FUEL_BURN_RATES,
    FUEL_DENSITY_KG_PER_LITER,
)

DEFAULT_TYPE = "A320"

# Transponder pseudo-types for ground vehicles and fixed installations. They
# emit ADS-B but burn no jet fuel, so they must never receive the fallback
# airliner rate (previously a TWR/GND contact counted as a full A320).
GROUND_TYPES = frozenset({"GND", "GRND", "TWR", "TOWER"})

_OPENAP_PATH = Path(__file__).resolve().parent / "data" / "emission_openap.json"


@lru_cache(maxsize=1)
def _openap() -> dict[str, Any]:
    """Precomputed OpenAP grid (``scripts/build_openap_emissions.py``)."""
    try:
        payload = json.loads(_OPENAP_PATH.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return {}
    return payload if isinstance(payload, dict) else {}


def _nearest_index(bands: list[float], value: float) -> int:
    return min(range(len(bands)), key=lambda index: abs(float(bands[index]) - value))


def _modelled_fuel(
    code: str,
    altitude: float | None,
    speed_kt: float | None,
    vertical_rate: float | None,
    on_ground: bool,
) -> float | None:
    """OpenAP fuel flow in kg/h, or None when the type/telemetry has no model."""
    payload = _openap()
    table = (payload.get("types") or {}).get(code)
    if not table:
        return None
    if on_ground:
        return float(table["ground"])
    if altitude is None and speed_kt is None:
        return None
    meta = payload.get("_meta") or {}
    altitude = float(altitude if altitude is not None else meta["alt_bands_ft"][-1])
    speed_kt = float(speed_kt if speed_kt is not None else meta["speed_bands_kt"][-1])
    vs = float(vertical_rate or 0.0)
    return float(
        table["flow"][_nearest_index(meta["vs_bands_fpm"], vs)][
            _nearest_index(meta["alt_bands_ft"], altitude)
        ][_nearest_index(meta["speed_bands_kt"], speed_kt)]
    )


def emission_estimate(
    aircraft_type: str | None,
    altitude: float | None = None,
    speed_kt: float | None = None,
    vertical_rate: float | None = None,
    on_ground: bool = False,
) -> tuple[float, float, bool]:
    """Return ``(fuel_kg_per_hour, co2_kg_per_hour, estimated)``.

    Ground vehicles resolve to a confident zero. A type in the OpenAP table
    uses the kinematic model; anything else falls back to the static table and
    then to an A320 narrowbody rate flagged as estimated, so the dashboard
    never presents a guess as a measurement.
    """
    code = (aircraft_type or "").upper()
    if code in GROUND_TYPES:
        return 0.0, 0.0, False
    modelled = _modelled_fuel(code, altitude, speed_kt, vertical_rate, on_ground)
    if modelled is not None:
        fuel = round(modelled, 1)
        return fuel, round(fuel * EMISSION_FACTOR_KG_PER_LITER, 1), False
    rate_lph = FUEL_BURN_RATES.get(code)
    estimated = rate_lph is None
    if rate_lph is None:
        rate_lph = FUEL_BURN_RATES[DEFAULT_TYPE]
    fuel = round(rate_lph * FUEL_DENSITY_KG_PER_LITER, 1)
    co2 = round(fuel * EMISSION_FACTOR_KG_PER_LITER, 1)
    return fuel, co2, estimated


def fuel_burn_kg_per_hour(aircraft_type: str | None) -> float:
    """Hourly fuel burn in kg for a type, falling back to a narrowbody."""
    fuel, _, _ = emission_estimate(aircraft_type)
    return fuel


def co2_kg_per_hour(aircraft_type: str | None) -> float:
    """Instantaneous CO₂ emission rate in kg/hour for a type."""
    _, co2, _ = emission_estimate(aircraft_type)
    return co2


def enrich_emissions(row: dict[str, Any]) -> dict[str, Any]:
    """Add fuel/CO₂ rates to an aircraft position row (in place).

    Uses the OpenAP kinematic model when the type is known and the row carries
    telemetry; otherwise the static per-type table.
    """
    fuel, co2, estimated = emission_estimate(
        row.get("aircraft_type"),
        altitude=row.get("altitude"),
        speed_kt=row.get("velocity"),
        vertical_rate=row.get("vertical_rate"),
        on_ground=bool(row.get("on_ground")),
    )
    row["fuel_burn_kg_per_hour"] = fuel
    row["co2_kg_per_hour"] = co2
    row["co2_estimated"] = estimated
    return row


def emission_factors() -> list[dict[str, Any]]:
    """Reference rows: per-type hourly fuel and CO₂ (used by the live + Gold)."""
    return [
        {
            "aircraft_type": aircraft_type,
            "fuel_burn_liters_per_hour": rate_lph,
            "fuel_burn_kg_per_hour": round(rate_lph * FUEL_DENSITY_KG_PER_LITER, 1),
            "co2_kg_per_hour": round(
                rate_lph * FUEL_DENSITY_KG_PER_LITER * EMISSION_FACTOR_KG_PER_LITER, 1
            ),
            "co2_tonnes_per_hour": round(
                rate_lph * FUEL_DENSITY_KG_PER_LITER * EMISSION_FACTOR_KG_PER_LITER / 1000, 3
            ),
            "emission_factor_kg_per_kg_fuel": EMISSION_FACTOR_KG_PER_LITER,
            "fuel_density_kg_per_liter": FUEL_DENSITY_KG_PER_LITER,
            "source": "icao_methodology",
        }
        for aircraft_type, rate_lph in sorted(FUEL_BURN_RATES.items())
    ]