Skip to main content

PythonAQ

Tests Python Licence

Air quality data analysis and visualisation for Python, following the R openair package. Download data from the UK monitoring networks and NOAA, average and condition it properly, and produce interactive Plotly figures.

Bivariate polar plot of NO2 at Leeds Centre

Every figure in this manual is generated by examples/build_readme_figures.py from real AURN data for Leeds Centre, 2022–2025 — so what you see is what the package actually renders.


Contents


Installation

git clone https://github.com/chris-r-uol/PythonAQ.git
cd PythonAQ
pip install -e .

The package is then importable from anywhere:

from PythonAQ import import_aq_meta, wind_rose

Optional extras

Some features carry dependencies deliberately kept out of the core install:

pip install -e '.[calendar]'  # the calendar plot
pip install -e '.[app]'       # the Streamlit demo application
pip install -e '.[dev]'       # test and lint tooling
pip install -e '.[docs]'      # static image export, for rebuilding this manual

A caveat on the calendar extra. It needs plotly-calplot, which pins numpy<2 and plotly<6 and does not work on pandas 3. Installing it will constrain the rest of your environment, which is why calendar is the only function unavailable from a plain pip install -e . — and why it raises a helpful ImportError rather than failing at import time.

Dependencies

Python 3.9+, with numpy, pandas, scipy, plotly, requests, rdata, scikit-learn, statsmodels, pymannkendall and pygam.


Quick start

from PythonAQ import download_aurn_data, polar_plot, time_variation

# Four years of hourly data for Leeds Centre
df = download_aurn_data('LEED', 2022, 2025, 'aurn')

polar_plot(df, conc_col='NO2').show()
fig, summary = time_variation(df, 'NO2')
fig.show()

Most plotting functions return a Plotly Figure. Several also return a DataFrame of the values behind the plot, so you can check or reuse the numbers rather than reading them off a chart.


Conditioning with type

Nearly every plotting function takes type, which splits the data and draws one panel per level. openair introduces this in chapter 2, before any plotting chapter, because it applies to all of them.

polar_plot(df, conc_col='NO2', type='season')

Polar plot conditioned by season

NO₂ at Leeds Centre is worst in winter and cleanest in summer, and the southerly arm is present year-round but strongest in the colder months.

type accepts anything cut_data understands:

type Panels
'year', 'month', 'season' one per year, month or season
'weekday', 'weekend' one per day, or weekday against weekend
'hour', 'daylight' one per hour, or daylight against night
'wd' one per compass sector
any numeric column one per quantile, n_levels of them

Alongside it: ncols for panels per row, hemisphere for season definitions, n_levels for quantile counts, and panel_height.

Panels share one colour scale and one set of axis limits, so they can actually be compared — otherwise the same colour, or the same radius, would mean different things in different panels. Any other argument is passed to every panel, so a data-driven limit can be fixed across them explicitly:

# Without ws_limit each season sets its own radial extent from its own
# 99th percentile, and the rings stop being comparable.
polar_plot(df, conc_col='NO2', type='season', ws_limit=12)

Conditioning never changes what a function returns: polar_plot gives a figure with or without type, and wind_rose gives a (figure, DataFrame) pair either way, with the summary gaining a column naming the level.


Getting data

import_aq_meta

Site metadata for a UK monitoring network.

from PythonAQ import import_aq_meta

metadata = import_aq_meta('aurn')
  • source (str): 'aurn', 'saqn', 'aqe', 'waqn' or 'ni'.

Returns a DataFrame with site_id, site_name, location_type, latitude, longitude, zone, local_authority and operating dates.

download_aurn_data

Hourly observations for one site across a range of years.

from PythonAQ import download_aurn_data

df = download_aurn_data(site='LEED', start_year=2022, end_year=2025, source='aurn')
  • site (str), start_year (int), end_year (int), source (str).

Returns a combined DataFrame, one row per hour, with a date_time column and whichever pollutants the site measures.

download_noaa_data and parse_noaa_data

Meteorology from NOAA's Integrated Surface Database.

from PythonAQ import download_noaa_data

met = download_noaa_data(station_code='033440-99999', year_start=2022, year_end=2025)

parse_noaa_data does the decoding and is called for you by download_noaa_data; call it directly if you have raw ISD CSVs. It splits the packed comma-separated fields, clears the documented missing-value sentinels before applying the ×10 scaling, resamples to hourly means, and derives relative humidity.

Returns air_temp (°C), ws (m/s), wd (degrees), dew_point (°C), atmospheric_pressure (hPa) and relative_humidity (%).

map_sites

Locate monitoring sites.

Monitoring site locations

from PythonAQ import map_sites

map_sites(metadata, sites=['LEED', 'LED6']).show()
  • map_style (str): base tiles. 'carto-positron' (default) and 'carto-darkmatter' are muted OpenStreetMap-derived styles, light and dark, which keep the markers legible; 'open-street-map' is the full-colour standard style, which competes with the data. None need an access token.
  • zoom (int), marker_colour (str).

Directional analysis

How concentrations vary with wind speed and direction — the most direct way to ask where pollution is coming from.

polar_plot

Concentration as a function of both wind speed (radius) and direction, smoothed with a tensor-product GAM.

Bivariate polar plot

from PythonAQ import polar_plot

polar_plot(df, conc_col='NO2').show()

Reading this one: NO₂ at Leeds Centre — an urban background site — peaks at the centre of the plot, at low wind speeds, the signature of nearby ground-level sources accumulating under calm conditions rather than of a single distant stack. Concentrations stay elevated towards the south and south-west, and fall away to the north-east at higher wind speeds where cleaner air dilutes them.

Parameters

  • conc_col (str): concentration column.
  • render (str): 'raster' (default) draws one continuous surface, 'contour' filled bands, 'tile' the original one-polygon-per-bin rendering.
  • resolution (int): grid points per axis, default 300. Higher is smoother and slower.
  • ws_limit (str or float): radial extent. 'auto' (default) uses the 99th percentile of wind speed, 'max' the full observed range, or give a number.
  • min_count (int): minimum observations per bin, and the local density required for a grid cell to be drawn.
  • exclude_missing (bool), exclude_distance (float): blank grid areas too far from any observation, rather than extrapolating into empty sectors.
  • uncertainty (float): confidence width, e.g. 0.95, for blanking regions where the prediction interval exceeds the prediction.
  • upper_limit (float): blank predictions above this concentration.

On rendering. The surface is predicted onto a regular Cartesian grid in wind-component space and drawn as a single interpolated raster, which is what openair does via lattice's levelplot.

polar_plot(df, conc_col='NO2', render='contour').show()

Polar plot as contour bands

The old behaviour is still available, and shows why the default changed — drawing one flat-filled polygon per bin looks blocky however smooth the underlying fit is, because nothing interpolates between neighbouring cells:

polar_plot(df, conc_col='NO2', render='tile').show()

Polar plot rendered as tiles

wind_rose

Wind speed and direction distribution.

Wind rose

from PythonAQ import wind_rose

fig, summary = wind_rose(df, group_by='none')
  • group_by (str): 'none', 'year' or 'quartile'.
  • quartile_col (str): column to split on when group_by='quartile'.
  • mode (str): 'count' or 'percentage'.
  • direction_bins (int), speed_bins (list): binning.

Returns the figure and a summary DataFrame.

pollutant_rose

Concentration by wind direction, banded by concentration.

Pollutant rose

from PythonAQ import pollutant_rose

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

percentile_rose

How the distribution, not just the mean, varies with direction. Useful when a source only shows up in the tail.

Percentile rose

from PythonAQ import percentile_rose

fig, summary = percentile_rose(df, 'NO2', percentile=[25, 50, 75, 90, 95])
  • percentile (sequence): percentiles to draw.
  • direction_bins (int): wind direction sectors, default 36.
  • fill (bool), mean_line (bool): styling.
  • statistic (str): 'percentile' (default) or 'cpf', below.
  • cpf_threshold (float): concentration defining "high" for the CPF; defaults to the given percentile of the observations.

The conditional probability function

statistic='cpf' gives, for each wind sector, the proportion of observations above a threshold:

percentile_rose(df, 'NO2', statistic='cpf', percentile=95)

Conditional probability function

This asks when the wind comes from here, how often is the concentration high? — a different question from the directional mean, and one that finds sources a mean misses when they are intermittent. A sector that is usually clean but occasionally very dirty has an unremarkable mean and a high CPF.

The threshold is taken over the whole record rather than per sector, so every sector is judged against one definition of "high", and the radial axis is fixed to 0–1 so two sites can be compared by eye.

polar_frequency_plot

How often each wind speed and direction combination occurs — worth checking alongside polar_plot, since a striking feature in a sector with almost no data is not a finding.

Polar frequency plot

from PythonAQ import polar_frequency_plot

polar_frequency_plot(df, separate_by_year=False).show()

polar_annulus

Wind direction around the ring, a temporal variable through it. For sources that only appear at certain hours or in certain months, which a plain polar plot averages away.

Polar annulus

from PythonAQ import polar_annulus

fig, summary = polar_annulus(df, 'NO2', period='hour')
  • period (str): radial variable — 'hour' (default), 'month', 'weekday', 'season' or 'trend' (one ring per year).
  • inner_radius (float): size of the hole, so the innermost ring is not squeezed to nothing.
  • smooth (bool), sigma (tuple): smoothing, which wraps at the cyclic edges so there is no seam at north.

polar_cluster

K-means clustering in polar coordinates, to group conditions that behave alike.

Polar cluster plot

from PythonAQ import polar_cluster

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

Time series and trends

time_variation

The four-panel temporal summary: hour of day split by weekday, day of week, hour of day, and month of year, each with bootstrap confidence intervals.

Temporal variation plot

from PythonAQ import time_variation

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

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

Parameters

  • pollutant (str or list): one or more columns.
  • statistic (str): 'mean' or 'median'.
  • conf_int (float): confidence level, default 0.95.
  • n_boot (int): bootstrap replicates (openair's B), default 100.
  • normalise (bool): divide each series by its mean, to compare shapes across variables on different scales.
  • ci (bool): draw the confidence bands.
  • random_state (int): seed, for reproducible intervals.

Returns the figure and a long-format DataFrame of every plotted statistic.

theil_sen_plot

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

Theil-Sen trend plot

from PythonAQ import theil_sen_plot

theil_sen_plot(df, pollutant_col='NO2', agg_freq='ME').show()
  • agg_freq (str): averaging period before fitting, e.g. 'ME'.
  • deseason (bool): remove the seasonal cycle via STL first.
  • alpha (float): significance level.

smooth_trend_plot

A GAM smooth rather than a straight line, for trends that are not monotonic.

Smooth trend plot

from PythonAQ import smooth_trend_plot

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

time_plot

Time series for one or more pollutants, with optional grouping, stacking and normalisation.

Time series plot

from PythonAQ import time_plot

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

trend_level

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

Trend level heat map

from PythonAQ import trend_level

fig, summary = trend_level(df, 'NO2', x='month', y='hour', type='year')
  • x, y (str): axes, from 'hour', 'weekday', 'month', 'year', 'season', 'monthyear', 'wd', or any column.
  • type (str or None): panel variable; None for a single panel.
  • statistic (str): 'mean', 'median', 'max', 'min' or 'frequency'.

time_prop

Stacked bars over time, split by the contribution of a category — the usual way source apportionment output is read.

Time proportion plot

from PythonAQ import time_prop

fig, summary = time_prop(df, 'NO2', proportion='wd', avg_time='month')
  • proportion (str): the splitting column — an existing category, a numeric column split into quantiles, or anything cut_data understands.
  • avg_time (str): bar width in time.
  • statistic (str): 'mean' or 'sum'.
  • normalise (bool): scale every bar to 100%, to compare composition rather than magnitude.

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. Needs the calendar extra.

Calendar plot

from PythonAQ import calendar

calendar(df, value_column='PM10').show()

Distributions and relationships

summary_plot

Time series, data-capture rug and histogram for every pollutant at once — the first thing to run on an unfamiliar dataset.

Summary plot

from PythonAQ import summary_plot

fig, summary = summary_plot(df[['date_time', 'NO2', 'PM10', 'O3']])

The summary DataFrame gives capture rate, missing count, min, max, mean, median and 95th percentile per column.

scatter_plot

Two variables against each other, with optional linear and LOWESS fits.

Scatter plot with fits

from PythonAQ import scatter_plot

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

For Leeds Centre this gives NO₂ = 0.410 × NOₓ + 8.31, R² = 0.740. The slope below one and the curvature in the LOWESS fit reflect NO₂ making up a smaller share of NOₓ as total NOₓ rises, since the oxidant available to convert NO is limited.

  • method (str): 'scatter', 'hexbin' or 'density'.
  • colour_by (str): column used to colour points.
  • linear (bool), smooth (bool), one_to_one (bool): overlays.

Returns the figure and a DataFrame of fit statistics.

corr_plot

Correlation matrix, ordered by hierarchical clustering so related species sit together.

Correlation matrix

from PythonAQ import corr_plot

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

Strongest pair here is PM10/PM2.5 at r = 0.943; strongest anticorrelation is O₃ against NO₂ at r = −0.676.

  • method (str): 'pearson', 'spearman' or 'kendall'.
  • cluster (bool), annotate (bool).

Model evaluation

mod_stats

Standard statistics for comparing modelled against observed values. The formulas follow the openair R source exactly, including the piecewise index of agreement of Willmott et al. (2011).

from PythonAQ import mod_stats

stats = mod_stats(df, mod='model', obs='observed', group_by='season')

Returns n, FAC2, MB, MGE, NMB, NMGE, RMSE, r, P, COE, IOA.

Statistic Meaning
FAC2 Fraction of predictions within a factor of two
MB, MGE Mean bias, mean gross error
NMB, NMGE The same, normalised by the sum of observations
RMSE Root mean square error
r, P Pearson correlation and its p-value
COE 1 is perfect, 0 is no better than the observed mean, negative is worse
IOA Index of agreement, −1 to +1

aq_stats

Annual summary statistics with exceedance counts against the UK objectives.

from PythonAQ import aq_stats

summary = aq_stats(df, 'NO2', data_thresh=75)

For Leeds Centre:

year data capture mean median 95th pct max daily hours > 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

conditional_quantile

mod_stats says how well a model does; this says where it fails. Modelled values are binned, and within each bin the spread of the corresponding observations is drawn.

Conditional quantile plot

from PythonAQ import conditional_quantile

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

A model can have a respectable correlation while being badly wrong at high concentrations — the range that usually matters most — and that shows up here as the median peeling away from the 1:1 line at the top of the range.

  • bins (int or sequence): bins across the modelled range.
  • min_count (int): bins with fewer pairs are dropped; a quantile of three points is not informative.
  • quantiles (sequence): two nested pairs, default the 10/90 and 25/75.
  • show_histograms (bool): the marginal distributions, which show where the data actually is — quantile bands alone say nothing about how many points support them.

taylor_diagram

Three statistics on one plot. The radius is the standard deviation, the angle is the correlation, and — because of how those combine — the distance from a model's point to the reference point is its centred RMS error.

Taylor diagram

from PythonAQ import taylor_diagram

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

The closer a point sits to the star, the better the model. Comparing several becomes a matter of looking.

  • mod (str or list): one or several modelled columns.
  • group (str or None): split the comparison, e.g. by season or site.
  • normalise (bool): divide by the observed standard deviation, so series on different scales share one diagram.

Returns sd, r, centred_rmse and bias per point. Bias is reported separately because the centred RMS error removes each series' mean by construction — a model that is uniformly 20 µg/m³ too high has zero centred error and sits exactly on the reference.


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)
  • avg_time (str): 'hour', 'day', '3 day', 'month', 'year', or any pandas offset alias.
  • data_thresh (float): minimum percentage of data required in a period; periods below become NaN.
  • statistic (str): 'mean', 'median', 'max', 'min', 'sum', 'sd', 'frequency', 'data.cap' or 'percentile'.
  • interval (str or None): the series' true sampling interval, e.g. 'hour'. Only used when data_thresh is set.

Give interval when rows may be absent. Capture is measured against a regular time base, and inferring that base from the data uses the most common gap between observations — which is wrong exactly when data is missing. An hourly series with every other row absent looks like a complete two-hourly series and reports 100% capture on 50% of the data. date_pad does the same job as a separate step.

Wind direction is averaged as a vector, not a scalar. Winds at 350° and 10° average to 0°, due north, rather than the 180° a naive mean would give. The scalar mean wind speed is kept; pass vector_ws=True for the vector magnitude.

select_by_date

Subset by date components; all criteria combine with AND.

from PythonAQ import select_by_date

subset = select_by_date(df, year=2024, month='June', day=['Sat', 'Sun'])
winter = select_by_date(df, season='winter')

month takes names or numbers; day takes weekday names or month-days.

rolling_mean

Rolling mean with a data-capture threshold. The 8-point default matches the running 8-hour mean used for ozone.

from PythonAQ import rolling_mean

result = rolling_mean(df, 'O3', width=8, data_thresh=75)
  • align (str): 'centre', 'left' or 'right'.

cut_data

Add a conditioning column for splitting data.

from PythonAQ import cut_data

tagged = cut_data(df, type='season')
  • type (str): 'year', 'month', 'monthyear', 'season', 'seasonyear', 'weekday', 'weekend', 'hour', 'daylight', 'wd', or a numeric column name (split into n_levels quantiles).
  • hemisphere (str): 'northern' or 'southern', for season definitions.

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_pad

Pad a series onto a complete, regular time base.

from PythonAQ import date_pad

padded = date_pad(df, interval='hour')

Real monitoring data has gaps where rows are simply absent rather than present-and-NaN, which quietly distorts anything that counts observations per period. Give interval when that is possible: the base is otherwise inferred from the most common gap between observations, which is wrong precisely when data is missing. An hourly series with every other row absent looks like a complete two-hourly series.

  • type (str or None): column identifying separate series, e.g. 'site'. Each is padded over its own span, so a site that started reporting late does not gain years of empty rows.

split_by_date

Split a series at given dates.

from PythonAQ import split_by_date

result = split_by_date(df, '2023-06-01', labels=['before', 'after'])

N cut points give N+1 periods, as an ordered categorical — so it drops straight into type= for before/after comparisons.

select_running

Find runs of consecutive values at or above a threshold.

from PythonAQ import select_running

episodes = select_running(df, 'NO2', run_length=8, mode='filter')

Episodes are defined by persistence rather than by any single high hour: one spike is not an episode, eight consecutive hours is. A gap ends a run rather than silently bridging it.

  • run_length (int), threshold (float or None, defaults to the 95th percentile), mode ('flag' or 'filter').

bin_data

Bin one variable against another, with bootstrap intervals on each bin.

from PythonAQ import bin_data

result = bin_data(df, x='ws', y='NO2', bins=20)

Answers "how does y behave across the range of x?" with an honest uncertainty per bin, rather than a single regression line that hides where the data is. Returns the bin centre, count, statistic, and lower and upper bounds.

deseason_data and get_period

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'), method='additive')

get_period converts a pandas interval string into the number of periods in a year, which is what the decomposition needs.


Meteorological helpers

e_sat and rh

Saturation vapour pressure (hPa) and relative humidity (%).

from PythonAQ import e_sat, rh

e_sat(20.0)        # 23.34 hPa
rh(20.0, 10.0)     # 52.5 %
rh(15.0, 15.0)     # 100.0 %, saturated

Both take scalars or arrays. rh expects air temperature and dew point in °C and clips the result to 0–100.


quick_text

Formats pollutant names and units for display. Applied automatically to the titles, axis labels and colourbars of the plotting functions, so 'no2' on an axis renders as NO₂.

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>)'
quick_text('Site 3 kerbside')  # unchanged

Matching is on whole tokens only. The species codes are short, so a careless substring match would mangle ordinary prose — 'nothing', 'Nottingham' and 'concentration' all pass through untouched.

  • auto (bool): set False to disable, without branching at the call site.
  • plain (bool): drop the markup, for contexts that do not render HTML.

Relationship to openair

PythonAQ follows openair using Python naming conventions — timeVariation becomes time_variation. Where a statistic has a precise definition, such as the modStats metrics, the formulas follow the openair R source exactly.

openair PythonAQ openair PythonAQ
importAURN etc. download_aurn_data timeAverage time_average
importMeta import_aq_meta selectByDate select_by_date
windRose wind_rose rollingMean rolling_mean
pollutionRose pollutant_rose cutData cut_data
percentileRose percentile_rose calcPercentile calc_percentile
polarPlot polar_plot modStats mod_stats
polarFreq polar_frequency_plot aqStats aq_stats
polarCluster polar_cluster corPlot corr_plot
timePlot time_plot scatterPlot scatter_plot
timeVariation time_variation trendLevel trend_level
TheilSen theil_sen_plot calendarPlot calendar
smoothTrend smooth_trend_plot polarAnnulus polar_annulus
quickText quick_text cutData / type type= everywhere
conditionalQuantile conditional_quantile datePad date_pad
TaylorDiagram taylor_diagram splitByDate split_by_date
timeProp time_prop selectRunning select_running
binData bin_data

Not yet ported: polarDiff, conditionalEval, distPlot, linearRelation, runRegression, the trajectory functions (trajPlot, trajLevel, trajCluster), the remaining smoothers (GaussianSmooth, WhittakerSmooth, kzFilter, rollingQuantile), and the openairmaps interactive maps, which are a separate package.

Two deliberate differences:

  • time_average averages wind direction as a vector but keeps the scalar mean wind speed. Pass vector_ws=True for the vector magnitude.
  • cut_data(type='daylight') uses a fixed 07:00–19:00 window rather than openair's solar elevation calculation. Adequate for coarse splits; replace it before relying on it at high latitude or near the solstices.

Guide

docs/guide/index.html is a single-page reference covering every public function, each with a short example and the output that example actually produces — an interactive figure, a table, or console output as appropriate.

Once Pages is enabled for the repository — Settings > Pages, source "GitHub Actions" — the workflow publishes it to chris-r-uol.github.io/PythonAQ/guide/. Until then, open the file locally or read it through the GitHub blob view.

It is generated by running the examples against real AURN data, so it cannot drift from the package:

pip install -e '.[calendar,docs]'
python examples/build_guide.py

The page has light, dark and system themes. Figures are embedded as Plotly JSON rather than images so they restyle live rather than needing two PNGs each, and the site map follows suit — carto-positron in light, carto-darkmatter in dark. Both are OpenStreetMap data rendered muted, so the markers stay readable against them.

The build asserts its own coverage and exits non-zero if a public function is left undocumented.

Worked example

examples/demo_leeds.py runs every public function against real data for Leeds Centre and writes each figure to examples/output/ as standalone interactive HTML:

pip install -e '.[calendar]'
python examples/demo_leeds.py

It downloads ~35,000 hourly records plus meteorology from the nearest NOAA station, caches them so re-runs are instant, and checks its own coverage — exiting non-zero if any public function goes unexercised. Pass --refresh to re-download.

Streamlit application

pip install -e '.[app,calendar]'
streamlit run app.py

Development

pip install -e '.[dev]'
pytest

The tests use synthetic data throughout and never touch the network, so they run offline and deterministically. CI covers Python 3.9–3.14 on Linux, plus macOS and Windows.

To rebuild the figures in this manual after changing a plotting function:

pip install -e '.[calendar,docs]'
python examples/build_readme_figures.py

Releasing

Publishing is automated but deliberately gated. It happens when a GitHub release is created, never on an ordinary push.

# 1. bump the version (one source of truth)
#    PythonAQ/__init__.py  ->  __version__ = "0.5.1"
# 2. merge, tag and release
git tag -a v0.5.1 -m "PythonAQ 0.5.1"
git push origin v0.5.1
gh release create v0.5.1 --title "PythonAQ 0.5.1" --notes-from-tag --verify-tag --latest

The Publish workflow then builds an sdist and a wheel, runs twine check, verifies the tag matches the packaged version, installs from the sdist to prove it is complete, and uploads to PyPI.

Uploads use Trusted Publishing: PyPI verifies a short-lived OpenID Connect token minted by GitHub for this specific repository, workflow and environment, and issues an upload token in return. No API token is stored in the repository, so there is no secret to leak or rotate.

One-time setup

On PyPI — add a trusted publisher at pypi.org/manage/account/publishing (for a project that does not exist yet) or the project's Publishing settings:

Field Value
Owner chris-r-uol
Repository PythonAQ
Workflow name publish.yml
Environment pypi

In this repository — create the environment under Settings → Environments named pypi. Adding a required reviewer there means a release waits for an explicit approval before anything uploads, which is worth having: a PyPI version number can never be reused, even after deletion.

Rehearsing

Repeat both steps on test.pypi.org with the environment named testpypi, then run the workflow manually:

gh workflow run publish.yml -f target=testpypi

That path uses skip-existing, so repeated rehearsals do not fail.

Contributing

Contributions are welcome — please open an issue or pull request.

Licence

MIT. See LICENSE.

Metadata

Release files for PythonAQ 0.5.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for PythonAQ 0.5.1
File Size Uploaded Provenance
pythonaq-0.5.1.tar.gz 133.2 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for PythonAQ 0.5.1
File Interpreter ABI Platform
pythonaq-0.5.1-py3-none-any.whl Python 3 none any Details

Total release size: 235.6 kB

Release files / pythonaq-0.5.1.tar.gz

Download URL pythonaq-0.5.1.tar.gz
Size 133.2 kB
Tags Source
SHA-256 checksum
How to use checksums
7ab8dffcbc15ebae9bf9c98b87294f051d0cc73fe42a66c9328208a230fb714c
BLAKE2b-256 checksum
How to use checksums
7edd0b1658c57dcf150b3a06a6d8ce1dbe91cf2d4b39a9b8b4f410c92810dc92
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 Aug 17, 2026.

Transparency log

Release files / pythonaq-0.5.1-py3-none-any.whl

Download URL pythonaq-0.5.1-py3-none-any.whl
Size 102.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ffea033a933cfb4dc2ed27cf1bef4d2f6713708d2093d3481eb4798309ff059b
BLAKE2b-256 checksum
How to use checksums
3d1a69ebaf54ec4f109a2dccf30356d70313eee1f63d913d1e38e00e41d8ca72
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 Aug 17, 2026.

Transparency log

Security for PythonAQ 0.5.1

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.

Select from 2 files

Attestation for pythonaq-0.5.1.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 Aug 17, 2026.

Transparency log

A 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.

Release history Release notifications | RSS feed

This release

0.5.1 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page