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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/explanation/input-prioritization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
4 changes: 2 additions & 2 deletions docs/explanation/mrv-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 20 additions & 5 deletions docs/internal/pyneats_units_audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)* |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/pyneats/steps/performance/bada_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 10 additions & 5 deletions src/pyneats/steps/performance/bada_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
"""

Expand All @@ -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
Expand Down
Loading
Loading