From b1ea1284140ef39f6947bead82a0ca1edd3b1ba3 Mon Sep 17 00:00:00 2001 From: Ramon DALMAU Date: Fri, 23 Oct 2026 10:46:48 +0200 Subject: [PATCH] fix(performance): treat a non-finite BADA fuel flow as a point failure (audit I13) In _single_pass_performance a point is a BADA failure when BADA raises or its fuel flow is above ff_outlier_threshold or negative; the point then takes the previous point's values, and more than max_consecutive_failures in a row (or a failure at the first point) fail the attempt. A NaN fuel flow passed both comparisons, so it was accepted: it became the "previous" value and, with a take-off mass, the integrated mass was NaN for the rest of the flight, so all later fuel dropped out of the sums. BADA4 gives NaN without raising wherever the TAS is NaN (frozen raw position; last point of the computed-TAS path). Fix: a fuel flow that is not finite is a BADA failure at that point, like an outlier; the existing rule applies unchanged. Effect (NM sample 2025-07-09, 4,959 flights, dev 5aba2f9, measured with this rule): same 4,303 flights computed; NaN-fuel rows 63 -> 0; fleet fuel +0.005 %, CO2 +0.005 %; 5 flights change: VLG1254 +36.8 %, IBE09LA +10.1 %, RYR4WR +16.5 % and VLG90AB +7.5 % (both BADA3 -> BADA4), IBS15XJ -2.1 % (BADA4 -> BADA3: 8 NaN-TAS rows in a row exceed the 5-failure limit). The last point of a BADA4 flight now carries the previous point's fuel flow instead of NaN; its fuel burn stays empty. Golden tests unchanged: test_fleet_pipeline (11 cases), test_pipeline and test_cases_pipeline pass. Tests: tests/test_bada_nonfinite_fuel_flow.py (6 tests, all fail on the previous code). Docs: input-prioritization.md (point failure rule), audit I13 and status table. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/explanation/input-prioritization.md | 2 +- docs/internal/pyneats_units_audit.md | 31 +++++ src/pyneats/steps/performance/bada_model.py | 15 ++- tests/test_bada_nonfinite_fuel_flow.py | 133 ++++++++++++++++++++ 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 tests/test_bada_nonfinite_fuel_flow.py diff --git a/docs/explanation/input-prioritization.md b/docs/explanation/input-prioritization.md index 8e92111..f952c2d 100644 --- a/docs/explanation/input-prioritization.md +++ b/docs/explanation/input-prioritization.md @@ -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. diff --git a/docs/internal/pyneats_units_audit.md b/docs/internal/pyneats_units_audit.md index 51c3b93..3e574e2 100644 --- a/docs/internal/pyneats_units_audit.md +++ b/docs/internal/pyneats_units_audit.md @@ -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 @@ -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 diff --git a/src/pyneats/steps/performance/bada_model.py b/src/pyneats/steps/performance/bada_model.py index 8e9822a..02a86b1 100644 --- a/src/pyneats/steps/performance/bada_model.py +++ b/src/pyneats/steps/performance/bada_model.py @@ -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: @@ -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 diff --git a/tests/test_bada_nonfinite_fuel_flow.py b/tests/test_bada_nonfinite_fuel_flow.py new file mode 100644 index 0000000..2266811 --- /dev/null +++ b/tests/test_bada_nonfinite_fuel_flow.py @@ -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()