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 |
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 |
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 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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.64Helpers
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.