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
2 changes: 1 addition & 1 deletion docs/explanation/mrv-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ See [Input Prioritization](input-prioritization.md) for the full decision flowch

#### Additional Parameters

- **Fuel calorific value (`q_fuel`):** if provided by the AO, applies a **linear correction** to the BADA fuel flow rate. If not provided, no correction is applied.
- **Fuel calorific value (`q_fuel`):** BADA gives the fuel flow of a fuel with a lower calorific value of **43.217 MJ/kg** (the value declared in the BADA 4 aircraft files; the same value is used for BADA 3, which declares none). PyNEATS rescales it to the fuel actually used: `fuel flow = BADA fuel flow × 43.217 / q_fuel`, where `q_fuel` is the AO value, else the airport fuel file value, else the default 42.8 MJ/kg. The correction therefore always applies: with the default, BADA fuel flow is multiplied by 43.217 / 42.8 = 1.0097 (+0.97 %). A provided AO fuel flow is not rescaled.
- **ICAO ↔ BADA mapping:** the correspondence between ICAO aircraft types, aircraft versions, engine identifiers and BADA types (**BADA 4 in priority**, then **BADA 3**) is achieved following the decision tree in Figure 2.

![ICAO-BADA Correspondence decision tree](../assets/icao-bada-mapping.png){ width="100%" }
Expand Down
51 changes: 44 additions & 7 deletions docs/internal/pyneats_units_audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ cite line numbers on `dev` before the fix they describe.
| A10 (airport fuel file units) and the missing `q_fuel` magnitude check | Fixed: a supplied `q_fuel` outside 35–50 MJ/kg is rejected (flight fails; a wrong airport file stops the run); no change on valid inputs | #77 | 2026-10-22 |
| I11 (fleet step cache shared between runners in one process) | Fixed; no change for a single run | #78 | 2026-10-22 |
| D3 (fuel flow derived from `am` one point late) | Fixed; changes fuel, emissions, contrail and climate results only for flights with `am` and no `ff` | `fix/mass-derived-fuel-flow` | 2026-10-22 |
| A11/A12 (fuel energy reference of the BADA fuel flow) | Fixed: reference = BADA4 LHV 43.217 MJ/kg for BADA4 and BADA3 (maintainer decision); docs corrected; fuel and CO2 +0.21 % on the sample | `fix/fuel-energy-reference` | 2026-10-22 |
| I12 (FlightRunner and FleetRunner give different contrail results for the same flight) | Open; recorded; contrail-based values get rtol 5e-3 in the FlightRunner golden test | `fix/fuel-energy-reference` | 2026-10-22 |

## 1. Outcome

Expand Down Expand Up @@ -147,7 +149,7 @@ golden data. *Stated* means written in a comment, docstring or document only.
| `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)* |
| `fuel_properties.calorific_value` | MJ/kg of fuel | attr `q_fuel`, **J/kg** (lower heating value) | J/kg in pycontrails `Fuel.q_fuel` and in the BADA fuel-flow correction ratio | × 1e6 in the **NEATS application** (owner-confirmed, not inspected). PyNEATS performs no conversion | **Known difference handled upstream** | `neats_io.py:118`; `neats_fuel.py:92`; `neats_default_parameters.py:315-320` (43.13e6, 42.8e6); `bada_model.py:765-771,841,875`; golden inputs 4.1944e7 *(demonstrated)* |
| `fuel_properties.calorific_value` | MJ/kg of fuel | attr `q_fuel`, **J/kg** (lower heating value) | J/kg in pycontrails `Fuel.q_fuel` and in the BADA fuel-flow correction ratio | × 1e6 in the **NEATS application** (owner-confirmed, not inspected). PyNEATS performs no conversion | **Known difference handled upstream** | `neats_io.py:118`; `neats_fuel.py:92`; `neats_default_parameters.py` (`REFERENCE_Q_FUEL` 43.217e6 since A12, was 43.13e6; `DEFAULT_Q_FUEL` 42.8e6); `bada_model.py:765-771,841,875`; golden inputs 4.1944e7 *(demonstrated)* |
| `fuel_properties.sulphur` | "unitless"; scale and basis not stated | attr `sulphur_content`, stored | not used. `ei_so2` is always the JetA value of 1.2e-3 kg/kg | none | **Not consumed**. Mass basis per MRR §5(4) default; JSON scale undefined (A5) | `neats_io.py:119`; `neats_fuel.py:97-98,107-108`; pycontrails `emissions.py:770-771` *(demonstrated)* |
| `fuel_properties.naphthalene` | "unitless"; scale and basis not stated | dropped | — | key `naphthalene` in `neats_io` against `naphtalene` in `Flight4D.ATTRS_OPTIONAL`, so the value never reaches the fuel object (I2) | **Not consumed** | `neats_io.py:120,122`; `views.py:45`; `neats_fuel.py:195`; snippet *(demonstrated)* |
| `trajectory.trajectory_data[].ts` | UTC, ISO 8601 date-time | column `time`: naive string `%Y-%m-%d %H:%M:%S`, read as UTC | tz-naive UTC `datetime64[ns]`; durations in s (forward differences); resampled to 60 s by default | `strptime(...%z)` then `strftime` **discards the offset without applying it** | **New discrepancy** (D2), conditional on non-UTC offsets; UTC input is correct | `neats_io.py:61-68`; `neats_parser.py:233-242`; pycontrails `core/vector.py:292-304,2162-2167`; snippet *(demonstrated)* |
Expand All @@ -169,8 +171,8 @@ golden data. *Stated* means written in a comment, docstring or document only.
| --- | --- | --- | --- | --- | --- | --- |
| DataFrame entry (`trajectory_dataframe`, `FlightRunner(source=df)`): `altitude`, `time` | Not defined by AO upload schema | `altitude` in **FL**, not metres. `time` as `datetime64` (naive means UTC; tz-aware is converted) or a string in `date_format` (default `%Y-%m-%d %H:%M:%S`) | as for the JSON path | the same parser conversions | **Ambiguous / unverified** (A9) | `neats_parser.py:141,217-258`; example comment `examples/fleet_computation_from_dataframe.py:172` *(stated)*; snippet: ISO strings become NaT and are dropped *(demonstrated)* |
| Airport fuel-property file (`airport_fuel_path`) | Not defined by AO upload schema | `float()` only. `q_fuel` assumed J/kg; `h_c_ratio` used as atomic | J/kg, wt.% after `NEATSFuel` | **none**. PyNEATS reads the file directly, so the application's × 1e6 does not apply to it | **Checked**: 35–50 MJ/kg in J/kg enforced at load (A10, fixed) | `neats_parser.py:35-41,44-120,284-296`; `tests/test_airport_qfuel.py` (J/kg) |
| Default branch: no `calorific_value` | Not defined by AO upload schema | `DEFAULT_Q_FUEL` = 42.8e6 J/kg | the BADA fuel flow is still multiplied by 43.13/42.8 = 1.0077 | correction ratio in `BADAPerformanceModel` | **Ambiguous / unverified** (A11) | `neats_fuel.py:92`; `bada_model.py:565-571,764-772,841,875`; `docs/explanation/mrv-specification.md:220` *(stated)* |
| `REFERENCE_Q_FUEL` against BADA4 LHV | Not defined by AO upload schema | 43.13e6 J/kg (pycontrails JetA) | — | not read from BADA files, although BADA4 carries a per-aircraft LHV | **Ambiguous / unverified** (A12) | `neats_default_parameters.py:315-317`; pyBADA `bada4.py:1855-1874`; pyBADA dummy BADA4 files: 43.217e6 and 43.71e6 J/kg |
| Default branch: no `calorific_value` | Not defined by AO upload schema | `DEFAULT_Q_FUEL` = 42.8e6 J/kg | the BADA fuel flow is multiplied by 43.217/42.8 = 1.0097 (43.13/42.8 = 1.0077 before A12) | correction ratio in `BADAPerformanceModel` | **Resolved**: the correction always applies and the spec now says so (A11) | `neats_fuel.py:92`; `bada_model.py:565-571,764-772,841,875`; `docs/explanation/mrv-specification.md:220` *(stated)* |
| `REFERENCE_Q_FUEL` against BADA4 LHV | Not defined by AO upload schema | 43.217e6 J/kg, the BADA4 4.2.1 LHV (was 43.13e6, pycontrails JetA) | — | checked against every mapped BADA4 file by a test | **Resolved** (A12) | `neats_default_parameters.py:315-317`; pyBADA `bada4.py:1855-1874`; pyBADA dummy BADA4 files: 43.217e6 and 43.71e6 J/kg |
| Per-point nulls in `tas`, `ff`, `ee`, `am` | schema allows `null` per point | a column survives unless every value is null | NaN spreads through interpolation and is skipped by sums | none | **New discrepancy** (D4) | `neats_io.py:93-94`; `steps/interpolation/pycontrails_interpolation.py:108-112`; `bada_model.py:1071-1076`; snippet *(demonstrated)* |

## 4. Audit table: public outputs
Expand Down Expand Up @@ -441,7 +443,7 @@ Each entry gives:
test stays a known DLR failure (strict xfail). The same applies to the hydrogen
([13.79, 15.5] wt.%) and H/C ([1.9061, 2.1857]) range cases.

**A11. `q_fuel` correction on the default branch.**
**A11. `q_fuel` correction on the default branch.** *Resolved on branch `fix/fuel-energy-reference` (see A12 and the follow-up status table).*
1. `bada_model.py:565-571,764-772,841,875`.
2. `mrv-specification.md:220` says no correction is applied when the AO gives no `q_fuel`. The
code always applies the ratio 43.13/42.8, because `NEATSFuel` always carries a `q_fuel`
Expand All @@ -460,6 +462,22 @@ Each entry gives:
Licensed files were not checked.
4. No.
5. **Check against BADA4 files**. Consider reading the adapter's LHV for BADA4.
6. **Resolution (2026-10-22), A11 and A12.**
- **Verification.** pyBADA 0.1.12 computes BADA4 fuel flow as energy flow / `AC.LHV`
(`bada4.py:1855-1873`). All 86 BADA4 models reachable through the PyNEATS mapping
tables declare LHV = 43.217e6 J/kg (all jets, BADA 4.2), in both BADA folders used
(`NEATS_TEST/mbg3P31p/4.2.1` and `NEATS_CLEAN/.../pyBADA/4.2.1`). The two pistons in
BADA 4.2.1 (SR20, SR22: 43.71e6) are not mapped and not used.
- **Maintainer decision (Philippe Very and Ramon Dalmau).** Keep the rescaling of the BADA
fuel flow to the actual fuel, `ff = ff_BADA × REFERENCE_Q_FUEL / q_fuel`, always applied
(default q_fuel 42.8e6, so ×1.0097). `REFERENCE_Q_FUEL` = 43.217e6 J/kg, the BADA4 LHV,
for BADA4 and for BADA3 (which declares no LHV). `mrv-specification.md` now states this
instead of "no correction if not provided". A test checks that every mapped BADA4 model
declares this LHV (`tests/test_fuel_energy_reference.py`).
- **Effect** (fleet before/after on the 4,959 NEATS_FINAL sample flights, `dev` db7d6e1):
fuel and CO2 +0.21 % (BADA4 +0.21 %, BADA3 +0.21 %), NOx +0.41 %; per flight −0.6 % to
+1.3 % (median +0.21 %; the iterative take-off mass stops within 1 %). Same failing
flights, no change of BADA family.

**A13. Mixed fuel-flow provenance (documented in part).**
- When `ff` is provided without `am`:
Expand Down Expand Up @@ -735,7 +753,7 @@ discrepancies:
| `docs/reference/input-format.md:43`; `scripts/schemas/flight_schema.json:94-96` | `hydrogen_content` is a "mass fraction (0–1)" | wt.% (13.79). A caller sending 0.138 gets `ei_h2o` 100 times too low | fix docs |
| `scripts/schemas/flight_schema.json:106-109` | `calorific_value` in MJ/kg | J/kg (golden inputs validated by this schema hold 4.1944e7) | fix schema text: PyNEATS input is J/kg after the application's × 1e6 |
| `docs/explanation/mrv-specification.md:233-238` | H/C is a "molecular mass ratio"; formula without × 100 | atomic ratio; × 100 to give wt.% | fix spec |
| `docs/explanation/mrv-specification.md:220` | no BADA correction when `q_fuel` is not provided | correction always applied (A11) | decide, then align |
| `docs/explanation/mrv-specification.md:220` | no BADA correction when `q_fuel` is not provided | correction always applied (A11) | **Aligned** (A11/A12): the spec now states the always-applied correction with the BADA4 LHV |
| `docs/explanation/mrv-specification.md:264,277,302` | EAGWP and AGWP in W m-2 | W m-2 yr | fix spec |
| `docs/explanation/mrv-specification.md:272` | s_yr = 31,536,000 s | 31,556,952 s (`physics.py:58`); contrail EAGWP differs by 0.066 % | decide which year length, then align |
| `docs/explanation/mrv-specification.md:284-286` | C(H) = 24.16 / 47.57 / 74.36 e-15 | 24.12 / 47.50 / 74.24 e-15 (commit `bf3aa7b`, newer); CO2eq differs by about 0.15–0.17 % | update spec |
Expand Down Expand Up @@ -888,6 +906,25 @@ for a separate decision and were not fixed.
step, and the builds are cheap. The weather dataset cache is keyed by
path, time window and chunks, and was not affected.

- **I12. FlightRunner and FleetRunner give different contrail results for the same flight.**
Found 2026-10-22 while regenerating the golden files for A11/A12. *Open.*
- **Observation.** `test_flight_runner_golden` runs each golden flight through FlightRunner
and compares it with golden outputs produced by FleetRunner (CoCiP on the whole fleet).
For FDX5067 (`aircraft_mass_col`), fuel flow, engine efficiency, aircraft mass,
temperature and humidity are identical at every waypoint in both runs, yet the
contrails formed at waypoints 48–49 end at 2 h 45 / 2 h 44 with FlightRunner and at
2 h 51 / 2 h 56 with FleetRunner. The flight's `contrails_ef_J` and its contrail
EAGWP/CO2eq differ by 1.03e-3 (test rtol 1e-3). On `dev` db7d6e1 the same contrails live
equally long in both runs (difference 1.8e-6); the +0.2 % fuel of A12 is enough to tip
them.
- **Likely cause (not verified).** CoCiP run on a fleet evolves all contrails on one common
time grid, while a single-flight run aligns the grid to that flight, and contrail
termination depends on the grid.
- **Handling.** In `tests/test_pipeline.py::test_flight_runner_golden` only the
contrail-based values (`contrails_ef_J` and the `Contrails` climate metrics) are compared
at rtol 5e-3; everything else stays at 1e-3, and the FleetRunner golden tests are
unchanged. To investigate: whether the two runners should give identical contrails.

## 11. Patch summary and checks performed

**Files modified.** Comments and docstrings were added or corrected in 27 source files (+826/−178 lines). New
Expand All @@ -900,8 +937,8 @@ file: `docs/internal/pyneats_units_audit.md` (this report).
column and attr), `params.py` (corrected 500 hPa → 550 hPa ≈ FL160).
- Fuel and constants: `core/neats_fuel.py` (wt.%, atomic H/C, J/kg template, fixed JetA
EIs), `core/neats_default_parameters.py` (units of every constant; corrected FL15 → FL20
and 1.1/10 % → 1.2/20 %; `REFERENCE_Q_FUEL` is the pycontrails JetA value, not a BADA
value), `core/physics.py` (year length and C(H) against the spec; conversion-factor
and 1.1/10 % → 1.2/20 %; `REFERENCE_Q_FUEL` was then the pycontrails JetA value, not a
BADA value; it is the BADA4 LHV since A12), `core/physics.py` (year length and C(H) against the spec; conversion-factor
direction and unverified absolute units), `core/fleet_utils.py` (fuel rebuilt from attrs),
`core/views.py` (`to_dict` time and timedelta encodings), `core/compute_parameters.py`.
- Performance: `steps/performance/bada_model.py` (parameter units and meaning; TAS template;
Expand Down
24 changes: 14 additions & 10 deletions src/pyneats/core/neats_default_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
DEFAULT_HYDROGEN_CONTENT : float
Default hydrogen content for fuel, in wt.% (percent by mass).
REFERENCE_Q_FUEL : float
Reference q_fuel value, in J/kg (pycontrails JetA lower calorific value).
Fuel lower calorific value assumed by the BADA fuel flow, in J/kg (the
BADA4 LHV, 43.217e6; also used for BADA3).
DEFAULT_Q_FUEL : float
Default q_fuel value, in J/kg (lower calorific value).
Q_FUEL_MIN, Q_FUEL_MAX : float
Expand Down Expand Up @@ -326,15 +327,18 @@
13.79 # wt.% - Default value from consortium (DLR/TO70) for fuel composition
)

# Fuel energy content (specific energy), lower calorific value in J/kg of fuel
# Critical for converting fuel consumption to energy and emissions estimates
# REFERENCE_Q_FUEL is the pycontrails JetA q_fuel (pycontrails/core/fuel.py); it is
# not read from BADA files. The BADA fuel flow is divided by q_fuel / REFERENCE_Q_FUEL.
# Because NEATSFuel always carries a q_fuel (DEFAULT_Q_FUEL when none is supplied),
# this correction always applies (see docs/internal/pyneats_units_audit.md).
REFERENCE_Q_FUEL: Final[float] = (
43_130_000.0 # J/kg - Baseline value from PyContrails JetA
)
# Fuel energy content (specific energy), lower calorific value in J/kg of fuel.
# BADA gives the fuel flow of a fuel with lower calorific value REFERENCE_Q_FUEL
# (pyBADA BADA4: fuel flow = energy flow / LHV of the aircraft file). PyNEATS
# rescales it to the actual fuel: fuel_flow = BADA fuel flow x REFERENCE_Q_FUEL /
# q_fuel. NEATSFuel always carries a q_fuel (DEFAULT_Q_FUEL when none is
# supplied), so this always applies: x 43.217/42.8 = 1.0097 by default.
# REFERENCE_Q_FUEL is the BADA4 4.2.1 LHV: 43.217e6 J/kg in every BADA4 model
# reachable through the PyNEATS mapping tables (86 jets; checked by
# tests/test_fuel_energy_reference.py). BADA3 gives no LHV; the same value is
# used for BADA3 (maintainer decision 2026-10-22, audit A11/A12). Piston models
# (43.71e6) are not used.
REFERENCE_Q_FUEL: Final[float] = 43_217_000.0 # J/kg - BADA4 4.2.1 LHV
# DEFAULT_Q_FUEL is used when neither the AO nor the airport fuel file supplies q_fuel
DEFAULT_Q_FUEL: Final[float] = (
42_800_000.0 # J/kg - Default value provided by consortium (DLR/TO70)
Expand Down
8 changes: 4 additions & 4 deletions src/pyneats/steps/performance/bada_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,10 @@ class BADAPerformanceModelParams(PerformanceModelParams):
Savitzky-Golay window length (number of points) passed to pycontrails
``segment_true_airspeed``; used only when TAS is computed from winds.
reference_q_fuel : float
Reference lower calorific value [J/kg of fuel]. BADA fuel flow is
divided by ``q_fuel_used / reference_q_fuel``. The default
(``REFERENCE_Q_FUEL`` = 43.13e6 J/kg) is the pycontrails JetA value,
not the per-aircraft BADA4 LHV (see docs/internal/pyneats_units_audit.md).
Lower calorific value [J/kg of fuel] of the fuel BADA assumes. BADA
fuel flow is divided by ``q_fuel_used / reference_q_fuel``. The default
(``REFERENCE_Q_FUEL`` = 43.217e6 J/kg) is the BADA4 4.2.1 LHV, used for
BADA4 and BADA3 (see docs/internal/pyneats_units_audit.md, A11/A12).
delta_tau_compute_method : {"point", "zero"}
Method for delta-tau computation.
delta_tau_fill_method : {"bffill", "none", "zero"}
Expand Down
Loading
Loading