Conventions cheat-sheet
A short reference card for the decisions baked into fmf_eval. Use
this document when verifying that fmf_eval matches a paper formula
or another tool (in particular the IDL DeltaTool). For full
derivations see INDICATORS.md.
Contents
Master denominator
Every continuous indicator in fmf_eval is built around the same
persistence baseline from JRC146007 Eq. 2: the model’s residuals
are compared against those of a trivial “yesterday’s observation”
predictor, with the measurement uncertainty applied as a
sign-preserving envelope on the persistence residuals.
Denominator |
Used for |
Formula |
|---|---|---|
|
|
|
|
|
|
|
|
|
The three “p” denominators share one building block, obstempDiff,
constructed from the obs series shifted by the forecast horizon and
inflated in the same direction as the residual:
obs2_i = obs shifted by (1 + FH) days (IDL line 652)
obstempDiff_i = obs_i − obs2_i
Crit_i = U_rv95r · sqrt( (1 − α²)·obs2_i² + α²·LV² )
if obstempDiff_i > 0: obstempDiff_i += Crit_i (IDL lines 681–686)
if obstempDiff_i ≤ 0: obstempDiff_i −= Crit_i
Crit is added in the same direction as the residual, not in
quadrature. Small persistence errors are therefore not penalised when
they fall within measurement noise. This is not the simpler form
sqrt(mean((P−O)² + U²)); the IDL formula carries an extra
cross-term 2·|P−O|·U inside the mean and is what DeltaTool actually
uses.
The pass/fail thresholds are MQIf ≤ 1, MPI1 ≤ 1 and MPI2 ≤ 1 —
i.e. “forecast no worse than the appropriate baseline”. See
INDICATORS.md — Continuous indicators
for the per-indicator algebra.
Which U(O) argument?
The choice of which argument the uncertainty function is evaluated on differs between use sites:
Use site |
|
Why |
|---|---|---|
Persistence |
|
The envelope inflates the persistence prediction’s residual; that prediction is |
|
|
|
This split is not cosmetic — it matches the IDL exactly
(elaboration_routines.pro lines 680 vs 1471) and the two values are
different at the MQOf boundary.
Which U(O) formulation?
U(O) is selected at run time via uncertainty_def in run.yaml:
|
Formula |
Parameter table |
Reference |
|---|---|---|---|
|
|
Janssen & Thunis 2022 (JRC129254) |
|
|
|
AAQD 2024/2881, Table 1 of JRC146736 |
Both formulations return the absolute observation uncertainty in
µg/m³. The stringency factor β is not baked into U_of_O itself
— beta_short is carried on the params object for downstream use only.
A user-supplied override CSV (inputs.uncertainty) is loaded with the
same schema as whichever default file matches the active definition.
For full curve definitions and parameter tables see
INDICATORS.md — Measurement uncertainty.
Statistical conventions
Strict > for exceedance
The contingency table uses strict > on both axes:
GA+ = #{ M > T AND O > T }
FA = #{ M > T AND O ≤ T }
MA = #{ M ≤ T AND O > T }
GA− = #{ M ≤ T AND O ≤ T }
A day where the observation exactly equals the threshold is counted
as a non-exceedance. Matches IDL elaboration_routines.pro lines
1214–1218 (forecast categorical) and 1438–1441 (persistence
categorical, with obs2 in place of M).
Pearson correlation
R = np.corrcoef(M, O)[0,1]. Range [−1, +1]. Used in the persistence
categorical scores and in the residual decomposition behind the
Forecast Target Plot.
NaN handling — paired drop
For any indicator computed from (O, M) pairs, days where either O
or M (or obs2, when needed) is NaN are dropped from the paired
set before any statistic is computed. This is the “paired
complete-case” convention; fmf_eval applies it inside
stats_core._aligned:
mask = ~(np.isnan(o) | np.isnan(m))
o_clean, m_clean = o[mask], m[mask]
NaNs do not propagate silently — they are dropped, and the
per-station N_valid count is logged.
NaN vs zero for categorical denominators
Categorical scores have denominators that can hit zero (e.g.
SR = GA+ / (GA+ + FA) is undefined if the forecast never exceeds the
threshold). fmf_eval returns NaN in those cases, not 0 — a
forecast that has never said “exceedance” is not a forecast with SR = 0; it has no SR at all. ACC is always defined whenever N > 0.
75 %-completeness rule (daily aggregation)
A station’s daily series requires ≥ 75 % of the hourly values to be
present (e.g. ≥ 18 of 24 for daily-mean pollutants). Days that fail
this stay as NaN and are never paired into the indicator calculation.
Stations whose total valid-day fraction falls below min_data_capture
are excluded from the network aggregation. Both checks are logged.
Sign conventions
Quantity |
Sign |
Aggregation |
|---|---|---|
|
signed |
— (per station; feeds Target y-axis) |
|
always ≥ 0 |
— |
|
always ≥ 0 |
|
|
always ≥ 0 |
— (already absolute-value mean) |
|
signed before |
— (cancels out in |
Categorical counts |
always ≥ 0 (integer) |
— |
|
always ≥ 0 (NaN possible) |
distribution across stations (Figs. 7, 8) |
|
always ≥ 0 (NaN possible) |
|
Target Plot x-axis sign rule
The Forecast Target Plot’s x-axis is CRMSE / RMSE_p, with sign:
x = +CRMSE / RMSE_p if FA > MA (false-alarm-dominated)
x = −CRMSE / RMSE_p if FA ≤ MA (miss-dominated)
The condition is strict > for the right side, matching the IDL.
A station with FA == MA lands on the negative (miss) side. Both
backends (matplotlib forecast_target.py and its plotly counterpart)
implement this rule identically.
90th-percentile rule
fmf_eval reproduces the IDL DeltaTool percentile interpolation
by default (indicators.percentile_worst(..., method="idl")):
# IDL DeltaTool convention (elaboration_routines.pro):
# n = 0.90 * N
# nf = floor(n)
# pos = nf - 1 + (n - nf) # real-valued 0-indexed rank
# MQIf_p90_worst = interp between sorted[floor(pos)] and sorted[floor(pos)+1]
MQIf_p90_worst = indicators.percentile_worst(mqif_per_station, 90) # method="idl"
This is bit-identical to the IDL rank position. For N = 18 it
interpolates at rank pos = 16 − 1 + 0.2 = 15.2 (0-indexed) on the
ascending-sorted values.
A method="numpy" opt-in is retained for backwards compatibility; it
calls numpy.percentile(..., method="linear"), whose position is
(N − 1)·0.9 = 15.3 for N = 18. The two differ by a fraction of a
rank and can flip green→red at the MQOf boundary — see
Reference-implementation alignment.
The 10th-percentile-worst persistence-normalised ratios use the same helper (and therefore the same IDL convention by default):
POD_over_PODp_p10 = indicators.percentile_worst(pod_over_podp, 10) # method="idl"
MPC and MQOf criteria
Indicator group |
Criterion implemented |
Status |
|---|---|---|
|
|
JRC146007 Eq. 1 |
MQOf (network) |
|
JRC146007 §1.1 — Modelling Quality Objective |
|
|
JRC146007 Eq. 6 |
|
|
JRC146007 Eq. 7 |
MPC (per station) |
|
JRC146007 §1.2 — Modelling Performance Criterion |
|
persistence-normalised; green when ratio > 1 |
JRC146007 §2.3 — no formal cut-off |
Six categorical scores (raw) |
No criterion |
JRC146007 §3 — open issue |
AQI distributional match |
No criterion |
JRC146007 §3 — open issue |
|
two-sided band |
FAIRMODE convention for ±25 % closeness-to-1 |
The open-issue criteria are reported as bare numbers in the per-station and summary CSVs; the tool does not invent a pass/fail threshold for them.
Network-level fulfilment fractions
Three summary scalars are written to indicators_summary_FH<n>.csv:
MQOf_fulfilled_fraction = mean(MQIf ≤ 1)
MPC_both_fulfilled_fraction = mean(MPI1 ≤ 1 AND MPI2 ≤ 1)
POD_over_PODp_p10 = np.percentile(POD/PODp, 10)
SR_over_SRp_p10 = np.percentile(SR/SRp, 10)
The MQOf is conventionally “fulfilled” when
MQIf_p90_worst ≤ 1, equivalently when at least 90 % of stations
have MQIf ≤ 1.
Time conventions
Local time and the GMT lag
local_time = UTC + GMTLag per station. GMTLag is read from
startup.ini (7th column, e.g. GMT, GMT+1; GMT is treated as
GMT+0). All daily aggregations (NO₂ max, O₃ max-8h, PM mean) are
performed in local time so that “daily” corresponds to the
calendar day at the station.
Daily aggregation rule by pollutant
Pollutant |
Daily statistic |
|---|---|
NO₂ |
daily maximum of hourly values |
O₃ |
daily maximum of 8-hour moving averages |
PM10 |
daily mean of hourly values |
PM2.5 |
daily mean of hourly values |
These match the EU Ambient Air Quality Directive aggregation rules. The 75 %-completeness rule is applied at every aggregation step.
Persistence lag
obs2_i = obs_{i − 1 − FH} for i ≥ 1 + FH. The first 1 + FH days
of any horizon-FH series are therefore unusable for persistence-based
indicators. The pipeline drops them silently from the paired set.
Reference-implementation alignment
fmf_eval is a Python port of the IDL DeltaTool’s forecast
evaluation routines.
Inspectable ground truth
The reference source is elaboration_routines.pro (4309 lines), the
human-readable IDL file shipped with the DeltaTool installer. The
compiled app/delta.sav is not inspected: its EULA forbids
reverse engineering. Routines defined outside
elaboration_routines.pro are flagged below as unaudited.
Verified bit-identical formulas
Quantity |
IDL line(s) |
|---|---|
Persistence shift ( |
652 |
Sign-preserving |
680–686 |
|
1252 |
|
1473 |
|
1471–1474 |
Strict |
1214–1218 |
Persistence categorical ( |
1438–1441 |
Documented IDL ↔ Python divergences
These are not bugs — they are deliberate, documented choices where the Python port differs from the IDL.
1. Threshold sensitivity (Fig. 6)
IDL: evaluates POD/SR at 5 points (
LV ± 1,LV ± 0.5,LV) and reports the slope.Python: evaluates at 2 points (
T ± threshold_sensitivity, default±1 µg/m³) and stores absolute scoresPOD_minus,POD_plus,SR_minus,SR_plus.
Same underlying data, different reported metric. Fig. 6 shows the 2-point version.
2. Percentile interpolation — now aligned
The percentile interpolation matches the IDL by default
(percentile_worst(..., method="idl")):
IDL / fmf_eval default:
pos = nf − 1 + (n − nf)withn = 0.9·N,nf = floor(n), on the ascending-sorted values.method="numpy"(opt-in):numpy.percentile(..., method="linear"), position(N − 1)·0.9.
For N = 18 the IDL/default position is 15.2; numpy is 15.3. The
default now reproduces the IDL value bit-for-bit; the numpy form is
retained only for backwards compatibility and can flip green/red on
MQIf_p90_worst at the MQOf boundary.
3. AQI categorisation
IDL: bin convention from the unaudited
aqindexroutine (lives in a separate IDL file).Python:
[lower, upper)for all categories except the last, which is[lower, upper].
Unverified equivalence — only matters at the exact bin edges.
4. obs_dir field accepts a .cdf file
For backwards compatibility the YAML field inputs.obs_dir accepts
either a directory of CSVs (the DeltaTool layout) or a NetCDF
file (DeltaTool’s OBS_TIME.cdf convention). The field name is kept
as-is to avoid breaking existing YAMLs.
Routines defined outside elaboration_routines.pro
Unaudited; behaviour matched best-effort against the DeltaTool User Guide:
aqindex— AQI bin classification.CheckCriteria,CheckCriteriaForecast— convenience wrappers.bias(),crmse()— helper functions.
Relation to fmm_assess
The companion fmm_assess package targets the same DeltaTool family
but for the assessment workflow (not forecasts). It uses the
mqor R package as primary reference and IDL only where mqor is
silent (the §3.7.3 coherence MPIs and §3.7.2 spatial-gradient MPIs).
fmf_eval has no equivalent of mqor in the forecast space, so the
IDL is the single normative reference for every indicator here.
For full formulas and reading guides see
INDICATORS.md and PLOTS.md. For the
full specification (run config, IDL mapping, every decision and why)
see TECH_SPEC.md.
Statistics reporting basis (classic stats vs MQI/MPI)
The per-station classic statistics (BIAS, RMSE, CRMSE, and DT’s
MO/MM/SO/SM/1-R) are reported on the regulatory averaging time of
the pollutant, matching the DeltaTool Summary Report:
Pollutant |
Statistics basis |
MQI / MPI basis |
|---|---|---|
NO2 |
hourly |
daily maximum |
O3 |
daily maximum of 8h |
daily maximum of 8h |
PM10 |
daily mean |
daily mean |
PM25 |
daily mean |
daily mean |
Only NO2 has a sub-daily statistics basis: its reported RMSE/BIAS/CRMSE
are computed on the hourly paired series, whereas its MQI/MPI are computed on the
daily-maximum series. For O3/PM the two bases coincide. This is implemented by
aggregations.to_stats_basis() / aggregations.stats_uses_subdaily(); the daily
series feeding MQIf (via rmse_f) is never altered. Reporting the daily-max
RMSE for NO2 would inflate it ~2× relative to DT, while leaving the MQI unchanged.
MFu / MPI2 — known DeltaTool discrepancy
fmf_eval computes the observation uncertainty per JRC146007 Eq. 4:
U(O) = Urv · sqrt( (1-α²)·O² + α²·LV² ) [spec, used here]
The compiled DeltaTool implements a different parenthesisation in
CheckCriteriaForecast:
U(O)_DT = Urv · sqrt( (1-α²)·(O² + α²·LV²) ) [DT, confirmed bug]
The DT form expands the LV² coefficient to (1-α²)·α² instead of α², and does
not reduce to Urv·O as α→0 (it should). It has been confirmed as a bug in
DT. Because MFu = mean(2·U(O)/O) feeds MPI2 = MFEf / MFu, DT’s smaller MFu
yields a systematically larger MPI2. The gap grows with α and is therefore
pollutant-dependent (empirical, sample dataset):
Pollutant |
α |
MPI2 gap (DT − fmf_eval) |
|---|---|---|
NO2 |
0.20 |
≈ +2.0 % |
PM10 |
0.25 |
≈ +2.7 % |
PM25 |
0.50 |
≈ +13 % |
O3 |
0.79 |
≈ +34 % |
fmf_eval intentionally keeps the JRC146007 formula; MQIf and MPI1 are
unaffected and match DT bit-for-bit. MPI2 will not reconcile with DT until the
DT binary is corrected.