diff --git a/docs/explanation/input-prioritization.md b/docs/explanation/input-prioritization.md index 402147a..84c4469 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). 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 at a point counts as a BADA failure at that point; 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. @@ -117,7 +117,9 @@ The correspondence between ICAO aircraft types, aircraft versions, engine identi 1. **BADA 4** type from the mapping tables (preferred — higher fidelity), looked up by ICAO type, aircraft version and engine, then with fewer of these keys 2. **BADA 3** type when the mapping gives no BADA 4 type, or when the BADA 4 calculation fails -3. If that also fails and the error allows a retry, points below FL20 are removed and the calculation is tried again (BADA 4, then BADA 3) +3. If that also fails and the flight has points below FL20 (at most 80 % of its points), those points are removed and the calculation is tried again (BADA 4, then BADA 3) + +A flight rejected by the fuel-burn plausibility check goes through the same chain and fails only if every attempt is rejected. The mapping tables were built with fleet statistics, used as frequency-based proxies where no direct correspondence exists. A type absent from the tables makes the flight fail. diff --git a/docs/explanation/mrv-specification.md b/docs/explanation/mrv-specification.md index edb74de..be9625d 100644 --- a/docs/explanation/mrv-specification.md +++ b/docs/explanation/mrv-specification.md @@ -187,7 +187,7 @@ The performance model is used to estimate these values when the AO does not prov Each performance quantity has its own source, so the cases below are not exclusive (an AO fuel flow can be combined with a BADA aircraft mass); the source of one quantity can still depend on what else is provided, as rules 2 and 4 show: 1. **BADA mapping:** every flight needs its aircraft type in the BADA mapping, even when the AO provides all performance data. The BADA file gives the wingspan (contrails); the mapping gives the number of engines (emissions; read from the BADA file when no BADA calculation is made) and, when no engine is declared, the engine UID (the MRR conservative engine where the type has one, otherwise the engine of the mapping row). A type not in the mapping makes the flight fail. -2. **Fuel flow, engine efficiency and aircraft mass all provided:** no BADA thrust or fuel-flow calculation; the provided values are used directly downstream. The fuel-burn plausibility check (total fuel burn at most 1.2 × (MTOW − OEW), applied on every other path with the fuel flow actually used) is not applied on this path. +2. **Fuel flow, engine efficiency and aircraft mass all provided:** no BADA thrust or fuel-flow calculation; the provided values are used directly downstream. The fuel-burn plausibility check (total fuel burn at most 1.2 × (MTOW − OEW), applied on every other path with the fuel flow actually used; a flight it rejects is retried with BADA 3 and then, when it has points below FL20, without those points; it fails only if every attempt is rejected) is not applied on this path. 3. **Otherwise BADA computes thrust and fuel flow at each point**, with the aircraft mass taken from, in order: the AO aircraft mass, if provided; else the AO take-off weight (TOW), decreased along the trajectory with the BADA fuel flow; else the iterative estimate of rule 5. 4. **Outputs:** - **Fuel flow:** AO fuel flow (kg/s) if provided; else, if the AO provides aircraft mass, the mass decrease since the previous point divided by the time step to the next point (set to 0 where the mass increases; 0 at the first point and empty at the last, so the last segment's mass decrease is not counted); else the BADA fuel flow. diff --git a/docs/internal/pyneats_units_audit.md b/docs/internal/pyneats_units_audit.md index 881e384..d0a18e5 100644 --- a/docs/internal/pyneats_units_audit.md +++ b/docs/internal/pyneats_units_audit.md @@ -17,9 +17,10 @@ cite line numbers on `dev` before the fix they describe. | D1 (bare single-flight object) | Open; now a clear `TypeError` on the `FleetRunner` JSON path | #71 | 2026-10-20 | | I4 (BADA3 maximum payload) | Fixed; changes BADA3 fuel and CO2 | #73 | 2026-10-21 | | Fuel reserve and number of mass iterations misstated in the user docs (Section 9) | Fixed (docs only) | #74 | 2026-10-21 | -| Performance input decision tree and data-sources table in the user docs (Section 9) | Fixed (docs only) | `docs/performance-input-logic` | 2026-10-21 | -| A13 (mixed fuel-flow provenance) | Documented in the user docs; the design choice stays open | `docs/performance-input-logic` | 2026-10-21 | -| I9 (`segment_duration` not recomputed after the speed filter) | Open; recorded, part of the low-speed filter work | `docs/performance-input-logic` | 2026-10-21 | +| Performance input decision tree and data-sources table in the user docs (Section 9) | Fixed (docs only) | #75 | 2026-10-21 | +| A13 (mixed fuel-flow provenance) | Documented in the user docs; the design choice stays open | #75 | 2026-10-21 | +| I9 (`segment_duration` not recomputed after the speed filter) | Open; recorded, part of the low-speed filter work | #75 | 2026-10-21 | +| I10 (a failed attempt leaked columns into the BADA3 retry) | Fixed; fuel-burn rejections go through the BADA3 retry and the altitude fallback (maintainer decision); no change on the NEATS_FINAL sample | `fix/performance-retry-isolation` | 2026-10-21 | ## 1. Outcome @@ -446,8 +447,8 @@ Each entry gives: - Units agree throughout. The issue is provenance, not scale. - **Recommendation**: document this, or integrate mass from the provided `ff`. The decision belongs to the method owner. -- *Documented in `input-prioritization.md` and `mrv-specification.md` on branch - `docs/performance-input-logic` (see the follow-up status table). The design choice +- *Documented in `input-prioritization.md` and `mrv-specification.md` (#75, see the + follow-up status table). The design choice (integrate mass from the provided `ff` or not) stays open for the method owner.* ## 7. New discrepancies @@ -717,12 +718,12 @@ discrepancies: | `tests/test_performance_fallback.py:4,74,80`; `tests/test_guardrails.py:133,142,151` | FL15; 500 hPa | FL20; 550 hPa | update test docstrings | | `docs/explanation/input-prioritization.md:60`; `docs/explanation/mrv-specification.md:203` | initial-mass fuel reserve ≈ 3 % of trip fuel | 15 % (`DEFAULT_FUEL_RESERVE_FRACTION = 0.15`, `neats_default_parameters.py:148`; `bada_model.py:1058`). Found after this audit, during the I4 review | fixed (#74) | | `docs/explanation/input-prioritization.md:70`; `docs/explanation/mrv-specification.md:214` | the mass estimate runs steps 2–4 "twice" | when neither an `aircraft_mass` column nor a `takeoff_weight` attribute is given, steps 2–4 run at least twice and at most four times per estimate: one constant-mass BADA pass, then up to three passes from the updated TOW, stopping when the TOW changes by ≤ 1 % of the previous TOW (`max_mass_estimation_iter = 4`, `max_rel_mass_diff = 0.01`, `bada_model.py:1065-1117`); the last pass is kept. Measured on the 4,303 flights computed from the NEATS_FINAL sample: 2,769 stop after two passes, 1,415 after three, 119 after four, 3 of which stop at the cap with a change of 1.0–1.4 % (1.04, 1.17 and 1.39 %) | fixed (#74) | -| `docs/explanation/input-prioritization.md:23-44`; `docs/explanation/mrv-specification.md:187-193` | the decision tree and rules 1–5 are exclusive cases checked in order: fuel flow + η skips the performance step; fuel flow only uses BADA only for η; with aircraft mass, BADA computes the fuel flow | each quantity has its own source. The BADA thrust and fuel-flow calculation is skipped only when fuel flow, η and aircraft mass are all given (`bada_model.py:741-745`); the BADA adapter is still resolved and, for BADA3 runs, the low-speed filter still removes rows on that path (`bada_model.py:483-495`). Otherwise the mass is the AO mass, else the AO TOW, else the iterative estimate, whatever fuel flow or η were given (`bada_model.py:850-874`); with aircraft mass and no fuel flow, the fuel flow is derived from the mass decrease (`bada_model.py:762-812`); the BADA mapping is needed for every flight. Checked by running one golden flight (EAF2143) with six input combinations (fuel flow + η + mass, fuel flow + η, fuel flow, mass, TOW, none) and with an aircraft type missing from the BADA mapping. A retry bug (a BADA3 retry after a BADA4 guardrail rejection reused the BADA4 columns and could take this exit) is fixed separately | fixed (`docs/performance-input-logic`) | -| `docs/explanation/input-prioritization.md:17` | modelled take-off weight = OEW + load_factor × (MTOW − OEW) | that is only the constant first-pass mass; the take-off weight used is min(MTOW, OEW + load_factor × MPL + 1.15 × trip fuel) from the iteration (MPL = MTOW − OEW when BADA gives no positive MPL). On the 4,303 computed NEATS_FINAL sample flights the first-pass mass equals MTOW on all, the take-off weight on 243 | fixed (`docs/performance-input-logic`) | -| `docs/explanation/input-prioritization.md:83,85` | unmapped types fall back to fleet-statistics proxies; the engine UID defaults to the MRR conservative value | the fleet statistics were used to build the mapping tables; a type absent from them makes the flight fail. Without a declared engine, the engine UID is the MRR conservative engine where the type has one (`MRR_conservative_mapping.csv`), otherwise the engine of the mapping row | fixed (`docs/performance-input-logic`) | -| `docs/explanation/input-prioritization.md:96-97` | "Fuel flow only: η derived from BADA"; "Mass evolution: full BADA performance chain" | with fuel flow only, the mass and TOW are also estimated from the load factor; with mass evolution, the fuel flow is derived from the mass decrease (star ranking left unchanged) | fixed (`docs/performance-input-logic`) | -| `docs/explanation/mrv-specification.md:119,172` | missing points of optional AO variables are filled by interpolation; the performance model estimates values "not available at a point" | a provided column is used whole; empty points are not filled, and resampling widens them (D4) | fixed (`docs/performance-input-logic`) | -| `docs/explanation/mrv-specification.md:181` | without AO TAS, TAS is set to 0 below 840 hPa | the wind is set to 0 where it is missing (`weather_provider.py:382-383`), so TAS equals the smoothed ground speed | fixed (`docs/performance-input-logic`) | +| `docs/explanation/input-prioritization.md:23-44`; `docs/explanation/mrv-specification.md:187-193` | the decision tree and rules 1–5 are exclusive cases checked in order: fuel flow + η skips the performance step; fuel flow only uses BADA only for η; with aircraft mass, BADA computes the fuel flow | each quantity has its own source. The BADA thrust and fuel-flow calculation is skipped only when fuel flow, η and aircraft mass are all given (`bada_model.py:741-745`); the BADA adapter is still resolved and, for BADA3 runs, the low-speed filter still removes rows on that path (`bada_model.py:483-495`). Otherwise the mass is the AO mass, else the AO TOW, else the iterative estimate, whatever fuel flow or η were given (`bada_model.py:850-874`); with aircraft mass and no fuel flow, the fuel flow is derived from the mass decrease (`bada_model.py:762-812`); the BADA mapping is needed for every flight. Checked by running one golden flight (EAF2143) with six input combinations (fuel flow + η + mass, fuel flow + η, fuel flow, mass, TOW, none) and with an aircraft type missing from the BADA mapping. A retry bug (a BADA3 retry after a BADA4 guardrail rejection reused the BADA4 columns and could take this exit) is fixed separately (I10) | fixed (#75) | +| `docs/explanation/input-prioritization.md:17` | modelled take-off weight = OEW + load_factor × (MTOW − OEW) | that is only the constant first-pass mass; the take-off weight used is min(MTOW, OEW + load_factor × MPL + 1.15 × trip fuel) from the iteration (MPL = MTOW − OEW when BADA gives no positive MPL). On the 4,303 computed NEATS_FINAL sample flights the first-pass mass equals MTOW on all, the take-off weight on 243 | fixed (#75) | +| `docs/explanation/input-prioritization.md:83,85` | unmapped types fall back to fleet-statistics proxies; the engine UID defaults to the MRR conservative value | the fleet statistics were used to build the mapping tables; a type absent from them makes the flight fail. Without a declared engine, the engine UID is the MRR conservative engine where the type has one (`MRR_conservative_mapping.csv`), otherwise the engine of the mapping row | fixed (#75) | +| `docs/explanation/input-prioritization.md:96-97` | "Fuel flow only: η derived from BADA"; "Mass evolution: full BADA performance chain" | with fuel flow only, the mass and TOW are also estimated from the load factor; with mass evolution, the fuel flow is derived from the mass decrease (star ranking left unchanged) | fixed (#75) | +| `docs/explanation/mrv-specification.md:119,172` | missing points of optional AO variables are filled by interpolation; the performance model estimates values "not available at a point" | a provided column is used whole; empty points are not filled, and resampling widens them (D4) | fixed (#75) | +| `docs/explanation/mrv-specification.md:181` | without AO TAS, TAS is set to 0 below 840 hPa | the wind is set to 0 where it is missing (`weather_provider.py:382-383`), so TAS equals the smoothed ground speed | fixed (#75) | ## 10. Incidental observations outside the unit scope @@ -784,6 +785,51 @@ for a separate decision and were not fixed. removed (639 leading, 1,947 trailing, 587 interior), carrying 1.36 % of flight time; CO2 / (3.159 × fuel) from 0.961 to 1.360 (median 0.995) per flight, 0.17 % below at fleet level. +- **I10. A failed attempt leaked columns into the BADA3 retry.** Found after this audit + (2026-10-21), while checking the user docs. *Fixed on branch + `fix/performance-retry-isolation` (see the follow-up status table).* + - **Defect.** `run()` passed the same preprocessed DataFrame to the first attempt and to the + BADA3 retry (`bada_model.py:350-391`), and `_run_with_altitude_fallback` did the same for + its two attempts (`bada_model.py:429-451`). `run_by_bada_version` writes into that + DataFrame: `fuel_flow` derived from `am` before the BADA calculation, then + `aircraft_mass`, `thrust`, `fuel_flow`, `phase`, `thrust_segment`, `fuel_burn` and `fuel` + (`_finalize_columns`) before the fuel-burn guardrail (`_build_result`). The DataFrame is + shared whenever the low-speed filter removes nothing, which is always the case for BADA4 + (I1). The BADA3 retry therefore took the columns of the failed BADA4 attempt as AO data. + - **Effects** (reproduced on golden flight EAF2143, BADA4 fuel or AO fuel flow × 15 to + trigger the guardrail, limit 45,600 kg): + + | AO data | First failure | Before the fix | With the fix | + | --- | --- | --- | --- | + | fuel flow + η | guardrail on BADA4 | BADA3 retry took the early exit ("all provided"): accepted with 55,674 kg, no guardrail, no `bada_version` | BADA3 retry rejected on the same AO fuel, then the altitude fallback; rejected if every attempt is | + | η only | guardrail on BADA4 | early exit: accepted with the rejected BADA4 fuel (49 t), no `bada_version` | clean BADA3 retry, accepted with 3,552 kg | + | none | guardrail on BADA4 | BADA3 retry reused the BADA4 fuel flow and mass: rejected again, no altitude fallback | clean BADA3 retry, accepted with 3,552 kg; otherwise the altitude fallback | + | mass + η | BADA4 fails before its result (e.g. first point) | early exit on the fuel flow derived from the mass, BADA skipped | clean BADA3 retry, with the guardrail | + | any, type without a BADA4 code | guardrail on BADA3 | altitude fallback | altitude fallback (unchanged) | + + - **Fix.** Each attempt works on its own copy (`df = df.copy()` at the start of + `run_by_bada_version`). + - **Maintainer decision (2026-10-21): a fuel-burn rejection goes through the whole fallback + chain.** Before, the guardrail error carried `retryable=False`. `run()` ignored the flag + before the BADA3 retry, but that retry reused the rejected BADA4 columns, so it never gave + a plausible result; `run()` then honoured the flag before the altitude fallback, except + for types without a BADA4 code and for BADA4 types whose BADA3 retry failed for another + reason (those errors carry the default flag). The maintainers considered making the + rejection final and decided to keep the fallbacks: the guardrail error is now retryable, + so a rejected flight is retried with BADA3, then with the altitude fallback (points below + FL20 removed), and fails only if every attempt is rejected. `run()` and + `_run_with_altitude_fallback` now also honour `retryable=False` before their BADA3 + retries. On this path only two internal consistency errors carry that flag (a BADA file + without MTOW or OEW when the take-off mass is estimated, and a missing initial mass), and + neither occurs with the BADA files in use. + - **Known, not changed (existed before).** For a type without a BADA4 code, the altitude + fallback runs the same BADA3 calculation twice (its "BADA4" attempt resolves to BADA3). + When the altitude filter cannot run (no point below FL20, or more than 80 % below it), + such a flight's error record reads "already performed with BADA3" and the actual reason + (for example the fuel-burn rejection) is only in the exception cause, which the fleet + runner does not record. Candidates for the AO-input / error-reporting work. + - **Effect on results.** No flight of the NEATS_FINAL sample reaches the guardrail, so its + results do not change (fleet before/after on the 4,959 sample flights). ## 11. Patch summary and checks performed diff --git a/src/pyneats/steps/performance/bada_model.py b/src/pyneats/steps/performance/bada_model.py index aa29ca7..f4cee17 100644 --- a/src/pyneats/steps/performance/bada_model.py +++ b/src/pyneats/steps/performance/bada_model.py @@ -329,6 +329,14 @@ def run(self, flight: FlightWithWeather) -> FlightWithPerformance: 2. Retry with BADA3 forced (if first attempt used BADA4) 3. Altitude fallback — filter points below DEFAULT_MIN_ALTITUDE_FL (FL20 = 2000 ft = 609.6 m), retry + + A fuel-burn guardrail rejection goes through the whole chain: BADA3 + retry, then altitude fallback; the flight fails only if every attempt + is rejected. A failure raised with ``retryable=False`` ends the chain + where it occurs; only two internal consistency errors carry that flag + on this path (a BADA file without MTOW or OEW when the take-off mass + is estimated, and a missing initial mass), and neither occurs with the + BADA files in use. """ # --- Preprocessing + aircraft data extraction --- @@ -355,6 +363,9 @@ def run(self, flight: FlightWithWeather) -> FlightWithPerformance: ) except PerformanceStepError as exc: + if not exc.retryable: + raise + self.logger.info( "BADA performance evaluation failed. Retrying BADA performance evaluation with BADA3 enforced", extra={"icao": icao, "series": series, "engine_id": engine_id_attr}, @@ -438,8 +449,10 @@ def _run_with_altitude_fallback( engine_id_attr, q_fuel_attr, ) - except PerformanceStepError: - # Try BADA3 on filtered data + except PerformanceStepError as exc4: + # Try BADA3 on filtered data, unless the failure is final + if not exc4.retryable: + raise return self.run_by_bada_version( filtered_flight, df_filtered, @@ -478,6 +491,11 @@ def run_by_bada_version( degrades gracefully and proceeds with the original unfiltered data. """ + # Work on a copy: the steps below write columns (aircraft_mass, thrust, + # fuel_flow, ...) into df. run() and the altitude fallback pass the same + # df to a BADA3 retry, which would otherwise take those columns as AO data. + df = df.copy() + try: # 1) resolve adapter + core attrs adapter, bada_version, nb_eng, engine_id = self._resolve_bada_adapter( @@ -552,11 +570,12 @@ def _build_result( total_fuel = float(df["fuel_burn"].sum()) limit = useful_payload * self.params.fuel_burn_threshold if total_fuel > limit: + # Retryable: the BADA3 retry and the altitude fallback (points + # below FL20 removed) may still give a plausible result. raise PerformanceStepError( f"Total fuel burn {total_fuel:.0f} kg exceeds " f"useful payload capacity {useful_payload:.0f} kg x " f"{self.params.fuel_burn_threshold} = {limit:.0f} kg", - retryable=False, ) else: self.logger.warning( diff --git a/tests/test_bada_model_speed_filter.py b/tests/test_bada_model_speed_filter.py index 1cff2b4..3e5d0c3 100644 --- a/tests/test_bada_model_speed_filter.py +++ b/tests/test_bada_model_speed_filter.py @@ -119,10 +119,12 @@ def test_speed_filter_called_before_perf(self, mock_filter: MagicMock) -> None: model.run_by_bada_version(flight, df, "A320", None, None, None) - # filter was called with the df and the resolved adapter + # filter was called with a copy of the df and the resolved adapter + # (each attempt works on its own copy, see run_by_bada_version) mock_filter.assert_called_once() call_args = mock_filter.call_args - assert call_args[0][0] is df # first positional: df + pd.testing.assert_frame_equal(call_args[0][0], df) # first positional: df + assert call_args[0][0] is not df assert call_args[0][1] is adapter # second positional: adapter diff --git a/tests/test_performance_fallback.py b/tests/test_performance_fallback.py index ef6ed93..2f870fa 100644 --- a/tests/test_performance_fallback.py +++ b/tests/test_performance_fallback.py @@ -231,6 +231,28 @@ def test_both_bada_fail_on_filtered_raises(self) -> None: assert model.run_by_bada_version.call_count == 2 + def test_final_bada4_failure_on_filtered_not_retried(self) -> None: + """A non-retryable BADA4 failure on filtered data is not retried with BADA3.""" + model = self._make_model() + model.run_by_bada_version = MagicMock( + side_effect=[ + PerformanceStepError("does not provide OEW or MTOW", retryable=False), + ], + ) + + flight = _make_flight_with_weather([5, 10, 100, 200, 350]) + with pytest.raises(PerformanceStepError, match="does not provide OEW or MTOW"): + model._run_with_altitude_fallback( + flight, + "A320", + None, + None, + None, + PerformanceStepError("original"), + ) + + model.run_by_bada_version.assert_called_once() + def test_no_low_points_propagates_original_error(self) -> None: """If flight has no low-altitude points, the original error is re-raised.""" model = self._make_model() diff --git a/tests/test_performance_retry_isolation.py b/tests/test_performance_retry_isolation.py new file mode 100644 index 0000000..a082414 --- /dev/null +++ b/tests/test_performance_retry_isolation.py @@ -0,0 +1,232 @@ +"""Retries in the performance step: clean start, and fuel-burn rejections. + +``run()`` passes the same preprocessed DataFrame to the first attempt and to +the BADA3 retry (the altitude fallback does the same). ``run_by_bada_version`` +writes ``aircraft_mass``, ``thrust``, ``fuel_flow``... into it. Without a copy, +the BADA3 retry took the columns left by a failed BADA4 attempt as AO data: +with AO fuel flow and engine efficiency it saw "all AO data provided", took +the early exit and was accepted without the fuel-burn guardrail. + +Each attempt now works on its own copy. A fuel-burn rejection goes through the +whole fallback chain: BADA3 retry, then altitude fallback. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock +from unittest.mock import patch + +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 + + +N_POINTS = 5 +MTOW = 80_000.0 +OEW = 45_000.0 +FUEL_BURN_THRESHOLD = 1.2 # limit = 1.2 x (MTOW - OEW) = 42,000 kg +IMPOSSIBLE_FUEL_FLOW = 200.0 # kg/s x 60 s x 4 segments = 48,000 kg > limit +PLAUSIBLE_FUEL_FLOW = 1.0 # kg/s + + +def _make_df(ao: set[str]) -> pd.DataFrame: + """Preprocessed flight; ``ao`` names the AO columns: ff, ee, am.""" + times = pd.date_range("2023-01-01 10:00", periods=N_POINTS, freq="1min") + df = pd.DataFrame( + { + "latitude": [51.5 + i * 0.1 for i in range(N_POINTS)], + "longitude": [-0.1 + i * 0.1 for i in range(N_POINTS)], + "time": times, + "altitude": [10_000.0] * N_POINTS, + "true_airspeed": [230.0] * N_POINTS, + "rocd": [0.0] * N_POINTS, + "acceleration": [0.0] * N_POINTS, + "delta_tau": [0.0] * N_POINTS, + "segment_duration": [60.0] * (N_POINTS - 1) + [float("nan")], + "u_wind": [0.0] * N_POINTS, + "v_wind": [0.0] * N_POINTS, + "air_temperature": [220.0] * N_POINTS, + "specific_humidity": [1e-4] * N_POINTS, + } + ) + if "ff" in ao: + df["fuel_flow"] = IMPOSSIBLE_FUEL_FLOW + if "ee" in ao: + df["engine_efficiency"] = 0.3 + if "am" in ao: + df["aircraft_mass"] = [70_000.0 - 60.0 * i for i in range(N_POINTS)] + return df + + +def _make_flight() -> MagicMock: + flight = MagicMock() + flight.attrs = { + "aircraft_type": "A320", + "flight_id": "TEST1", + "departure_airport": "EGLL", + "arrival_airport": "LFPG", + "model_type": "test", + "aobt": "2023-01-01 10:00:00", + } + flight.fuel = None + return flight + + +def _perf(fuel_flow: float) -> tuple[dict[str, list[Any]], float]: + return ( + { + "mass": [70_000.0] * N_POINTS, + "fuel_flow": [fuel_flow] * N_POINTS, + "thrust": [50_000.0] * N_POINTS, + "phase": ["Cruise"] * N_POINTS, + "segment": ["TOTAL"] * N_POINTS, + }, + 43.13e6, + ) + + +def _make_model(bada_results: list[Any], ao: set[str]) -> tuple[MagicMock, MagicMock]: + """Real run / run_by_bada_version / early exit / finalize / guardrail. + + Adapter resolution and the BADA calculation are mocked: each attempt that + reaches the BADA calculation takes the next item of ``bada_results`` (a + BADA fuel flow in kg/s, or an exception to raise). + """ + model = MagicMock(spec=BADAPerformanceModel) + model.logger = MagicMock() + for name in ( + "run", + "run_by_bada_version", + "_early_exit_if_fuel_and_efficiency", + "_derive_fuel_flow_from_mass_if_needed", + "_finalize_columns", + "_build_result", + "_attach_output_attrs", + ): + setattr(model, name, getattr(BADAPerformanceModel, name).__get__(model)) + + model.params = MagicMock() + model.params.fuel_burn_threshold = FUEL_BURN_THRESHOLD + model.params.reference_q_fuel = 43.13e6 + model.params.bada_type = MagicMock(return_value=(2, "A320", "A320-214", "ENG")) + + adapter = MagicMock() + adapter.MTOW = MTOW + adapter.OEW = OEW + adapter.nb_eng = 2 + adapter.span = 34.1 + adapter.bada_code = "A320" + + def resolve( + icao: str, series: Any, engine_id: Any, force_bada3: bool = False + ) -> tuple[Any, str, int, str]: + return adapter, ("BADA3" if force_bada3 else "BADA4"), 2, "ENG" + + model._resolve_bada_adapter = MagicMock(side_effect=resolve) + model._choose_mass_strategy = MagicMock( + side_effect=[r if isinstance(r, Exception) else _perf(r) for r in bada_results] + ) + + def efficiency(df: pd.DataFrame, perf: Any, q_fuel_used: float) -> None: + # As the real step: fill engine_efficiency only when it is absent. + if "engine_efficiency" not in df.columns: + df["engine_efficiency"] = 0.3 + + model._compute_engine_efficiency_if_missing = MagicMock(side_effect=efficiency) + model._preprocess = MagicMock(return_value=_make_df(ao)) + model._extract_aircraft_attrs = MagicMock(return_value=("A320", None, None, None)) + model._run_with_altitude_fallback = MagicMock( + side_effect=AssertionError("altitude fallback must not run") + ) + return model, adapter + + +def _speed_filter_fails(*_: Any, **__: Any) -> pd.DataFrame: + # As for BADA4 today: the filter raises and the step goes on unfiltered, + # so the attempt works on the DataFrame it was given. + raise TypeError("VStall signature") + + +@patch( + "pyneats.steps.performance.bada_model.filter_low_speed_points", + side_effect=_speed_filter_fails, +) +def test_attempt_does_not_modify_callers_dataframe(_: MagicMock) -> None: + model, _adapter = _make_model([PLAUSIBLE_FUEL_FLOW], {"ff", "ee"}) + df = _make_df({"ff", "ee"}) + before = df.copy() + + with pytest.raises(PerformanceStepError, match="fuel burn"): + model.run_by_bada_version(_make_flight(), df, "A320", None, None, None) + + pd.testing.assert_frame_equal(df, before) + + +@pytest.mark.parametrize("ao", [set(), {"ee"}], ids=["no_ao", "ao_ee"]) +@patch( + "pyneats.steps.performance.bada_model.filter_low_speed_points", + side_effect=_speed_filter_fails, +) +def test_guardrail_rejection_retried_with_bada3(_: MagicMock, ao: set[str]) -> None: + # BADA4 fuel is impossible, BADA3 fuel is plausible: the flight is + # accepted with BADA3. Before the fix, the BADA3 retry reused the rejected + # BADA4 columns (rejected again, or accepted via the early exit with "ee"). + model, _adapter = _make_model([IMPOSSIBLE_FUEL_FLOW, PLAUSIBLE_FUEL_FLOW], ao) + + result = model.run(_make_flight()) + + forced = [c.args[3] for c in model._resolve_bada_adapter.call_args_list] + assert forced == [False, True] + assert model._choose_mass_strategy.call_count == 2 + assert result.attrs["bada_version"] == "BADA3" + model._run_with_altitude_fallback.assert_not_called() + + +@patch( + "pyneats.steps.performance.bada_model.filter_low_speed_points", + side_effect=_speed_filter_fails, +) +def test_guardrail_rejection_reaches_altitude_fallback(_: MagicMock) -> None: + # AO fuel flow is impossible: BADA4 and BADA3 are both rejected, then the + # altitude fallback runs. Before the fix, the BADA3 retry saw fuel flow + + # efficiency + the BADA4 mass, skipped the guardrail and accepted the flight. + model, _adapter = _make_model( + [PLAUSIBLE_FUEL_FLOW, PLAUSIBLE_FUEL_FLOW], {"ff", "ee"} + ) + fallback_result = MagicMock(name="altitude fallback result") + model._run_with_altitude_fallback = MagicMock(return_value=fallback_result) + + result = model.run(_make_flight()) + + assert result is fallback_result + forced = [c.args[3] for c in model._resolve_bada_adapter.call_args_list] + assert forced == [False, True] + assert model._choose_mass_strategy.call_count == 2 + (call,) = model._run_with_altitude_fallback.call_args_list + assert "fuel burn" in str(call.args[-1]) + + +@patch( + "pyneats.steps.performance.bada_model.filter_low_speed_points", + side_effect=_speed_filter_fails, +) +def test_retry_after_bada4_failure_starts_clean(_: MagicMock) -> None: + # AO mass and efficiency. BADA4 fails (retryable) after the fuel flow was + # derived from the mass. Before the fix, the BADA3 retry saw that derived + # fuel flow as AO data, skipped BADA and returned no bada_version. + model, _adapter = _make_model( + [PerformanceStepError("BADA4 failure"), PLAUSIBLE_FUEL_FLOW], {"am", "ee"} + ) + + result = model.run(_make_flight()) + + forced = [c.args[3] for c in model._resolve_bada_adapter.call_args_list] + assert forced == [False, True] + assert model._choose_mass_strategy.call_count == 2 + assert result.attrs["bada_version"] == "BADA3"