AQEval Python Package
A Python port of the R package AQEval for air quality time-series analysis.
Overview
AQEval provides tools for the routine detection, characterization, and quantification of discrete changes in air quality time-series, such as identifying the impacts of air quality policy interventions.
The main functions use signal isolation and break-point/segment (BP/S) methods to detect and quantify change events.
Methodology and R parity
This port reproduces the R package's methodology step for step (AQEval v0.6.12; Ropkins et al., JOSS 2026, 10.21105/joss.08839):
- Find possible points-of-change —
find_break_pointsis a faithful NumPy port ofstrucchange::breakpoints(y ~ 1, h): the Bai & Perron dynamic-programming segmentation of the intercept-only (mean-shift) model, with the number of breaks selected by BIC and break-date confidence intervals computed with Bai's (1997) asymptotic distribution. - Test them —
test_break_pointsfits an OLS model for every subset of the candidate breaks (single shared intercept, per-segment epoch-second regressors, exactly as R'saqe_fitBreakPointsModel); a model is valid only if every coefficient is significant at p < 0.05, and the valid model with the highest adjusted R² wins. - Quantify —
quant_break_pointsreports the fitted concentrations either side of each confirmed break;quant_break_segmentsports thesegmented-package machinery vendored by R AQEval (single Muggeo iteration,it.max = 1, no bootstrap) and grid-searches segmented fits started around the break confidence intervals, quantifying regions-of-change rather than instantaneous steps. The port follows R's arithmetic, not just its algebra: the same normal-equations solve, the same pivoted QR withlm's 1e-7 rank tolerance, and so the same candidate fits accepted and rejected. - Signal isolation —
isolate_contributionfits the R default GAMpollutant ~ te(wd,ws) + s(year.day) + s(day.hour)(plus optional background/extra terms). R usesmgcv; this port uses pygam (penalized P-splines with staged GCV lambda selection), the closest maintained pure-Python equivalent. Results agree with mgcv closely but not bit-for-bit (about ±2 µg/m³ on the hourly test fixture). This is the one deliberate deviation from R.
How closely it matches R
The port is checked against R three ways, all in the test suite, so none of them needs R to run:
- R AQEval's own regression fixtures (
inst/tinytest/): break locations and confidence intervals, quantified break rows, segment boundaries, date-range statistics and spectral output dimensions (tests/test_regression_r_fixtures.py). - A live R run on real AURN data
(
tests/test_regression_r_break_segments.py). R AQEval 0.6.11, with openair 3.1.0 and strucchange 1.6.0 under R 4.6.1, analysed 20 series: measured and isolated NO2 and NOx from four AURN sites, 2018–2025, averaged to 8 hours, 1 day and 7 days, athfrom 0.12 to 0.3. The series R analysed and everythingfindBreakPoints,testBreakPoints,quantBreakPointsandquantBreakSegmentsreturned are committed as fixtures, including a log of all 4,716 candidate fits inquantBreakSegments' grid searches. openair::timeAverageon the bundledaq.data(tests/test_regression_r_time_average.py).
What is identical to R, and what is only very close:
| Output | Agreement with R |
|---|---|
time_average: period starts; mean, median, min, max, sum, frequency |
Identical, to the last bit |
time_average: sd, vector-averaged wind direction |
Within a few units in the last place |
find_break_points: break rows and confidence intervals |
Identical (20 of 20 series) |
quant_break_segments: starting values of every candidate fit |
Identical, to the last bit |
quant_break_segments: which candidates fit, which are rejected and why, which one wins |
Identical (4,716 of 4,716 fits; 20 of 20 winners) |
quant_break_segments: segments table |
Identical (20 of 20 series) |
quant_break_segments: breakpoint estimates, fitted trend, standard errors |
About 9 significant figures (trend within 3×10⁻⁸ µg/m³) |
test_break_points: which break subsets are significant, and which is preferred |
Identical (20 of 20 series) |
quant_break_points: report (dates, concentrations, changes) |
Identical (20 of 20 series) |
quant_break_points: fitted trend |
Within 3×10⁻¹¹ µg/m³ |
spectral_frequency: spectral density (13,500 frequencies) |
r = 1.000000, max diff 2×10⁻¹⁵ |
isolate_contribution (3,600 hourly values) |
r = 0.992 (mgcv vs pygam) |
Things worth knowing behind that table:
- Least-squares results cannot be bit-identical.
segmentedsolves the normal equations, and their rounding depends on the linear-algebra library (BLAS) in use; R gives slightly different digits on different machines too. The decisions that matter — which candidate fits are kept and which wins — came out the same as R's in every case tested, on macOS (Accelerate) and on Linux (OpenBLAS, arm64 and x86-64), with pandas 2.3 and 3.0. - pandas 3. Up to 0.7.2 the break analysis assumed the
datecolumn was stored at nanosecond resolution. pandas 3 no longer defaults to that, anddownload_aurn_datareturns second-resolution dates under it. Two things went wrong. The break-point model behindtest_break_pointsandquant_break_pointslost its time trend (every row got the same regressor value), so a different subset of breaks, or none at all, was preferred. Andquant_break_segments' time variable came out a few units in the last place away from R's; its grid search starts exactly on data points, so that was enough to change the winning fit, and on the 20 series above it agreed with R on fewer than half. From 0.7.3 the results do not depend on the stored resolution (tested at s, ms, µs and ns) or on the pandas version. Anything run with 0.7.2 and pandas 3 is worth re-running.spectral_frequencyhad the same assumption, with an effect of about 5×10⁻¹⁰. - openair 3.1.
time_averagefollows openair 3.1: multiples of a fixed-length unit ("8 hour","2 day","7 day") are aligned to the Unix epoch, so 7-day periods start on Thursdays wherever the data begin, and"week"starts on Sundays. Earlier openair releases counted multi-day periods from the first day of the data, as this package did before 0.7.3. R AQEval's tinytest values were recorded that way (break rows 213/317/402 on 2-dayaq.data); with openair 3.1.0 R itself returns 214/317/405 for the same call. The tests check both. - Text is not exact unless parsed exactly. pandas' default CSV float
parser is off by one unit in the last place for roughly one value in
ten. When comparing with R at this level, read R-exported numbers with
pd.read_csv(..., float_precision="round_trip"); the bundledaq.datais loaded that way from 0.7.3 and now equals R's exactly. - No breaks. As in R,
quant_break_segments(data, pollutant, breaks=None)fits a single straight line, so the result offind_break_pointscan be passed straight in. Leavebreaksout to have the breaks found and tested for you.
Conventions carried over from R: break/segment tables hold
1-based row numbers into the input data frame, and report columns
use R's dotted names (date.low, c.delta, per.delta, seg.str,
...). As in R, break-point analysis is designed for time-averaged
series — resample hourly data first (e.g. time_average(data, "day"));
signal isolation runs at the native hourly resolution before
averaging.
Installation
# Install from PyPI
pip install aqeval
# Install in development mode
pip install -e .
# Or install dependencies only
pip install -r requirements.txt
Quick Start
from aqeval import (
load_aq_data,
isolate_contribution,
quant_break_points,
quant_break_segments,
spectral_frequency,
)
from aqeval.utilities import time_average
# Load sample data
data = load_aq_data()
# Resample to daily averages (openair::timeAverage semantics)
daily = time_average(data, avg_time="day")
# 1. Signal Isolation - Remove seasonal and weather effects
data["dswb_no2"] = isolate_contribution(
data,
"no2",
background="bg.no2"
)
# 2. Break-Point Analysis
result = quant_break_points(daily, "no2", h=0.3)
result["plot"].show() # Interactive Plotly plot
# 3. Break-Segment Analysis
result = quant_break_segments(daily, "no2", h=0.3)
result["plot"].show()
# 4. Spectral Frequency Analysis
result = spectral_frequency(data, "no2")
result["plot"].show()
Main Functions
Signal Processing
isolate_contribution(): Remove background, seasonal, and weather effects from pollutant time-series using GAM models (pygam; R uses mgcv).fit_near_site_model(): Gap-fill one site's series using GAM models built from nearby sites.
Break-Point Detection
find_break_points(): Detect candidate break-points with the strucchange method (Bai–Perron dynamic programming + BIC + Bai 1997 confidence intervals).test_break_points(): Test all break-point subsets; all-coefficients-significant models ranked by adjusted R².quant_break_points(): Quantify confirmed break-points with confidence intervals, report and plot.quant_break_segments(): Quantify regions-of-change about break-points via Muggeo-style segmented regression seeded from the break confidence intervals.
Analysis Tools
spectral_frequency(): Perform spectral frequency analysis to identify periodic patterns.calc_date_range_stat(): Calculate statistics for specific date ranges.calc_rolling_date_range_stat(): Calculate rolling statistics over moving windows.
Site Finding
find_near_sites(): Find nearby air quality monitoring sites by location.find_near_lat_lon(): General-purpose nearest location finder.
Data Access
load_aq_data(): Load the sample AQEval dataset.import_aq_meta(): Import metadata from UK air quality networks.download_aurn_data(): Download data from UK air quality networks.download_noaa_data(): Download meteorological data from NOAA.
Example Workflow
import pandas as pd
from aqeval import (
load_aq_data,
isolate_contribution,
quant_break_points,
)
from aqeval.utilities import time_average
# 1. Load and prepare data
data = load_aq_data()
# 2. Apply signal isolation (deseasonalization + deweathering + background)
data["isolated_no2"] = isolate_contribution(
data,
pollutant="no2",
background="bg.no2",
deseason=True,
deweather=True,
)
# 3. Resample to 14-day averages for smoother analysis
avg_14day = time_average(data, avg_time="14 day")
# 4. Detect and quantify break-points
result = quant_break_points(
avg_14day,
"isolated_no2",
h=0.1,
event={
"x": "2020-03-23",
"label": "COVID Lockdown",
"color": "grey",
}
)
# 5. View results
print(result["report"])
result["plot"].show()
Tutorial example: BDMA 2018-2023
examples/tutorial_bdma.py replicates the
AQEval R tutorial
end-to-end with this package:
from aqeval import (download_aurn_data, isolate_contribution,
time_average, find_break_points, quant_break_segments)
# 1. AURN data for the BDMA site (R: importAURN(site='bdma', year=2018:2023))
data = download_aurn_data("bdma", 2018, 2023, source="aurn")
# 2. De-weather and de-season NO2, with air temperature as an
# extra background term (fits no2 ~ s(air_temp)+te(wd,ws)+s(year.day)+s(day.hour))
data["deweatherdeseason"] = isolate_contribution(
data, "no2", deseason=True, deweather=True, background="air_temp")
# 3. Average to 8-hour resolution (openair::timeAverage equivalent)
data_8h = time_average(data, "8 hour")
# 4. Find and quantify the break segments (h=0.3 for speed; h=0.12 for
# a full-resolution 8-hour analysis)
break_points = find_break_points(data_8h, "deweatherdeseason", h=0.3)
quant = quant_break_segments(data_8h, "deweatherdeseason", breaks=break_points)
print(quant["report"])
Cross-checked against the identical workflow run in R (each side using its own GAM backend for the isolation step), the two implementations find the same change events:
| Quantity | R (mgcv) | Python (pygam) |
|---|---|---|
| Break 1 (lower, bpt, upper; 1-based 8h rows) | 2116, 2189, 2240 | 2116, 2189, 2239 |
| Break 2 | 4557, 4701, 4824 | 4557, 4701, 4825 |
| Change event 1 | 41.60 → 34.17 (−17.9%), Nov 2019–Mar 2020 | 41.54 → 34.28 (−17.5%), Dec 2019–Mar 2020 |
| Change event 2 | 37.91 → 33.16 (−12.5%), Apr–May 2022 | 37.82 → 33.22 (−12.2%), Apr–May 2022 |
The isolated hourly series correlate at r = 0.9967 and the fitted segment trends at r = 0.9994 (mean difference 0.06 µg/m³).
Note on performance: the break-point search is a vectorised
implementation of the O(n²) Bai–Perron dynamic program. On the 8-hour
BDMA series (~6,000 rows) it runs in well under a second at any h
down to 0.05 — including the h=0.12 setting recommended for 8-hour
data (R's strucchange takes minutes on the same problem). Memory still
grows as O(n²) (~300 MB at n≈6,000), so run the analysis on
time-averaged data (8-hour/daily), not raw multi-year hourly series.
Visualization
All visualization functions return interactive Plotly figures that can be:
- Displayed in Jupyter notebooks
- Saved as HTML files:
fig.write_html("plot.html") - Exported as static images:
fig.write_image("plot.png")
Figures Module
The figures module provides a menu of ready-to-use plotting functions.
Each function accepts a DataFrame (with a date column and one or more
pollutant columns), runs the necessary analysis internally, and returns a
single plotly.graph_objects.Figure.
from aqeval.figures import (
plot_break_points,
plot_segments_raw,
plot_segments_isolated,
plot_raw_vs_isolated_trends,
plot_raw_vs_isolated_timeseries,
plot_residuals_timeseries,
plot_residual_histogram,
plot_spectral,
plot_rolling_statistics,
plot_signal_isolation,
plot_signal_isolation_trend_comparison,
)
Data preparation
Most figure functions expect hourly data so that signal-isolation
(deseasoning / deweathering) can operate at full resolution. Functions
that only need daily data (e.g. plot_rolling_statistics) will aggregate
internally.
You can use the bundled sample dataset or download live data from a UK air-quality network:
# Option 1 — bundled sample data
from aqeval import load_aq_data
data = load_aq_data()
# Option 2 — download AURN (or SAQN / AQE / WAQN / NI) data
from aqeval.data import download_aurn_data
data = download_aurn_data("LED6", 2018, 2025, source="aurn")
data.columns = data.columns.str.lower() # normalise column names
Then set the analysis parameters:
POLLUTANT = "no2"
H = 0.3 # minimum segment size (fraction of series length)
DESEASON = True # remove seasonal patterns
DEWEATHER = True # remove weather effects (needs ws / wd columns)
ROLLING_WINDOW = 30 # rolling-mean window in days
1. plot_rolling_statistics — Rolling mean
Daily scatter plot overlaid with a rolling mean line — a quick way to visualise the overall trend and variability before running any formal analysis.
fig = plot_rolling_statistics(data, POLLUTANT, rolling_window=ROLLING_WINDOW)
fig.show()
| Parameter | Default | Description |
|---|---|---|
rolling_window |
30 |
Rolling-mean window in days |
title |
auto | Custom plot title |
height |
400 |
Figure height in pixels |
2. plot_spectral — Spectral frequency analysis
Spectral power-density plot identifying periodic components in the data (e.g. 24-hour diurnal cycle, 7-day weekly pattern, 365-day annual cycle).
fig = plot_spectral(data, POLLUTANT)
fig.show()
3. plot_break_points — Break-point detection & quantification
Detects structural breaks in the time-series using the strucchange method (Bai–Perron dynamic programming with BIC break-count selection) and overlays the fitted break-point model on the observed daily means. Vertical dashed lines indicate detected break-point locations.
fig = plot_break_points(data, POLLUTANT, h=H)
fig.show()
| Parameter | Default | Description |
|---|---|---|
h |
0.15 |
Minimum segment size as a fraction of data length |
title |
auto | Custom plot title |
height |
500 |
Figure height in pixels |
4. plot_segments_raw — Segmented trend analysis (raw data)
Fits segmented linear regression to the raw (observed) daily data, showing the slope within each segment. Dotted lines indicate the confidence interval around each detected break-point.
fig = plot_segments_raw(data, POLLUTANT, h=H)
fig.show()
| Parameter | Default | Description |
|---|---|---|
h |
0.15 |
Minimum segment size fraction |
show_break_ci |
True |
Show break-point CI lines |
5. plot_segments_isolated — Segmented trend analysis (deseasoned data)
Same segmented regression, but applied to the deseasoned data.
Seasonal patterns are removed first via isolate_contribution, so the
trends here reflect underlying emission changes rather than
weather/seasonal artefacts.
fig = plot_segments_isolated(data, POLLUTANT, h=H, deseason=DESEASON, deweather=DEWEATHER)
fig.show()
| Parameter | Default | Description |
|---|---|---|
deseason |
True |
Remove seasonal patterns |
deweather |
False |
Remove weather effects (needs ws/wd columns) |
6. plot_raw_vs_isolated_trends — Side-by-side trend comparison
Two-row subplot comparing segmented trends side by side:
- Top: raw data with trend segments and break-point lines
- Bottom: isolated (deseasoned) data with its own trends and break-points
Differences between the two panels reveal where seasonal patterns may mask or create apparent changes.
fig = plot_raw_vs_isolated_trends(data, POLLUTANT, h=H, deweather=DEWEATHER)
fig.show()
7. plot_raw_vs_isolated_timeseries — Overlaid time-series
Overlays the raw and isolated daily mean time-series on a single axis. The gap between the two lines represents the seasonal/weather component that was removed.
fig = plot_raw_vs_isolated_timeseries(data, POLLUTANT, deweather=DEWEATHER)
fig.show()
8. plot_residuals_timeseries — Residuals over time
Colour-coded bar chart of daily residuals (raw − isolated):
- Orange bars: seasonal/weather factors increased concentrations that day
- Blue bars: seasonal/weather factors decreased concentrations
The black line is the rolling mean — if it drifts over time, seasonal patterns may be changing.
fig = plot_residuals_timeseries(data, POLLUTANT, deweather=DEWEATHER, rolling_window=ROLLING_WINDOW)
fig.show()
| Parameter | Default | Description |
|---|---|---|
rolling_window |
30 |
Rolling-mean window in days |
9. plot_residual_histogram — Residual distribution
Distribution of the residuals (raw − isolated). A symmetric histogram centred near zero indicates the isolation removed a balanced seasonal component. The red dotted line marks the mean.
fig = plot_residual_histogram(data, POLLUTANT, deweather=DEWEATHER, nbins=50)
fig.show()
10. plot_signal_isolation — Signal isolation (two-panel)
Two-panel view of the signal isolation result:
- Top: original daily data (grey scatter) with a rolling mean (blue line)
- Bottom: isolated data (green scatter) with its rolling mean (green line)
Shows at a glance how much seasonal structure has been removed.
fig = plot_signal_isolation(data, POLLUTANT, deseason=DESEASON, deweather=DEWEATHER, rolling_window=ROLLING_WINDOW)
fig.show()
11. plot_signal_isolation_trend_comparison — Isolation trend comparison
The full signal-isolation workflow in one figure: break-point detection and segmented regression on both the original and isolated datasets, displayed in a two-row subplot.
How to interpret the comparison:
- Same break-points in both panels → likely a real emission change
- Break-point only in raw data → may be a weather/seasonal artefact
- Break-point only in isolated data → a real change that was masked by weather
- Different trend magnitudes → weather amplified or dampened the true change
fig = plot_signal_isolation_trend_comparison(data, POLLUTANT, h=H, deseason=DESEASON, deweather=DEWEATHER)
fig.show()
Common parameters
All figure functions accept these optional keyword arguments:
| Parameter | Default | Description |
|---|---|---|
title |
auto-generated | Custom plot title string |
height |
varies (400–700) | Figure height in pixels |
Dependencies
- numpy >= 1.24.0
- pandas >= 2.2.0
- scipy >= 1.10.0
- statsmodels >= 0.14.0
- pygam >= 0.9.1
- plotly >= 5.0.0
- requests >= 2.25.0
- rdata >= 0.8.0
- python-dateutil >= 2.8.0
References
Ropkins, K. and Tate, J. (2021). Early assessment of the impact of the COVID-19 lockdown on air quality in the United Kingdom. Science of The Total Environment, 142374. DOI: 10.1016/j.scitotenv.2020.142374
Ropkins, K., Walker, A., et al. Change Detection of Air Quality Time-Series Using the R Package AQEval. Available at SSRN 4267722.
Ropkins, K., Walker, A., Philips, I., Rushton, C. E., Clark, T. and Tate, J. E. (2026). AQEval: R code for the analysis of discrete change in air quality time-series. Journal of Open Source Software, 11(118), 8839. DOI: 10.21105/joss.08839
Zeileis, A., Kleiber, C., Krämer, W. and Hornik, K. (2003). Testing and dating of structural changes in practice. Computational Statistics & Data Analysis, 44, 109–123.
Muggeo, V. M. (2003). Estimating regression models with unknown break-points. Statistics in Medicine, 22(19), 3055–3071.
License
GPL (>= 3)
Authors
- Original R Package: Karl Ropkins, Anthony Walker, James Tate
- Python Port: Chris Rushton
Metadata
Release files for aqeval 0.7.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| aqeval-0.7.3.tar.gz | 557.8 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aqeval-0.7.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.1 MB
Release files / aqeval-0.7.3.tar.gz
| Download URL | aqeval-0.7.3.tar.gz |
|---|---|
| Size | 557.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
367c0acfbca77f14b87339071b6109b27b6fb0806156a1224535c6b32cef510e
|
|
BLAKE2b-256 checksum How to use checksums |
6805b07cb6f7c999ff4067543a2fb2d1dd0c430c68aebc704f932e0503322ae8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logRelease files / aqeval-0.7.3-py3-none-any.whl
| Download URL | aqeval-0.7.3-py3-none-any.whl |
|---|---|
| Size | 537.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e2b94147a84aa99df219607c60e11146c97aac37aebd3cf6a9cbbcefc8df8020
|
|
BLAKE2b-256 checksum How to use checksums |
d6d255bc8cc958ed8e0bf41e9edaa155437099316b216b7fd3e0abbeea9e9795
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logSecurity for aqeval 0.7.3
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for aqeval-0.7.3.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.