Metadata-Version: 2.4
Name: PythonAQ
Version: 0.5.1
Summary: Air quality data processing and visualisation toolkit
Author-email: Chris Rushton <c.e.rushton@leeds.ac.uk>
License: MIT
Project-URL: Homepage, https://github.com/chris-r-uol/PythonAQ
Project-URL: Repository, https://github.com/chris-r-uol/PythonAQ
Project-URL: Issues, https://github.com/chris-r-uol/PythonAQ/issues
Keywords: air quality,openair,AURN,NOAA,atmospheric science
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Science/Research
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24
Requires-Dist: pandas>=2.0
Requires-Dist: plotly>=5.15
Requires-Dist: scipy>=1.10
Requires-Dist: requests>=2.28
Requires-Dist: rdata>=0.11
Requires-Dist: scikit-learn>=1.3
Requires-Dist: statsmodels>=0.14
Requires-Dist: pymannkendall>=1.4
Requires-Dist: pygam>=0.9
Provides-Extra: calendar
Requires-Dist: plotly-calplot>=0.1.20; extra == "calendar"
Requires-Dist: pandas<3; extra == "calendar"
Provides-Extra: app
Requires-Dist: streamlit>=1.30; extra == "app"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: flake8>=6.0; extra == "dev"
Provides-Extra: docs
Requires-Dist: kaleido<1.0; extra == "docs"
Dynamic: license-file

# PythonAQ

[![Tests](https://github.com/chris-r-uol/PythonAQ/actions/workflows/tests.yml/badge.svg)](https://github.com/chris-r-uol/PythonAQ/actions/workflows/tests.yml)
[![Python](https://img.shields.io/badge/python-3.9%20%E2%80%93%203.14-blue)](https://www.python.org)
[![Licence](https://img.shields.io/badge/licence-MIT-green)](https://github.com/chris-r-uol/PythonAQ/blob/main/LICENSE)

Air quality data analysis and visualisation for Python, following the R
[openair](https://github.com/openair-project/openair) package. Download data
from the UK monitoring networks and NOAA, average and condition it properly,
and produce interactive Plotly figures.

<p align="center">
  <img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/polar_plot.png" alt="Bivariate polar plot of NO2 at Leeds Centre" width="620">
</p>

Every figure in this manual is generated by
[`examples/build_readme_figures.py`](https://github.com/chris-r-uol/PythonAQ/blob/main/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](#installation)
- [Quick start](#quick-start)
- [Conditioning with `type`](#conditioning-with-type)
- [Getting data](#getting-data)
- [Directional analysis](#directional-analysis) — how concentrations relate to wind
- [Time series and trends](#time-series-and-trends)
- [Distributions and relationships](#distributions-and-relationships)
- [Model evaluation](#model-evaluation)
- [Data utilities](#data-utilities)
- [Meteorological helpers](#meteorological-helpers)
- [Relationship to openair](#relationship-to-openair)
- [Guide](#guide)
- [Worked example](#worked-example)
- [Development](#development)
- [Releasing](#releasing)
- [Licence](#licence)

---

## Installation

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

The package is then importable from anywhere:

```python
from PythonAQ import import_aq_meta, wind_rose
```

### Optional extras

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

```bash
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

```python
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.

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

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/polar_plot_by_season.png" alt="Polar plot conditioned by season" width="700"></p>

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`](#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:

```python
# 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.

```python
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.

```python
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.

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/map_sites.png" alt="Monitoring site locations" width="700"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/polar_plot.png" alt="Bivariate polar plot" width="620"></p>

```python
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`.

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

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/polar_plot_contour.png" alt="Polar plot as contour bands" width="560"></p>

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:

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

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/polar_plot_tile.png" alt="Polar plot rendered as tiles" width="560"></p>

### `wind_rose`

Wind speed and direction distribution.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/wind_rose.png" alt="Wind rose" width="660"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/pollutant_rose.png" alt="Pollutant rose" width="660"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/percentile_rose.png" alt="Percentile rose" width="560"></p>

```python
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:

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

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/cpf_rose.png" alt="Conditional probability function" width="560"></p>

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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/polar_frequency.png" alt="Polar frequency plot" width="560"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/polar_annulus.png" alt="Polar annulus" width="560"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/polar_cluster.png" alt="Polar cluster plot" width="620"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/time_variation.png" alt="Temporal variation plot" width="820"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/theil_sen.png" alt="Theil-Sen trend plot" width="780"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/smooth_trend.png" alt="Smooth trend plot" width="780"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/time_plot.png" alt="Time series plot" width="780"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/trend_level.png" alt="Trend level heat map" width="820"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/time_prop.png" alt="Time proportion plot" width="820"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/calendar.png" alt="Calendar plot" width="820"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/summary_plot.png" alt="Summary plot" width="820"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/scatter_plot.png" alt="Scatter plot with fits" width="740"></p>

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/corr_plot.png" alt="Correlation matrix" width="560"></p>

```python
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).

```python
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.

```python
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.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/conditional_quantile.png" alt="Conditional quantile plot" width="700"></p>

```python
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**.

<p align="center"><img src="https://raw.githubusercontent.com/chris-r-uol/PythonAQ/main/docs/images/taylor_diagram.png" alt="Taylor diagram" width="620"></p>

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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 (%).

```python
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₂.

```python
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`](https://github.com/chris-r-uol/PythonAQ/blob/main/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/](https://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:

```bash
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`](https://github.com/chris-r-uol/PythonAQ/blob/main/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:

```bash
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

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

---

## Development

```bash
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:

```bash
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.

```bash
# 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](https://docs.pypi.org/trusted-publishers/):
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](https://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](https://test.pypi.org) with the
environment named `testpypi`, then run the workflow manually:

```bash
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](https://github.com/chris-r-uol/PythonAQ/blob/main/LICENSE).
