From b92fe016c84c3e388bef4b98713fc26070bb50f1 Mon Sep 17 00:00:00 2001 From: Ramon DALMAU Date: Wed, 21 Oct 2026 09:10:27 +0200 Subject: [PATCH 1/2] fix(performance): use the BADA3 OPF maximum payload in the mass estimate BADA3Adapter.MPL always returned None, although pyBADA reads the maximum payload from the OPF file. The mass estimate then took MTOW - OEW as the payload, so with the default payload factor 1.0 the zero-fuel weight was MTOW and every BADA3 flight took off at MTOW. BADA4 flights already used their BADA maximum payload. BADA3Adapter.MPL now returns the OPF value, as BADA4Adapter.MPL does. The MTOW - OEW fallback is kept for a BADA file without MPL. The fuel-burn guardrail still uses MTOW - OEW on purpose. Updates the units audit (I4), the user docs that said BADA 3 has no MPL, and adds tests for the adapter and the mass estimate. Co-Authored-By: Claude Opus 5.5 --- docs/explanation/input-prioritization.md | 4 +- docs/explanation/mrv-specification.md | 4 +- docs/internal/pyneats_units_audit.md | 22 ++- .../steps/performance/bada_adapters.py | 8 +- src/pyneats/steps/performance/bada_model.py | 3 +- tests/test_bada3_max_payload.py | 167 ++++++++++++++++++ 6 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 tests/test_bada3_max_payload.py diff --git a/docs/explanation/input-prioritization.md b/docs/explanation/input-prioritization.md index 86f5ea4..9910db3 100644 --- a/docs/explanation/input-prioritization.md +++ b/docs/explanation/input-prioritization.md @@ -69,8 +69,8 @@ Reduce aircraft mass along the trajectory according to fuel burnt at each point. **Step 5 — Iterate** steps 2–4 until convergence. In PyNeats, **two iterations** are used to balance accuracy and computational cost. -!!! info "BADA 3 and MPL" - BADA 3 does not provide MPL (Maximum Payload). In that case, $\text{MPL} = MTOW - OEW$. +!!! info "MPL source" + MPL (Maximum Payload) is read from the BADA files: the OPF maximum payload for BADA 3, the aircraft file for BADA 4. Only if a BADA file gives no MPL is $\text{MPL} = MTOW - OEW$ used. --- diff --git a/docs/explanation/mrv-specification.md b/docs/explanation/mrv-specification.md index d595c63..1ac4a84 100644 --- a/docs/explanation/mrv-specification.md +++ b/docs/explanation/mrv-specification.md @@ -208,8 +208,8 @@ The following rules are applied **sequentially** to determine how the primary da where MPL is the maximum payload and `consumed_fuel` is the total fuel burn estimated in step 2. Aircraft mass along the trajectory is then reduced according to fuel burnt. - !!! note "BADA 3 and MPL" - BADA 3 does not provide MPL. If BADA 3 is used, $\text{MPL} = MTOW - OEW$. + !!! note "MPL source" + MPL is read from the BADA files (the OPF maximum payload for BADA 3). Only if a BADA file gives no MPL is $\text{MPL} = MTOW - OEW$ used. 5. **Iteration:** steps 2–4 are repeated until convergence (fixed number of iterations, or relative-tolerance stopping criterion). In PyNeats, **steps 2–4 are performed twice** to reduce computational effort. diff --git a/docs/internal/pyneats_units_audit.md b/docs/internal/pyneats_units_audit.md index 308a2d6..1b51627 100644 --- a/docs/internal/pyneats_units_audit.md +++ b/docs/internal/pyneats_units_audit.md @@ -12,8 +12,9 @@ note under its item. Items not listed here are open. | Item | Status | Branch / PR | Date | | --- | --- | --- | --- | | D2 (trajectory `ts` offsets) | Fixed | #70 | 2026-10-20 | -| One flight that cannot be converted no longer stops the run (D2 follow-up) | Fixed | `fix/isolate-invalid-flights` | 2026-10-20 | -| D1 (bare single-flight object) | Open; now a clear `TypeError` on the `FleetRunner` JSON path | `fix/isolate-invalid-flights` | 2026-10-20 | +| One flight that cannot be converted no longer stops the run (D2 follow-up) | Fixed | #71 | 2026-10-20 | +| 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 | `fix/bada3-max-payload` | 2026-10-21 | ## 1. Outcome @@ -133,7 +134,7 @@ golden data. *Stated* means written in a comment, docstring or document only. | `arrival_date_time` | UTC, ISO 8601 | mapped, then dropped | — | removed by the `allowed_attrs` filter, because it is not in the Flight4D attr lists | **Not consumed** | `neats_io.py:98,107,122`; `steps/parsing/views.py:35-55` *(demonstrated)* | | `nwp_date_time` | UTC, ISO 8601, nullable; the NWP instance to use | not read | — | none. The NWP instance is whatever weather store or factory the caller supplies | **Not consumed** (open question, A8) | `grep -rn nwp` finds no code; `runners/fleet.py:578-584` *(demonstrated)* | | `aircraft_properties.takeoff_mass` | kg, 0.1 kg resolution | attr `takeoff_weight`, kg | kg; pyBADA mass | rename only | **Consistent**: MRR Annex IIIa §1(29) defines it as kg "at beginning of the take-off run" (usage note in A6) | `neats_io.py:112`; `steps/performance/bada_model.py:786-808,849` *(demonstrated)* | -| `aircraft_properties.load_factor` | "unitless", [0, 1]; basis not stated | attr `payload_factor`, fraction | ZFW = OEW + pf × MPL in kg, or pf × (MTOW − OEW) when MPL is unavailable, which is always the case for BADA3 | rename only. Default 1.0. Not clipped | **Consistent**: defined by MRR Annex IIIa §1(32) as a fraction of maximum payload mass; default 1 per §5(2)(b) (A3) | `neats_io.py:113`; `bada_model.py:963-980`; `neats_default_parameters.py:141-143` *(demonstrated)*; `flight_schema.json:78-81` "payload / max-payload ratio" *(stated)* | +| `aircraft_properties.load_factor` | "unitless", [0, 1]; basis not stated | attr `payload_factor`, fraction | ZFW = OEW + pf × MPL in kg, or pf × (MTOW − OEW) when MPL is unavailable, which was always the case for BADA3 before the I4 fix | rename only. Default 1.0. Not clipped | **Consistent**: defined by MRR Annex IIIa §1(32) as a fraction of maximum payload mass; default 1 per §5(2)(b) (A3) | `neats_io.py:113`; `bada_model.py:963-980`; `neats_default_parameters.py:141-143` *(demonstrated)*; `flight_schema.json:78-81` "payload / max-payload ratio" *(stated)* | | `fuel_properties.hydrogen_content` | "percentage of hydrogen mass content", 0.01 resolution | wt.% (for example 13.79) | wt.%. Scales `ei_h2o` = 1.23 × (wt.% / 13.8) kg/kg | none | **Consistent** | `neats_io.py:115`; `core/neats_fuel.py:81-82,106`; pycontrails `core/fuel.py:53` (JetA 13.8); golden inputs 13.79 *(demonstrated)*. PyNEATS docs that say "fraction" are in Section 9 | | `fuel_properties.hydrogen_per_carbon_ratio` | "unitless"; atomic or mass basis not stated | attr `h_c_ratio`, treated as an **atomic (molar)** H/C ratio | converted to wt.% H = 100 · r·1.008 / (12.011 + r·1.008); used only when `hydrogen_content` is absent | conversion in `NEATSFuel.__init__` | **Consistent**: MRR Annex IIIa §1(36) defines it as the number of H atoms per C atom (A4) | `neats_fuel.py:83-87`; range check: atomic [1.9061, 2.1857] maps exactly to [13.79, 15.50] wt.% (`tests/test_cases_pipeline.py:1492-1516`, mapping recomputed by the auditor) | | `fuel_properties.aromatic_content` | "unitless"; scale and basis not stated | attr `aromatic_content`, stored | not used in any calculation | none | **Not consumed**. Volume basis per MRR §5(4) default; JSON scale undefined (A5) | `neats_io.py:117`; `neats_fuel.py:94-96`; default 0.25 commented as 25 % (`neats_default_parameters.py:300-302`) *(stated)* | @@ -241,7 +242,7 @@ Sources were ranked in this order: | --- | --- | --- | --- | --- | | `ff` | "the mass of fuel in kilograms that passes through the aircraft fuel system and into the aircraft's engines per second": **whole aircraft, kg/s** | §1(33); SAD "total fuel mass flow rate"; RSTS Table 2.13 (CoCiP input) "total" | matches | RSTS Tables 2.5, 2.7, 2.8 and 2.10 say "fuel mass flow rate of each engine". The law never says "total" or "all engines" in so many words; the whole-aircraft reading is its plain meaning | | `ee` | "the percentage of useful thrust generated by an aircraft engine relative to the energy input from fuel". The guidance calls it "aircraft overall propulsion efficiency"; the SAD and RSTS give "Overall propulsion efficiency", unitless, [0, 1] | §1(34), §1(35) ("by all-time stamps"); guidance §3.3; SAD II.11.2.7 | matches (F·TAS / (ff·LHV), pycontrails SAC) | The law says "percentage" while the schema, SAD and RSTS give [0, 1]; the schema's maximum of 1 rejects percentage input. Net or gross thrust is not stated. The guidance wording could also be read as one value per flight | -| `load_factor` | "the weight of passengers, cargo and baggage, including mail and hand luggage, expressed as fraction of the maximum payload mass"; maximum payload "can be retrieved by the applied fuel burn module"; default 1 | §1(31), §1(32), §5(2)(b); SAD and RSTS "fraction of the maximum payload mass, e.g., as defined in BADA" | matches (ZFW = OEW + pf·MPL) | none for the definition. §1(3) also uses "load factor" in the aerodynamic (g-load) sense, so the template should name the payload meaning explicitly. For BADA3, PyNEATS uses MTOW − OEW instead of MPL (I4) | +| `load_factor` | "the weight of passengers, cargo and baggage, including mail and hand luggage, expressed as fraction of the maximum payload mass"; maximum payload "can be retrieved by the applied fuel burn module"; default 1 | §1(31), §1(32), §5(2)(b); SAD and RSTS "fraction of the maximum payload mass, e.g., as defined in BADA" | matches (ZFW = OEW + pf·MPL) | none for the definition. §1(3) also uses "load factor" in the aerodynamic (g-load) sense, so the template should name the payload meaning explicitly. For BADA3, PyNEATS used MTOW − OEW instead of MPL (I4, fixed) | | `hydrogen_per_carbon_ratio` | "the number of hydrogen atoms (H) per carbon atom (C) per molecule of the fuel": **atomic (molar)** | §1(36); ReFuelEU Union Database field "Molar ratio of hydrogen to carbon" | matches | The SAD draft said "ratio of hydrogen mass to carbon mass"; the law prevails. `mrv-specification.md` still says "molecular mass ratio" (Section 9) | | `hydrogen_content` | not in the law. NEATS default "Hydrogen Content 13.79 % mass"; RSTS and SAD "Percentage of hydrogen mass content" | guidance v2.1 §3.3 Table 3; RSTS Table 2.12 | matches (wt.%) | none. PyNEATS's own `input-format.md` and `flight_schema.json` say "fraction (0–1)" (Section 9) | | `calorific_value` | "net calorific value of the fuel on board", which is the lower heating value (MRR Art. 3 NCV definition); MJ/kg in the schema, SAD and ReFuelEU; default 42.8 MJ/kg | §1(38); MRR Art. 3; guidance Table 3 | matches (J/kg after the application's × 1e6) | none | @@ -316,7 +317,7 @@ Each entry gives: The default is 1.0, meaning full payload. Values above 1 are not clipped. 3. An AO sending a passenger load factor (for example 0.85) would get 0.85 × MPL of payload. That differs from the real payload of passengers, baggage and cargo, so initial mass and - fuel change. For BADA3 aircraft the basis is MTOW − OEW rather than MPL (I4). + fuel change. For BADA3 aircraft the basis was MTOW − OEW rather than MPL until the I4 fix. 4. Possibly. The application might convert a seat load factor. 5. **Clarify the AO template**: "payload mass ÷ maximum payload, fraction 0–1; not a seat load factor". @@ -726,6 +727,17 @@ for a separate decision and were not fixed. MPL attribute". pyBADA 0.1.12 `Bada3Aircraft` does set MPL (OPF maximum payload × 1000 kg, `bada3.py:396-405`). BADA3 flights therefore estimate ZFW with pf × (MTOW − OEW), which is heavier than pf × MPL. The comment was corrected; the behaviour was not changed. + *Fixed on branch `fix/bada3-max-payload` (see the follow-up status table). + `BADA3Adapter.MPL` now returns the OPF maximum payload, as `BADA4Adapter.MPL` already did. + The MTOW − OEW fallback stays for a BADA file without MPL; all 264 BADA 3.16 OPF files + have one. Before the fix, with the default payload factor 1.0, ZFW = MTOW, so every BADA3 + flight took off at MTOW (1,050 of 1,050 BADA3 flights in the NEATS_FINAL sample). Measured + on those 1,050 flights with the BADA 3.16 files: BADA3 fuel −4.5 % (7,313 t → 6,983 t), + CO2 −4.5 %; per flight median −5.7 %, 5th percentile −10.4 %, minimum −21 % (GLEX, whose + OPF payload is 8 % of MTOW − OEW); 42 flights still take off at MTOW; take-off mass + median −8.2 t. BADA4 flights are not affected. Only the BADA3 path changes, including + BADA4 types that fall back to BADA3. The fuel-burn guardrail still uses MTOW − OEW on + purpose (payload and fuel together).* - **I5. Parser `timezone` parameter has no effect.** pycontrails stores time as naive UTC (`vector.py:2162-2167`). The docstrings were corrected. - **I6. Outputs not strict JSON.** `scripts/run_json.py` writes no output. Golden JSON diff --git a/src/pyneats/steps/performance/bada_adapters.py b/src/pyneats/steps/performance/bada_adapters.py index 7b8c3d6..974684d 100644 --- a/src/pyneats/steps/performance/bada_adapters.py +++ b/src/pyneats/steps/performance/bada_adapters.py @@ -537,12 +537,12 @@ def MTOW(self) -> float | None: value = getattr(self._obj, "MTOW", None) return cast("float | None", value) + # MPL [kg]: pyBADA 0.1.12 Bada3Aircraft sets it from the OPF maximum + # payload (tonnes x 1000), as it does MTOW and OEW. @property def MPL(self) -> float | None: - # Always None. pyBADA 0.1.12 Bada3Aircraft does set MPL (from OPF - # files: max payload x 1000, kg), but it is not read here, so mass - # estimation uses MTOW - OEW (see docs/internal/pyneats_units_audit.md). - return None + value = getattr(self._obj, "MPL", None) + return cast("float | None", value) @property def OEW(self) -> float | None: diff --git a/src/pyneats/steps/performance/bada_model.py b/src/pyneats/steps/performance/bada_model.py index a1a771f..a43168a 100644 --- a/src/pyneats/steps/performance/bada_model.py +++ b/src/pyneats/steps/performance/bada_model.py @@ -168,7 +168,8 @@ class BADAPerformanceModelParams(PerformanceModelParams): payload_factor : float Payload mass as a fraction of the BADA maximum payload (MPL): ZFW = OEW + payload_factor x MPL [kg], with MPL taken as MTOW - OEW - when the adapter gives no MPL (always the case with ``BADA3Adapter``). + when the adapter gives no MPL (BADA3 and BADA4 adapters both read it + from the BADA files). The first-pass constant-mass guess is OEW + payload_factor x (MTOW - OEW). Not a seat load factor; not clipped. Overridden by the ``payload_factor`` flight attribute and diff --git a/tests/test_bada3_max_payload.py b/tests/test_bada3_max_payload.py new file mode 100644 index 0000000..91ccd39 --- /dev/null +++ b/tests/test_bada3_max_payload.py @@ -0,0 +1,167 @@ +"""Tests for the maximum payload (MPL) used in the initial mass estimate (audit I4). + +``BADA3Adapter.MPL`` returns the OPF maximum payload, as ``BADA4Adapter.MPL`` +returns the BADA4 one. ``_estimate_initial_mass_iterative`` uses +ZFW = OEW + payload_factor x MPL, and MTOW - OEW only when the adapter gives +no MPL. +""" + +from __future__ import annotations + +from pathlib import Path +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.core.neats_default_parameters import DEFAULT_BADA3_VERSION +from pyneats.core.neats_default_parameters import DEFAULT_BADA4_VERSION +from pyneats.steps.performance.bada_adapters import BADA3Adapter +from pyneats.steps.performance.bada_adapters import BADA4Adapter +from pyneats.steps.performance.bada_model import BADAPerformanceModel + + +# A20N masses in BADA 3.16 (A20N__.OPF) and BADA 4.2.1 (A320-271N), kg. +MTOW = 79_000.0 +OEW = 46_000.0 +MPL = 18_300.0 + +N_POINTS = 50 +SEGMENT_S = 60.0 +FUEL_FLOW = 1.0 # kg/s, constant in the mocked performance pass +TRIP_FUEL = FUEL_FLOW * SEGMENT_S * N_POINTS # 3,000 kg +RESERVE = 0.15 + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + + +def _make_bada3_adapter(mpl: float | None) -> BADA3Adapter: + mock_aircraft = MagicMock() + mock_aircraft.MTOW = MTOW + mock_aircraft.OEW = OEW + mock_aircraft.MPL = mpl + with patch( + "pyneats.steps.performance.bada_adapters.Bada3Aircraft", + return_value=mock_aircraft, + ): + return BADA3Adapter("/fake/path/3.16", "A20N", rocd_phase_threshold_fpm=0.0) + + +class TestBada3AdapterMPL: + def test_returns_opf_max_payload(self) -> None: + assert _make_bada3_adapter(MPL).MPL == MPL + + def test_returns_none_when_pybada_has_none(self) -> None: + assert _make_bada3_adapter(None).MPL is None + + def test_returns_none_when_attribute_missing(self) -> None: + mock_aircraft = MagicMock(spec=["MTOW", "OEW"]) + mock_aircraft.MTOW = MTOW + mock_aircraft.OEW = OEW + with patch( + "pyneats.steps.performance.bada_adapters.Bada3Aircraft", + return_value=mock_aircraft, + ): + adapter = BADA3Adapter( + "/fake/path/3.16", "A20N", rocd_phase_threshold_fpm=0.0 + ) + assert adapter.MPL is None + + +# --------------------------------------------------------------------------- +# Initial mass estimate +# --------------------------------------------------------------------------- + + +def _run_mass_estimate( + mpl: float | None, payload_factor: float | None = None +) -> list[float | None]: + """Run the real estimate with a constant-fuel-flow performance pass. + + Returns the ``initial_mass`` of every performance pass, in order. + """ + model = MagicMock(spec=BADAPerformanceModel) + model.logger = MagicMock() + model.params = MagicMock() + model.params.payload_factor = 1.0 + model.params.fuel_reserve_fraction = RESERVE + model.params.max_rel_mass_diff = 0.01 + model.params.max_mass_estimation_iter = 4 + + passes: list[float | None] = [] + + def single_pass( + adapter: Any, df: pd.DataFrame, initial_mass: float | None, **_: Any + ) -> dict[str, list[float]]: + passes.append(initial_mass) + return {"fuel_flow": [FUEL_FLOW] * len(df)} + + model._single_pass_performance = MagicMock(side_effect=single_pass) + + adapter = MagicMock() + adapter.MTOW = MTOW + adapter.OEW = OEW + adapter.MPL = mpl + + flight = MagicMock() + flight.attrs = {} if payload_factor is None else {"payload_factor": payload_factor} + df = pd.DataFrame({"segment_duration": [SEGMENT_S] * N_POINTS}) + + BADAPerformanceModel._estimate_initial_mass_iterative( + model, adapter, df, flight, "A20N", 43.13e6, 43.13e6 + ) + return passes + + +class TestInitialMassUsesMPL: + def test_uses_mpl(self) -> None: + passes = _run_mass_estimate(MPL) + assert passes[0] is None # first pass: constant-mass guess + assert passes[1] == pytest.approx(OEW + MPL + TRIP_FUEL * (1 + RESERVE)) + assert passes[1] < MTOW + + def test_falls_back_to_mtow_minus_oew(self) -> None: + # ZFW = OEW + (MTOW - OEW) = MTOW, so the cap applies. + passes = _run_mass_estimate(None) + assert passes[1] == MTOW + + def test_payload_factor_scales_mpl(self) -> None: + passes = _run_mass_estimate(MPL, payload_factor=0.5) + assert passes[1] == pytest.approx(OEW + 0.5 * MPL + TRIP_FUEL * (1 + RESERVE)) + + def test_capped_at_mtow(self) -> None: + # Payload at MPL plus fuel above MTOW is capped. + passes = _run_mass_estimate(MTOW - OEW - 100.0) + assert passes[1] == MTOW + + +# --------------------------------------------------------------------------- +# Real BADA files (skipped without --bada-path / BADA_PATH) +# --------------------------------------------------------------------------- + + +def test_bada3_mpl_matches_opf_and_bada4(bada_path: Path | None) -> None: + """A20N: BADA 3.16 OPF and BADA 4.2.1 A320-271N give the same MPL.""" + if bada_path is None: + pytest.skip("BADA data not available (use --bada-path or BADA_PATH)") + bada3_dir = bada_path / DEFAULT_BADA3_VERSION + bada4_dir = bada_path / DEFAULT_BADA4_VERSION + if not (bada3_dir / "A20N__.OPF").exists(): + pytest.skip(f"A20N__.OPF not found in {bada3_dir}") + + bada3 = BADA3Adapter(str(bada3_dir), "A20N") + assert bada3.MPL == MPL + assert bada3.MTOW == MTOW + assert bada3.OEW == OEW + + if not (bada4_dir / "A320-271N").exists(): + pytest.skip(f"A320-271N not found in {bada4_dir}") + assert BADA4Adapter(str(bada4_dir), "A320-271N").MPL == bada3.MPL From b64ed3ed72b38eb238b0789117c311d98981c1ac Mon Sep 17 00:00:00 2001 From: Ramon DALMAU Date: Wed, 21 Oct 2026 09:16:47 +0200 Subject: [PATCH 2/2] fix(performance): treat a zero or NaN maximum payload as missing BADA 3.16 U2____.OPF (also used by pyBADA for G850, GFLY, M17 and M55) gives a maximum payload of 0, which the mass estimate would take as zero payload (ZFW = OEW) whatever the payload factor. A missing, zero, negative or NaN MPL now falls back to MTOW - OEW with the warning. No type in the NEATS BADA mapping is affected; BADA 4.2.1 has no such value. Also corrects the I4 audit note (the -21 % minimum is a GLF5). Co-Authored-By: Claude Opus 5.5 --- docs/internal/pyneats_units_audit.md | 21 ++++++++++++--------- src/pyneats/steps/performance/bada_model.py | 16 ++++++++++------ tests/test_bada3_max_payload.py | 7 +++++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/internal/pyneats_units_audit.md b/docs/internal/pyneats_units_audit.md index 1b51627..1c4bc5b 100644 --- a/docs/internal/pyneats_units_audit.md +++ b/docs/internal/pyneats_units_audit.md @@ -729,15 +729,18 @@ for a separate decision and were not fixed. heavier than pf × MPL. The comment was corrected; the behaviour was not changed. *Fixed on branch `fix/bada3-max-payload` (see the follow-up status table). `BADA3Adapter.MPL` now returns the OPF maximum payload, as `BADA4Adapter.MPL` already did. - The MTOW − OEW fallback stays for a BADA file without MPL; all 264 BADA 3.16 OPF files - have one. Before the fix, with the default payload factor 1.0, ZFW = MTOW, so every BADA3 - flight took off at MTOW (1,050 of 1,050 BADA3 flights in the NEATS_FINAL sample). Measured - on those 1,050 flights with the BADA 3.16 files: BADA3 fuel −4.5 % (7,313 t → 6,983 t), - CO2 −4.5 %; per flight median −5.7 %, 5th percentile −10.4 %, minimum −21 % (GLEX, whose - OPF payload is 8 % of MTOW − OEW); 42 flights still take off at MTOW; take-off mass - median −8.2 t. BADA4 flights are not affected. Only the BADA3 path changes, including - BADA4 types that fall back to BADA3. The fuel-burn guardrail still uses MTOW − OEW on - purpose (payload and fuel together).* + The MTOW − OEW fallback stays for a BADA file without a usable MPL, and now also covers a + zero, negative or NaN value: all 264 BADA 3.16 OPF files give one, but `U2____.OPF` (also + used by pyBADA for G850, GFLY, M17 and M55) gives 0. None of these types is in the NEATS + BADA mapping. In BADA 4.2.1, only SR20 and SR22 have no MPL. Before the fix, with the + default payload factor 1.0, ZFW = MTOW, so every BADA3 flight took off at MTOW (1,050 of + 1,050 BADA3 flights in the NEATS_FINAL sample). Measured on those 1,050 flights with the + BADA 3.16 files: BADA3 fuel −4.5 % (7,313 t → 6,983 t), CO2 −4.5 %; per flight median + −5.7 %, 5th percentile −10.4 %, minimum −21 % (business jets with a small OPF payload: GLF5 + 15 %, GLEX 8 % of MTOW − OEW); 42 flights still take off at MTOW; take-off mass median + −8.2 t. BADA4 flights are not affected. Only the BADA3 path changes, including BADA4 types + that fall back to BADA3. The fuel-burn guardrail still uses MTOW − OEW on purpose (payload + and fuel together).* - **I5. Parser `timezone` parameter has no effect.** pycontrails stores time as naive UTC (`vector.py:2162-2167`). The docstrings were corrected. - **I6. Outputs not strict JSON.** `scripts/run_json.py` writes no output. Golden JSON diff --git a/src/pyneats/steps/performance/bada_model.py b/src/pyneats/steps/performance/bada_model.py index a43168a..aa29ca7 100644 --- a/src/pyneats/steps/performance/bada_model.py +++ b/src/pyneats/steps/performance/bada_model.py @@ -168,8 +168,8 @@ class BADAPerformanceModelParams(PerformanceModelParams): payload_factor : float Payload mass as a fraction of the BADA maximum payload (MPL): ZFW = OEW + payload_factor x MPL [kg], with MPL taken as MTOW - OEW - when the adapter gives no MPL (BADA3 and BADA4 adapters both read it - from the BADA files). + when the adapter gives no usable MPL (missing, zero, negative or NaN; + BADA3 and BADA4 adapters both read it from the BADA files). The first-pass constant-mass guess is OEW + payload_factor x (MTOW - OEW). Not a seat load factor; not clipped. Overridden by the ``payload_factor`` flight attribute and @@ -1020,7 +1020,7 @@ def _estimate_initial_mass_iterative( All masses in kg. Initial mass = min(MTOW, ZFW + trip fuel x (1 + fuel_reserve_fraction)), with ZFW = OEW + payload_factor x MPL - (or x (MTOW - OEW) when MPL is None) and trip fuel = sum of BADA fuel + (or x (MTOW - OEW) when MPL is missing or not positive) and trip fuel = sum of BADA fuel flow [kg/s] x segment_duration [s] (NaN skipped). """ @@ -1039,14 +1039,18 @@ def _estimate_initial_mass_iterative( retryable=False, ) - if adapter.MPL is None: + # A zero, negative or NaN MPL is treated as missing (BADA 3.16 U2____.OPF + # gives 0, which would otherwise mean ZFW = OEW whatever the payload factor). + bada_mpl = adapter.MPL + if bada_mpl is None or not np.isfinite(bada_mpl) or bada_mpl <= 0: self.logger.warning( - "BADA adapter for ICAO '%s' does not provide MPL, assuming MPL = MTOW - OEW. Conservative case", + "BADA adapter for ICAO '%s' does not provide a usable MPL (%s), assuming MPL = MTOW - OEW. Conservative case", icao, + bada_mpl, ) maximum_payload = maximum_takeoff_weight - operating_empty_weight else: - maximum_payload = adapter.MPL + maximum_payload = bada_mpl zero_fuel_weight: float = ( operating_empty_weight + payload_factor * maximum_payload diff --git a/tests/test_bada3_max_payload.py b/tests/test_bada3_max_payload.py index 91ccd39..e7a22fb 100644 --- a/tests/test_bada3_max_payload.py +++ b/tests/test_bada3_max_payload.py @@ -133,6 +133,13 @@ def test_falls_back_to_mtow_minus_oew(self) -> None: passes = _run_mass_estimate(None) assert passes[1] == MTOW + @pytest.mark.parametrize("bad_mpl", [0.0, -1.0, float("nan")]) + def test_unusable_mpl_falls_back(self, bad_mpl: float) -> None: + # BADA 3.16 U2____.OPF gives MPL = 0: treated as missing, not as + # zero payload (which would give ZFW = OEW). + passes = _run_mass_estimate(bad_mpl) + assert passes[1] == MTOW + def test_payload_factor_scales_mpl(self) -> None: passes = _run_mass_estimate(MPL, payload_factor=0.5) assert passes[1] == pytest.approx(OEW + 0.5 * MPL + TRIP_FUEL * (1 + RESERVE))