Air Quality Data Gaps and Negative Values
On this page
Problem statement
The annual means look slightly off. A rural station reads higher than it should next to its neighbours; a PM2.5 series has a few hundred negative hours that somebody wants to delete; one suburban site has a mean that is suspiciously low and turns out to be built from four months of data. None of these raises an error. Each is a decision about which hours count, made implicitly by a filter or a default, and each biases the mean in a predictable direction.
The fix is to make the decisions explicit: which validity codes to keep, what to do with instrument noise below zero, and how much of the year a mean needs before it means anything.
Quick answer
Three rules cover most of it:
- Keep every hour flagged valid, including "valid, below detection limit". In EEA data that is
Validity > 0, notValidity == 1. - Keep negative values in means; clip only for display or index lookups. Deleting them biases the mean upwards.
- Require data capture — 90% of hours for an EU annual statistic, 75% (18 of 24 hours) for a daily mean — and report capture next to every mean.
import io, requests, pandas as pd
API = "https://eeadmz1-downloads-api-appservice.azurewebsites.net/ParquetFile/urls"
META = "https://discomap.eea.europa.eu/map/fme/metadata/PanEuropean_metadata.csv"
r = requests.post(API, json={"countries": ["DE"], "cities": ["Berlin"], "pollutants": ["NO2"],
"dataset": 2, "dateTimeStart": "2023-01-01T00:00:00Z",
"dateTimeEnd": "2023-12-31T23:00:00Z", "aggregationType": "hour"},
timeout=120)
meta = pd.read_csv(META, sep="\t", low_memory=False,
storage_options={"User-Agent": "spatialworkflow-examples/1.0"}).drop_duplicates("SamplingPoint") # EEA refuses Python's default agent
rows = []
for url in [u.strip() for u in r.text.splitlines()[1:] if u.strip()]:
d = pd.read_parquet(io.BytesIO(requests.get(url, timeout=120).content))
d["Value"] = pd.to_numeric(d.Value, errors="coerce")
y = d[(d.Start >= "2023-01-01") & (d.Start < "2024-01-01")]
if y.empty:
continue
sp = d.Samplingpoint.iloc[0].split("/")[1]
rows.append({"point": sp[7:14], "area": meta.set_index("SamplingPoint").loc[sp, "AirQualityStationArea"],
"share_code_2": (y.Validity == 2).mean(),
"mean_valid": y[y.Validity > 0].Value.mean(),
"mean_code_1_only": y[y.Validity == 1].Value.mean()})
t = pd.DataFrame(rows)
t["bias"] = t.mean_code_1_only - t.mean_valid
print(t.groupby("area")[["share_code_2", "mean_valid", "mean_code_1_only", "bias"]]
.mean().round(2).to_string())
share_code_2 mean_valid mean_code_1_only bias
area
rural-nearcity 0.24 7.52 9.31 1.79
suburban 0.16 8.79 10.07 1.29
urban 0.01 20.67 20.84 0.16
Hours below the detection limit are the cleanest hours, and they are concentrated at the cleanest stations: a quarter of the year at the rural stations, 1% in the city. Dropping them lifts the rural means by 1.8 µg/m³ — 24% — and the urban ones by 0.2, so the urban–rural gradient looks shallower than it is.
List flags, filter on validity, keep negatives, compute capture
1. List the flags and their meanings
EEA Validity: 1 valid, 2 valid below detection limit, 3 valid below detection limit with a substituted value, −1 not valid, −99 not valid because of maintenance. AURN: R ratified, P provisional. Read the codes before choosing a filter.
2. Filter on validity, not on value
Remove rows the provider marks invalid. Do not add your own value-based filters — "drop anything below zero", "drop values over 500" — without a documented reason.
3. Keep negative values in averages
Instruments measuring near zero report noise in both directions. The negative half is as real as the positive half; removing it removes only one side of the noise.
4. Compute capture per period
For each station and year: valid hours ÷ hours in the year. For each day: valid hours ÷ 24. Drop or flag periods below the threshold.
5. Check when the gaps are
Four missing weeks in winter bias an NO2 mean down; the same four weeks in summer bias it up. Report the months covered, not only the percentage.
Negative PM2.5 hours and a third-of-a-year mean
Example 1 — negative PM2.5 hours
def aurn(site, col):
text = requests.get(f"https://uk-air.defra.gov.uk/datastore/data_files/site_data/"
f"{site}_2023.csv", timeout=120).text
d = pd.read_csv(io.StringIO(text), skiprows=4, low_memory=False)
s = pd.Series(pd.to_numeric(d[col], errors="coerce").to_numpy(),
index=pd.to_datetime(d.Date, dayfirst=True))
return s, d.iloc[:, d.columns.get_loc(col) + 2].dropna().iloc[0]
pm, unit = aurn("HORS", "PM<sub>2.5</sub> particulate matter (Hourly measured)")
neg = pm[pm < 0]
print(f"London Westminster PM2.5 ({unit}): {len(neg)} negative hours, "
f"min {neg.min():.1f}, median of negatives {neg.median():.1f} µg/m³")
print(f"annual mean, as ratified: {pm.mean():.2f}")
print(f"annual mean, negatives → 0: {pm.clip(lower=0).mean():.2f}")
print(f"annual mean, negatives dropped: {pm[pm >= 0].mean():.2f}")
London Westminster PM2.5 (ugm-3 (BAM)): 159 negative hours, min -4.0, median of negatives -1.0 µg/m³
annual mean, as ratified: 7.81
annual mean, negatives → 0: 7.83
annual mean, negatives dropped: 7.98
The differences are fractions of a µg/m³ — and at the WHO guideline of 5 µg/m³, a fraction of a µg/m³ is several percent of the number being tested. The ratified mean already includes the negatives; that is what the data provider considers the best estimate.
Example 2 — a mean built from a third of the year
no2, _ = aurn("LON6", "Nitrogen dioxide")
ref, _ = aurn("KC1", "Nitrogen dioxide")
capture = no2.notna().groupby(no2.index.month).mean()
print(f"London Eltham NO2 capture: {no2.notna().mean():.0%} overall")
print("by month:", " ".join(f"{m}:{c:.0%}" for m, c in capture.items()))
both = pd.DataFrame({"eltham": no2, "kensington": ref}).dropna()
print(f"\nEltham mean over its {len(both):,} valid hours: {both.eltham.mean():.1f} µg/m³")
print(f"North Kensington over the same hours: {both.kensington.mean():.1f}; "
f"over the full year: {ref.mean():.1f}")
London Eltham NO2 capture: 33% overall
by month: 1:100% 2:0% 3:0% 4:85% 5:99% 6:100% 7:11% 8:0% 9:0% 10:0% 11:0% 12:0%
Eltham mean over its 2,887 valid hours: 11.0 µg/m³
North Kensington over the same hours: 17.2; over the full year: 18.1
Eltham measured in January and from April to early July, and nothing from August on. The reference station shows how unrepresentative those hours are: North Kensington reads 17.2 over them against 18.1 for the whole year, 5% lower. Scaling Eltham by that ratio gives about 11.6 µg/m³ — a documented correction, resting on one reference station. A mean on 33% capture should not be published without it, or without saying what it rests on.
Example 3 — capture-aware annual table
out = []
for site in ("MY1", "KC1", "CLL2", "HORS", "LON6"):
s, _ = aurn(site, "Nitrogen dioxide")
cap = s.notna().mean()
out.append({"site": site, "capture": f"{cap:.0%}", "mean": round(s.mean(), 1),
"valid for an annual statistic": cap >= 0.9})
print(pd.DataFrame(out).to_string(index=False))
site capture mean valid for an annual statistic
MY1 95% 41.6 True
KC1 99% 18.1 True
CLL2 99% 24.2 True
HORS 99% 21.8 True
LON6 33% 11.0 False
The column on the right is what turns a table of numbers into a table of findings.
Detection limits and capture thresholds
Why "below detection limit" is data
A detection limit says the instrument cannot distinguish the concentration from zero with confidence. It does not say the hour is missing: the air was clean, and the reported value (or a substituted one) is the best estimate. Treating those hours as gaps removes exactly the information that distinguishes a clean site from a moderately polluted one.
Why negative values are kept
Beta attenuation monitors and some gas analysers subtract a baseline; at low concentrations the noise straddles zero. The expected value of the noise is zero only if both sides are kept. Clipping to zero or deleting negatives makes the noise positive on average, which is a bias — and it is largest at clean sites, where most readings are near zero.
Why capture thresholds exist
A mean from part of the year inherits the seasonality of the part. For NO2, with a strong winter peak, a year missing its winter reads low. The EU's 90% rule (with allowances for calibration) makes annual statistics comparable between stations and years; below it, a value is at best indicative.
Provisional data, instrument changes and imputation
- Provisional data can later be ratified with changes — or deleted; recompute when the ratified set is published.
- Instrument changes mid-year can show up as a step rather than a gap; check the unit or method field by month.
- Validity 3 in EEA data (substituted value) should be kept but noted; the substituted value is usually half the detection limit.
- Daily files from the EEA carry their own capture statistics; use them rather than recomputing from hourly when available.
- Imputation is for modelling inputs, not for official statistics; never publish an imputed annual mean as a measured one.
Internal links
- How to download air quality station data in Python — the flags at load time
- Air pollutants and their units explained — why BAM monitors go negative
- How to analyse an air quality time series — capture before trends
- Air quality monitoring networks explained — which stations are clean
- Hazard raster nodata read as zero — the raster version of a silent gap
- Repair, reject or flag — choosing what to do with bad records
FAQ
Should I delete negative air quality values?
No. Keep them in means; they are instrument noise around a low true value. Clip only for display or index lookups.
Which EEA validity codes are valid?
Codes 1, 2 and 3. Drop −1 and −99.
What data capture is needed for an annual mean?
90% of hours for EU compliance statistics (allowing for maintenance); many analyses flag anything below 75%.
What does "below detection limit" mean?
The concentration was too low to measure precisely. It is a valid, clean-air hour, not a missing one.
How do I handle a station with a big gap?
Report the capture and the months covered; if you must estimate, scale by a nearby reference station and say so.
Does it matter when in the year the gaps are?
Yes. Pollutants are seasonal, so a gap in winter and a gap in summer bias the mean in opposite directions.