PythonAQ v1.0.0
GitHub

Guide

Every public function, with a short example and the output that example actually produces. The page is generated by running the code against real AURN data for Leeds Centre, 2022–2025, so it cannot drift from the package.

pip install -e '.[calendar]'

52 functions documented. Figures are interactive — hover, zoom, and drag. They restyle with the theme.

Getting data

import_aq_meta

Site metadata for a UK monitoring network.

from PythonAQ import import_aq_meta

metadata = import_aq_meta('aurn')
site_id site_name location_type latitude longitude local_authority
LEED Leeds Centre Urban Background 53.80 -1.55 Leeds
LED6 Leeds Headingley Kerbside Urban Traffic 53.82 -1.58 Leeds
MAN3 Manchester Piccadilly Urban Background 53.48 -2.24 Manchester
3 row(s)

download_aurn_data

Hourly observations for one site across a range of years.

from PythonAQ import download_aurn_data

df = download_aurn_data('LEED', 2022, 2025, 'aurn')
date_time NO2 O3 PM10 ws wd
2022-01-01 00:00:00 27.52 37.65 23.52 3.5 200.4
2022-01-01 01:00:00 25.20 33.53 18.45 2.8 179.1
2022-01-01 02:00:00 23.94 30.28 15.50 3.6 182.9
2022-01-01 03:00:00 12.45 41.81 8.70 3.7 179.1
2022-01-01 04:00:00 19.22 30.28 11.30 4.3 199.7
first 5 of 35064 rows

get_r_data is the low-level RData reader this builds on, if you need to fetch an R object directly.

download_noaa_data

Meteorology from NOAA's Integrated Surface Database.

from PythonAQ import download_noaa_data

met = download_noaa_data('033440-99999', 2022, 2025)
date                 air_temp    ws     wd  dew_point  pressure    rh
2022-01-01 00:00:00      11.2   2.6  210.0        9.2    1015.2  87.5
2022-01-01 01:00:00      11.8   4.1  180.0        9.5    1014.3  85.8
2022-01-01 02:00:00      12.4   7.2  200.0       10.4    1013.1  87.6

parse_noaa_data does the decoding and is called for you. It clears the ISD missing-value sentinels before applying the ×10 scaling, which is the step most hand-rolled parsers get wrong.

map_sites

Locate monitoring sites on a muted OpenStreetMap base.

from PythonAQ import map_sites

map_sites(metadata, sites=['LEED', 'LED6'])

The base map follows the page theme: carto-positron in light, carto-darkmatter in dark. Both are OpenStreetMap data rendered muted, so the markers stay readable.

Directional analysis

polar_plot

Concentration by wind speed and direction, GAM-smoothed.

from PythonAQ import polar_plot

polar_plot(df, conc_col='NO2')

Peaks at the centre, at low wind speeds, are the signature of nearby ground-level sources accumulating under calm conditions. Use render='contour' for bands, or 'tile' for the older one-polygon-per-bin look.

polar_diff

What changed between two periods, by wind sector.

from PythonAQ import polar_diff

polar_diff(before, after, conc_col='NO2')

Leeds Centre, 2024-25 against 2022-23. The scale is forced to be symmetric about zero, so white always means no change. Blank sectors are those one period cannot support - unmeasured rather than unchanged.

Maps

polar_map

Polar plots at their sites' positions.

from PythonAQ import import_aq_meta, polar_map

meta = import_aq_meta('aurn')[['site_id', 'latitude', 'longitude']]
df = df.merge(meta.drop_duplicates('site_id'), on='site_id')

polar_map(df, 'NO2')

A polar plot says which direction a source lies in; the map says what is in that direction. Bradford Mayo Avenue is a roadside site and looks like one. The radial axis is wind speed, not distance: marker size on the ground is set by radius_km.

wind_rose_map

Wind roses at their sites.

from PythonAQ import wind_rose_map

wind_rose_map(df)

All three share the West Yorkshire prevailing westerly, which is the point of drawing them together: a site that disagreed would be worth investigating.

freq_map

How often the wind blows each way, per site.

from PythonAQ import freq_map

freq_map(df, map_style='carto-darkmatter')

Shown on the dark basemap; every map takes map_style='carto-darkmatter'. Worth putting beside polar_map: a striking feature in a sector the wind rarely comes from is not a finding.

pollutant_rose_map

A pollution rose per site.

from PythonAQ import pollutant_rose_map

pollutant_rose_map(df, 'NO2')

Petal length is how often the wind came from that sector; the colours divide it by concentration band.

percentile_rose_map

A percentile of concentration by wind direction, per site.

from PythonAQ import percentile_rose_map

percentile_rose_map(df, 'NO2', percentile=95)

A high percentile answers a different question from a mean: a sector whose 95th percentile is high but whose mean is not has occasional episodes rather than a steady contribution.

annulus_map

Wind direction around the ring, time through it, per site.

from PythonAQ import annulus_map

annulus_map(df, 'NO2', period='hour')

The inner edge is the first period and the outer edge the last, so a source active only at night shows as a bright band at both edges of an hour annulus rather than one arc.

Directional analysis

polar_annulus

Wind direction around the ring, a temporal variable through it.

from PythonAQ import polar_annulus

fig, summary = polar_annulus(df, 'NO2', period='hour')

For sources that only appear at certain hours or months, which a plain polar plot averages away.

wind_rose

Wind speed and direction distribution.

from PythonAQ import wind_rose

fig, summary = wind_rose(df, group_by='none')

pollutant_rose

Concentration by wind direction, banded by concentration.

from PythonAQ import pollutant_rose

fig, summary = pollutant_rose(df, pollutant='NO2')

percentile_rose

How the distribution, not just the mean, varies with direction.

from PythonAQ import percentile_rose

fig, summary = percentile_rose(df, 'NO2', percentile=[25, 50, 75, 90, 95])

percentile_rose (CPF)

Conditional probability function: how often the wind from each sector brings a high value.

# statistic='cpf' answers a different question from the mean:
# when the wind comes from here, how often is it bad?
fig, summary = percentile_rose(df, 'NO2', statistic='cpf', percentile=95)

Finds intermittent sources a directional mean misses: a sector that is usually clean but occasionally very dirty has an unremarkable mean and a high CPF.

polar_frequency_plot

How often each wind speed and direction combination occurs.

from PythonAQ import polar_frequency_plot

polar_frequency_plot(df, separate_by_year=False)

Worth reading alongside polar_plot: a striking feature in a sector with almost no data is not a finding.

polar_cluster

K-means clustering in polar coordinates.

from PythonAQ import polar_cluster

polar_cluster(df, feature_cols=['NO2', 'PM10', 'O3'], n_clusters=6)

Time series and trends

time_variation

The four-panel temporal summary, with bootstrap confidence intervals.

from PythonAQ import time_variation

fig, summary = time_variation(df, ['NO2', 'O3'], random_state=42)

NO₂ at Leeds Centre peaks at 07:00, runs about 3 µg/m³ lower at weekends and is roughly twice as high in winter. O₃ moves the opposite way on all three, which is the expected titration.

theil_sen_plot

Non-parametric trend with confidence intervals and a Mann-Kendall test.

from PythonAQ import theil_sen_plot

theil_sen_plot(df, pollutant_col='NO2', agg_freq='ME')

smooth_trend_plot

A GAM smooth rather than a straight line, for non-monotonic trends.

from PythonAQ import smooth_trend_plot

smooth_trend_plot(df, pollutant_col='NO2', avg_freq='MS')

time_plot

Time series for one or more pollutants.

from PythonAQ import time_plot

time_plot(df, columns_to_plot=['NO2', 'PM10', 'O3'],
          averaging_period='ME', group_data=True)

trend_level

A heat map over two time dimensions, split into panels by a third.

from PythonAQ import trend_level

fig, summary = trend_level(df, 'NO2', x='month', y='hour', type='year')

time_prop

Stacked bars over time, split by a category's contribution.

from PythonAQ import time_prop

fig, summary = time_prop(df, 'NO2', proportion='wd', avg_time='month')

Segments are each category's share of the period, so the bars total the period statistic rather than summing category means.

calendar

Daily values as a calendar heat map.

from PythonAQ import calendar

calendar(df, value_column='PM10')

Needs the optional calendar extra: pip install 'PythonAQ[calendar]'.

Distributions and relationships

summary_plot

Time series, data-capture rug and histogram for every pollutant.

from PythonAQ import summary_plot

fig, summary = summary_plot(df[['date_time', 'NO2', 'PM10', 'O3']])
Column Capture Rate (%) Missing Points Min Max Mean Median 95th Percentile
NO2 96.50 1227 0.66 121.49 19.79 15.97 47.69
O3 96.98 1059 -0.45 169.24 49.62 51.39 84.30
PM10 98.38 569 0.28 677.40 13.52 10.68 32.72
the returned summary

The first thing to run on an unfamiliar dataset. The figure is a tall stack of panels, so the returned table is shown here instead.

dist_plot

The shape of a distribution, not just its mean.

from PythonAQ import dist_plot

dist_plot(df, ['NO2', 'PM2.5'], kind='density')

Concentrations are bounded at zero and right-skewed, so a mean sits well above the mode. kind='cdf' for percentiles, log_x=True to see bulk and tail at once.

linear_relation

How the relationship between two pollutants moves over time.

from PythonAQ import linear_relation

fig, summary = linear_relation(df, x='NOx', y='NO2', period='month')

The slope describes the source rather than the amount, so a change in it is a change in what is emitting. The band is the standard error of the slope within each month.

scatter_plot

Two variables against each other, with optional fits.

from PythonAQ import scatter_plot

fig, fit = scatter_plot(df, 'NOXasNO2', 'NO2', linear=True, smooth=True)

Gives NO₂ = 0.410 × NOₓ + 8.31, R² = 0.740. The curvature reflects NO₂ making up a smaller share of NOₓ as total NOₓ rises, since available oxidant is limited.

corr_plot

Correlation matrix, ordered by hierarchical clustering.

from PythonAQ import corr_plot

fig, corr = corr_plot(df, ['NO2', 'NOXasNO2', 'O3', 'PM10', 'PM2.5'])

Strongest pair is PM10/PM2.5 at r = 0.943; strongest anticorrelation is O₃ against NO₂ at r = -0.676, the titration relationship.

Model evaluation

conditional_quantile

Where a model fails, not just how badly.

from PythonAQ import conditional_quantile

fig, summary = conditional_quantile(df, obs='observed', mod='model')

The median peeling away from the 1:1 line at the top of the range is a model that is fine on average and wrong where it matters. Here the stand-in model is deliberately damped.

taylor_diagram

Standard deviation, correlation and centred RMS error on one plot.

from PythonAQ import taylor_diagram

fig, summary = taylor_diagram(df, obs='observed',
                              mod=['model_a', 'model_b'])

Radius is the standard deviation, angle is the correlation, and the distance from the star is the centred RMS error. Closer to the star is better. Bias is reported separately, since the centred error removes each series' mean.

conditional_eval

Why a model fails, by breaking the error down by other variables.

from PythonAQ import conditional_eval

fig, summary = conditional_eval(df, obs='observed', mod='model',
                                variables=['ws', 'temp'])

The top panel is the error; the rest are conditions plotted as anomalies from their own mean. A panel that trends with the error names a suspect. Association, not proof.

run_regression

A regression coefficient as a series rather than a single number.

from PythonAQ import run_regression

fig, summary = run_regression(df, y='NO2', x=['NOx', 'ws'],
                              window=168, step=24)

One fit per sliding window, so a relationship that held for a year and then moved is visible. Neighbouring windows overlap and are not independent: read the level, not the wiggles.

mod_stats

The standard model evaluation statistics.

from PythonAQ import mod_stats

stats = mod_stats(df, mod='model', obs='observed')
n FAC2 MB MGE NMB NMGE RMSE r P COE IOA
33813 0.921 0.082 4.202 0.004 0.212 5.429 1.0 0.0 0.598 0.799
1 row(s)

COE is 1 for a perfect model, 0 when no better than the observed mean. IOA spans −1 to +1. FAC2 is the fraction within a factor of two. Formulas follow the openair R source.

aq_stats

Annual summaries with exceedance counts against UK objectives.

from PythonAQ import aq_stats

summary = aq_stats(df, 'NO2', data_thresh=75)
year data_capture mean median percentile.95 max_daily days_hourly_gt_200
2022 97.6 22.2 18.3 50.5 66.5 0
2023 95.7 19.1 15.4 47.0 52.6 0
2024 93.7 19.2 15.8 45.6 49.0 0
2025 99.0 18.6 14.4 46.8 61.1 0
4 row(s)

Conditioning

type= conditioning

Nearly every plotting function splits into panels with type.

# one panel per season, sharing a colour scale and axis limits
polar_plot(df, conc_col='NO2', type='season', ws_limit=12)

Accepts anything cut_data understands: 'year', 'month', 'season', 'weekday', 'weekend', 'hour', 'daylight', 'wd', or a numeric column split into quantiles. Panels share one colour scale and one set of axis limits, so they can be compared.

Data utilities

time_average

Average over a period, honouring a data-capture threshold.

from PythonAQ import time_average

daily = time_average(df, avg_time='day', data_thresh=75, interval='hour')
date_time NO2 PM10 ws wd
2022-01-01 12.50 16.75 3.05 181.77
2022-01-02 9.87 9.52 4.81 242.80
2022-01-03 10.53 12.86 5.96 261.86
2022-01-04 16.57 9.69 5.16 316.25
2022-01-05 21.07 16.86 4.82 315.03
first 5 of 1461 rows

Wind direction is averaged as a vector: winds at 350° and 10° give 0°, not the 180° a naive mean produces. Give interval when rows may be absent rather than NaN — capture is otherwise measured against an inferred time base, which is wrong exactly when data is missing.

select_by_date

Subset by date components; criteria combine with AND.

from PythonAQ import select_by_date

summer_weekends = select_by_date(df, season='summer', day=['Sat', 'Sun'])
select_by_date(df, year=2024)              ->  8,784 rows
select_by_date(df, season='summer')        ->  8,832 rows
select_by_date(df, day=['Sat','Sun'])      -> 10,032 rows
select_by_date(df, month='June', hour=8)   ->    120 rows

rolling_mean

Rolling mean with a data-capture threshold.

from PythonAQ import rolling_mean

result = rolling_mean(df, 'O3', width=8, data_thresh=75)
peak running 8-hour O3 : 145.2 ug/m3
hours above 100 ug/m3  : 289

The 8-point default matches the running 8-hour mean used for ozone in most air quality standards.

the smoothers

Four ways to separate signal from noise, each with a different notion of signal.

from PythonAQ import (gaussian_smooth, kz_filter,
                      rolling_quantile, whittaker_smooth)

df = rolling_quantile(df, 'NO2', width=24, quantile=0.5)
df = kz_filter(df, 'NO2', width=24, iterations=3)
df = whittaker_smooth(df, 'NO2', lam=1e6)
df = gaussian_smooth(df, 'NO2', sigma=12)
date_time NO2 median24 kz whittaker gaussian
2022-01-01 06:00:00 11.67 11.17 11.99 8.85 12.25
2022-01-31 08:00:00 15.30 12.41 15.80 17.83 15.73
2022-03-02 12:00:00 29.43 28.59 28.15 31.16 29.12
2022-04-03 02:00:00 20.06 18.20 20.40 17.54 20.38
2022-05-03 04:00:00 8.72 11.59 17.61 21.12 18.20
2022-06-02 08:00:00 26.61 26.22 23.61 16.93 23.28
first 6 of 47 rows

kz_filter and whittaker_smooth bridge gaps; rolling_quantile and gaussian_smooth leave them. A rolling median is unmoved by a single extreme hour, where a mean is not.

cut_data

Add a conditioning column for splitting data.

from PythonAQ import cut_data

tagged = cut_data(df, type='season')
season count mean median
spring (MAM) 8753 18.8 15.4
summer (JJA) 8678 13.5 11.8
autumn (SON) 8575 22.1 19.3
winter (DJF) 7831 25.3 21.9
mean NO2 by season

calc_percentile

Percentiles of a pollutant over a period.

from PythonAQ import calc_percentile

result = calc_percentile(df, 'NO2', percentile=[25, 50, 75, 95],
                         avg_time='month')
date_time percentile.25 percentile.50 percentile.75 percentile.95
2022-01-01 14.94 25.85 37.16 60.66
2022-02-01 13.17 20.15 29.63 49.92
2022-03-01 16.69 27.02 41.11 63.30
2022-04-01 10.17 14.83 22.28 37.79
first 4 of 48 rows

date_pad

Pad a series onto a complete, regular time base.

from PythonAQ import date_pad

padded = date_pad(df, interval='hour')
input  : 24 rows, every other hour present
padded : 47 rows, gaps filled with NaN

Why it matters:
  time_average(gappy, 'day', data_thresh=75)                  -> kept  (50% capture, wrongly kept)
  time_average(gappy, 'day', data_thresh=75, interval='hour') -> blanked

Rows that are simply absent rather than NaN make an hourly series look like a complete two-hourly one, so a 75% capture threshold passes on 50% of the data. Stating the interval removes the guess.

split_by_date

Split a series at given dates.

from PythonAQ import split_by_date

result = split_by_date(df, '2024-01-01', labels=['2022-23', '2024-25'])
split_by count mean
2022-23 16936 20.7
2024-25 16901 18.9
2 row(s)

select_running

Find runs of consecutive values above a threshold.

from PythonAQ import select_running

episodes = select_running(df, 'NO2', run_length=8, mode='filter')
532 hours fall in runs of 8+ consecutive hours
above the 95th percentile (47.7 ug/m3)

mean NO2 during those runs : 60.9 ug/m3
mean NO2 overall           : 19.8 ug/m3

Episodes are defined by persistence, not by any single high hour. A gap ends a run rather than silently bridging it.

bin_data

Bin one variable against another, with bootstrap intervals.

from PythonAQ import bin_data

result = bin_data(df, x='ws', y='NO2', bins=12)
ws n mean lower upper
0.62 1538 30.54 29.71 31.21
1.85 8749 24.54 24.25 24.87
3.08 10584 19.18 18.92 19.47
4.32 6242 16.73 16.46 17.01
5.55 3472 15.16 14.85 15.45
6.78 1742 14.64 14.21 15.08
first 6 of 12 rows

deseason_data

Remove a seasonal cycle by decomposition.

from PythonAQ import deseason_data, get_period

ds = deseason_data(df, pollutant_column='NO2', interval='7D',
                   period=get_period('7D'))
get_period('7D')        = 52 periods per year

raw weekly NO2 sd        : 7.38
deseasoned weekly NO2 sd : 4.64

Helpers

e_sat and rh

Saturation vapour pressure and relative humidity.

from PythonAQ import e_sat, rh

e_sat(20.0)     # hPa
rh(20.0, 10.0)  # air temp, dew point -> %
e_sat(20.0)      = 23.34 hPa
rh(20.0, 10.0)   = 52.5 %
rh(15.0, 15.0)   = 100.0 %  (saturated)

solar_elevation and is_daylight

Where the sun actually is, for splitting day from night.

from PythonAQ import cut_data, is_daylight, solar_elevation

solar_elevation(df['date_time'], latitude=53.80, longitude=-1.55)
cut_data(df, type='daylight', latitude=53.80, longitude=-1.55)
Leeds Centre, 53.8008 N 1.5491 W

2022-06-21 04:00 UTC   elevation   +1.9 deg   daylight
2022-06-21 12:00 UTC   elevation  +59.6 deg   daylight
2022-12-21 04:00 UTC   elevation  -36.9 deg   night
2022-12-21 12:00 UTC   elevation  +12.8 deg   daylight

hours a fixed 07:00-19:00 window mislabels: 1,102 of 8,760 (12.6%)

A fixed clock window mislabels five hours a day at this latitude in midsummer, and errs the other way in midwinter, so it does not average out of a seasonal comparison. cut_data(type='daylight') warns if you omit the coordinates.

quick_text

Format pollutant names and units for display.

from PythonAQ import quick_text

quick_text('no2')            # 'NO<sub>2</sub>'
quick_text('PM2.5 (ug/m3)')  # 'PM<sub>2.5</sub> (µg m<sup>-3</sup>)'
no2                  -> NO<sub>2</sub>
pm2.5                -> PM<sub>2.5</sub>
NOXasNO2             -> NO<sub>x</sub> as NO<sub>2</sub>
PM10 (ug/m3)         -> PM<sub>10</sub> (µg m<sup>-3</sup>)
Leeds Centre         -> Leeds Centre

Applied automatically to plot titles, axis labels and colourbars. Matching is on whole tokens, so ordinary prose like “Nottingham” or “concentration” passes through untouched.