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..1c4bc5b 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,20 @@ 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 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_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..aa29ca7 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 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 @@ -1019,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). """ @@ -1038,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 new file mode 100644 index 0000000..e7a22fb --- /dev/null +++ b/tests/test_bada3_max_payload.py @@ -0,0 +1,174 @@ +"""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 + + @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)) + + 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