Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/explanation/input-prioritization.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ graph TD
A quantity counts as provided when at least one point has a value. Points left empty are not filled from BADA: they stay empty and are left out of the fuel totals. After the resampling to 60 s, an empty point also empties the resampled points between its two neighbours.

!!! note "Fuel-burn plausibility check"
A flight is rejected when its total fuel burn, with the fuel flow actually used (AO, derived from the mass, or BADA), exceeds 1.2 × (MTOW − OEW). A flight rejected by this check goes through the retries described in [BADA Type Mapping](#bada-type-mapping): BADA 3, then (when the flight has points below FL20) the calculation without those points. It fails only if every attempt is rejected. The check is not applied when fuel flow, engine efficiency and aircraft mass are all provided. A BADA fuel flow above 100 kg/s at a point counts as a BADA failure at that point; AO fuel flow is not checked point by point.
A flight is rejected when its total fuel burn, with the fuel flow actually used (AO, derived from the mass, or BADA), exceeds 1.2 × (MTOW − OEW). A flight rejected by this check goes through the retries described in [BADA Type Mapping](#bada-type-mapping): BADA 3, then (when the flight has points below FL20) the calculation without those points. It fails only if every attempt is rejected. The check is not applied when fuel flow, engine efficiency and aircraft mass are all provided. A BADA fuel flow above 100 kg/s, negative or not a number at a point counts as a BADA failure at that point (the point takes the values of the previous point; more than 5 such points in a row make the attempt fail); AO fuel flow is not checked point by point.

!!! note "Low-speed points"
Before any of the cases above, points whose true airspeed is below the BADA stall speed (at MTOW, take-off configuration), or that have no true airspeed, are removed, also when the AO provides fuel flow, engine efficiency and aircraft mass. If more than 80 % of the points would be removed, none are. Currently this applies only to aircraft computed with BADA 3; this filter is under review.
Expand Down
31 changes: 31 additions & 0 deletions docs/internal/pyneats_units_audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ cite line numbers on `dev` before the fix they describe.
| A11/A12 (fuel energy reference of the BADA fuel flow) | Fixed: reference = BADA4 LHV 43.217 MJ/kg for BADA4 and BADA3 (maintainer decision); docs corrected; fuel and CO2 +0.21 % on the sample | `fix/fuel-energy-reference` | 2026-10-22 |
| I12 (FlightRunner and FleetRunner give different contrail results for the same flight) | Open; recorded; contrail-based values get rtol 5e-3 in the FlightRunner golden test | `fix/fuel-energy-reference` | 2026-10-22 |
| D4 (partially empty AO columns) | Fixed: empty values skipped when resampling (straight line between valid neighbours; maintainer decision); no change on the NM sample | `fix/partial-null-columns` | 2026-10-22 |
| I13 (a non-finite BADA fuel flow made the mass NaN for the rest of the flight) | Fixed: treated as a BADA failure at that point (previous point's values; maintainer decision); fleet fuel +0.005 %, 5 flights change | `fix/bada-nonfinite-fuel-flow` | 2026-10-23 |

## 1. Outcome

Expand Down Expand Up @@ -951,6 +952,36 @@ for a separate decision and were not fixed.
at rtol 5e-3; everything else stays at 1e-3, and the FleetRunner golden tests are
unchanged. To investigate: whether the two runners should give identical contrails.

- **I13. A non-finite BADA fuel flow made the mass NaN for the rest of the flight.** Found
2026-10-23 during the low-speed filter work. *Fixed on branch `fix/bada-nonfinite-fuel-flow`
(see the follow-up status table).*
- **Defect.** `_single_pass_performance` (`bada_model.py:895-1026` on `dev` 5aba2f9)
treats a point as a BADA failure when BADA raises or when the fuel flow is above
`ff_outlier_threshold` or negative (`:971`); the point then takes the previous point's
values, and more than `max_consecutive_failures` (5) failures in a row, or a failure at
the first point, fail the attempt. A NaN fuel flow passes both comparisons, so it was
accepted: it became the "previous" value (`:976`), and with a take-off mass the
integrated mass (`:1018`) was NaN from that point on. The fuel of every later point was
then NaN and dropped out of the sums (trip fuel, guardrail and totals skip NaN). BADA4
gives a NaN fuel flow, without raising, wherever the TAS is NaN: at a frozen raw position
(zero-length segment) and at the last point of the computed-TAS path. BADA3 runs did not
reach it on `dev`, because the speed filter drops NaN-TAS rows.
- **Effect before the fix** (NM sample 2025-07-09, 4,959 flights, `dev` 5aba2f9): 3 BADA4
flights (VLG1254, IBE09LA, IBS15XJ) had NaN fuel on their last 21 rows each (63 rows);
4 flights (RYR4WR, RYR6EF, EZY87KM, VLG90AB) fell back to BADA3 through exceptions on the
NaN mass.
- **Fix.** A fuel flow that is not finite is a BADA failure at that point, like an
outlier, so the existing rule applies (previous point's values; the attempt fails after
more than 5 in a row or at the first point).
- **Effect** (fleet before/after on the same 4,959 flights, measured with this rule):
same 4,303 flights computed, no NaN fuel row left; fleet fuel +0.005 % (+2.5 t), CO2
+0.005 %; 5 flights change: VLG1254 +36.8 %, IBE09LA +10.1 %, RYR4WR +16.5 % and VLG90AB
+7.5 % (both now BADA4), IBS15XJ −2.1 % (now BADA3: its 8 NaN-TAS rows at a frozen
raw position exceed the 5-failure limit). RYR6EF and EZY87KM stay BADA3 (other BADA4
failures). The last point of a BADA4 flight now carries the previous point's fuel flow
instead of NaN; its fuel burn stays empty (no next point), so totals do not change
there.

## 11. Patch summary and checks performed

**Files modified.** Comments and docstrings were added or corrected in 27 source files (+826/−178 lines). New
Expand Down
15 changes: 14 additions & 1 deletion src/pyneats/steps/performance/bada_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,11 @@ def _single_pass_performance(
``fuel_flow`` column is provided. Without it, the ``aircraft_mass``
column [kg] is used point by point. Returned fuel flow is kg/s for the
whole aircraft and thrust is N.

A point where BADA raises, or gives a fuel flow that is not finite,
negative or above ``ff_outlier_threshold`` [kg/s], takes the values of
the previous point; more than ``max_consecutive_failures`` such points
in a row, or such a first point, fail the attempt (retryable).
"""

if initial_mass is None and "aircraft_mass" not in df.columns:
Expand Down Expand Up @@ -968,7 +973,15 @@ def _single_pass_performance(
phase_val = phase
segment_val = str(thrust_seg)

if (ff_corrected > ff_outlier_threshold) or (ff_corrected < 0.0):
# A non-finite fuel flow (e.g. BADA at a NaN TAS) is handled like
# an outlier. NaN fails both comparisons, so without the
# isfinite test it became the "previous" value and made the
# integrated mass NaN for the rest of the flight (audit I13).
if (
not np.isfinite(ff_corrected)
or (ff_corrected > ff_outlier_threshold)
or (ff_corrected < 0.0)
):
raise Exception("BADA fuel flow is unrealistic")

# If successful, reset the failure counter and update our "last known good" values
Expand Down
133 changes: 133 additions & 0 deletions tests/test_bada_nonfinite_fuel_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""A non-finite BADA fuel flow counts as a BADA failure at that point (audit I13).

``_single_pass_performance`` replaces a point where BADA fails (raises, or gives
a fuel flow above ``ff_outlier_threshold`` or below 0) by the values of the
previous point, and fails the attempt after ``max_consecutive_failures`` such
points in a row. A NaN fuel flow passed both comparisons, so it was accepted:
it became the "previous" value, and the mass integrated from a take-off mass
was NaN for the rest of the flight, so all later fuel was lost.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock

import numpy as np
import pandas as pd
import pytest


pytest.importorskip("pyBADA", reason="pyBADA required for performance module imports")

from pyneats.steps.performance.bada_model import BADAPerformanceModel
from pyneats.steps.performance.protocol import PerformanceStepError


SEGMENT_S = 60.0
FUEL_FLOW = 1.0 # kg/s, BADA fuel flow at a finite TAS
THRUST = 50_000.0 # N
TOW = 70_000.0 # kg
MAX_CONSECUTIVE_FAILURES = 5


class _FakeAdapter:
"""BADA stand-in: fuel flow ``FUEL_FLOW`` at a finite TAS, NaN otherwise.

BADA4 gives NaN fuel flow and thrust at a NaN TAS without raising.
"""

def thrust_fuel_segment(
self,
mass: float,
altitude_m: float,
tas_mps: float,
rocd_mps: float,
accel_mps2: float,
delta_tau: float,
) -> tuple[float, float, str, str]:
if not np.isfinite(tas_mps):
return float("nan"), float("nan"), "Cruise", "TOTAL"
return FUEL_FLOW, THRUST, "Cruise", "TOTAL"


def _make_model() -> MagicMock:
model = MagicMock(spec=BADAPerformanceModel)
model.logger = MagicMock()
model.params = MagicMock()
model.params.max_consecutive_failures = MAX_CONSECUTIVE_FAILURES
model.params.ff_outlier_threshold = 100.0
model._single_pass_performance = (
BADAPerformanceModel._single_pass_performance.__get__(model)
)
return model


def _make_df(tas: list[float]) -> pd.DataFrame:
n = len(tas)
return pd.DataFrame(
{
"altitude": [10_000.0] * n,
"true_airspeed": tas,
"rocd": [0.0] * n,
"acceleration": [0.0] * n,
"delta_tau": [0.0] * n,
"segment_duration": [SEGMENT_S] * (n - 1) + [float("nan")],
}
)


def _run(tas: list[float], initial_mass: float | None = TOW) -> Any:
df = _make_df(tas)
if initial_mass is None:
df["aircraft_mass"] = TOW
return _make_model()._single_pass_performance(
_FakeAdapter(), df, initial_mass, q_fuel_used=1.0, default_q_fuel=1.0
)


def test_interior_nan_fuel_flow_takes_previous_point() -> None:
nan = float("nan")
perf = _run([230.0, 230.0, nan, nan, 230.0, 230.0, 230.0])

assert perf["fuel_flow"] == [FUEL_FLOW] * 7
assert perf["thrust"] == [THRUST] * 7
# The mass stays finite and decreases by FUEL_FLOW x 60 s per segment.
expected = [TOW - FUEL_FLOW * SEGMENT_S * i for i in range(7)]
np.testing.assert_allclose(perf["mass"], expected)


def test_nan_last_point_takes_previous_point() -> None:
# On the computed-TAS path the last point has no TAS (forward segment).
perf = _run([230.0, 230.0, 230.0, float("nan")])

assert perf["fuel_flow"] == [FUEL_FLOW] * 4
assert np.isfinite(perf["mass"]).all()


def test_nan_fuel_flow_with_aircraft_mass_column() -> None:
perf = _run([230.0, float("nan"), 230.0], initial_mass=None)

assert perf["fuel_flow"] == [FUEL_FLOW] * 3
assert perf["mass"] == [TOW] * 3


def test_nan_fuel_flow_at_first_point_fails_the_attempt() -> None:
with pytest.raises(PerformanceStepError, match="very first") as exc:
_run([float("nan"), 230.0, 230.0])
assert exc.value.retryable


def test_too_many_consecutive_nan_fuel_flows_fail_the_attempt() -> None:
tas = [230.0] + [float("nan")] * (MAX_CONSECUTIVE_FAILURES + 1) + [230.0]
with pytest.raises(PerformanceStepError, match="consecutive") as exc:
_run(tas)
assert exc.value.retryable


def test_max_consecutive_nan_fuel_flows_are_accepted() -> None:
tas = [230.0] + [float("nan")] * MAX_CONSECUTIVE_FAILURES + [230.0]
perf = _run(tas)

assert perf["fuel_flow"] == [FUEL_FLOW] * len(tas)
assert np.isfinite(perf["mass"]).all()
Loading