History / version C9S6FHZR · saved

Cite this version

@software{keeling_curve_2026,
  author = {ScienceHub},
  title = {Keeling curve},
  version = {C9S6FHZR},
  year = {2026},
  publisher = {ScienceHub},
  url = {https://sciencehub.sh/sciencehub-team/keeling-curve/history/ver_01M4V366RQREJ1PH45C9S6FHZR}
}
ScienceHub (2026). Keeling curve (Version C9S6FHZR) [Computer software]. ScienceHub. https://sciencehub.sh/sciencehub-team/keeling-curve/history/ver_01M4V366RQREJ1PH45C9S6FHZR
cff-version: 1.2.0
message: "If you use this version, cite it as below."
title: "Keeling curve"
type: software
authors:
  - name: "ScienceHub"
version: "C9S6FHZR"
date-released: "2026-10-13"
url: "https://sciencehub.sh/sciencehub-team/keeling-curve/history/ver_01M4V366RQREJ1PH45C9S6FHZR"

This address names exactly this version. The project page cites all versions.

# /// script
# requires-python = ">=3.11"
# dependencies = ["marimo>=0.25.1", "numpy", "pandas", "matplotlib"]
# ///

import marimo

__generated_with = "0.25.1"
app = marimo.App(width="medium", app_title="The Keeling curve")

with app.setup:
    import numpy as np
    import pandas as pd

    DATA = "data/co2_mm_mlo.csv"


@app.function
def load_co2(path: str = DATA) -> pd.DataFrame:
    """Monthly mean CO₂ in the air at Mauna Loa, Hawaii, in ppm, from March 1958.

    Columns: year, month, decimal_date, average, deseasonalized, ndays, sdev,
    unc. NOAA fills months without measurements by interpolation and marks
    them with a negative ndays.
    """
    co2 = pd.read_csv(path, comment="#")
    return co2.rename(columns={"decimal date": "decimal_date"})


@app.function
def yearly_mean(path: str = DATA) -> pd.Series:
    """The deseasonalized concentration averaged over each complete year, in ppm, by year."""
    co2 = load_co2(path)
    complete = co2.groupby("year").filter(lambda year: len(year) == 12)
    return complete.groupby("year")["deseasonalized"].mean()


@app.function
def growth_rate(start_year: int, end_year: int, path: str = DATA) -> float:
    """Average growth of CO₂ between two complete years, in ppm a year.

    >>> round(growth_rate(1960, 1970), 2)
    0.88
    """
    yearly = yearly_mean(path)
    if start_year not in yearly.index or end_year not in yearly.index:
        raise ValueError(f"Both years need twelve months of data; there are {yearly.index.min()} to {yearly.index.max()}.")
    return float((yearly[end_year] - yearly[start_year]) / (end_year - start_year))


@app.function
def seasonal_cycle(path: str = DATA) -> pd.Series:
    """Each month's mean departure from the deseasonalized trend, in ppm: the breath of the northern forests."""
    co2 = load_co2(path)
    return (co2["average"] - co2["deseasonalized"]).groupby(co2["month"]).mean()


@app.cell
def _():
    import marimo as mo
    import matplotlib.pyplot as plt

    return mo, plt


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    # The Keeling curve

    In March 1958 Charles David Keeling began measuring carbon dioxide in the
    air on the slope of Mauna Loa, Hawaii, far from cities and forests. The
    record has run almost without a break since; NOAA's Global Monitoring
    Laboratory continues it today. It shows two things at once: a seasonal
    rhythm as the land plants of the northern hemisphere grow and decay, and a
    rise that has not paused in any year.

    This project is the data and the notebook together. The file
    `data/co2_mm_mlo.csv` is NOAA's monthly record as published; the functions
    below read it, and anyone can call them from Python with
    `sciencehub.load("sciencehub-team/keeling-curve")`.
    """)
    return


@app.cell
def _():
    co2 = load_co2()
    first_year, last_year = int(co2["year"].min()), int(co2["year"].max())
    return co2, first_year, last_year


@app.cell
def _(first_year, last_year, mo):
    years = mo.ui.range_slider(
        start=first_year,
        stop=last_year,
        step=1,
        value=[first_year, last_year],
        label="Years",
        full_width=True,
    )
    years
    return (years,)


@app.cell
def _(co2, mo, plt, years):
    _start, _end = years.value
    _shown = co2[(co2["year"] >= _start) & (co2["year"] <= _end)]
    fig, ax = plt.subplots(figsize=(7, 3.8))
    ax.plot(_shown["decimal_date"], _shown["average"], color="#0969da", lw=1, label="monthly mean")
    ax.plot(_shown["decimal_date"], _shown["deseasonalized"], color="#1f2328", lw=2, label="without the seasons")
    ax.set_xlabel("year")
    ax.set_ylabel("CO₂ (ppm)")
    ax.legend(frameon=False, loc="upper left")
    for _side in ("top", "right"):
        ax.spines[_side].set_visible(False)
    fig.tight_layout()

    _rise = _shown["deseasonalized"].iloc[-1] - _shown["deseasonalized"].iloc[0]
    mo.vstack(
        [
            fig,
            mo.md(
                f"From {_start} to {_end} the concentration rose by **{_rise:.1f} ppm**, "
                f"from {_shown['deseasonalized'].iloc[0]:.1f} to {_shown['deseasonalized'].iloc[-1]:.1f} ppm."
            ),
        ]
    )
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ## The rise is speeding up

    The growth rate is the average yearly rise over a decade. In the 1960s the
    concentration grew by under one ppm a year; it now grows by more than two.
    """)
    return


@app.cell
def _(mo, plt):
    _yearly = yearly_mean()
    _decades = list(range(int(_yearly.index.min()) // 10 * 10 + 10, int(_yearly.index.max()), 10))
    _rates = [growth_rate(_d - 10, _d) if _d - 10 in _yearly.index else np.nan for _d in _decades]
    fig2, ax2 = plt.subplots(figsize=(7, 3))
    ax2.bar([f"{_d - 10}s" for _d in _decades], _rates, color="#1f2328")
    ax2.set_ylabel("ppm a year")
    for _side in ("top", "right"):
        ax2.spines[_side].set_visible(False)
    fig2.tight_layout()
    mo.vstack([fig2, mo.md(f"Over the last complete decade: **{_rates[-1]:.2f} ppm a year**.")])
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ## The seasons

    Every year the concentration falls from May to October, while northern
    forests and crops take up carbon, and rises again through winter. The
    swing at Mauna Loa is about six ppm.
    """)
    return


@app.cell
def _(plt):
    _cycle = seasonal_cycle()
    fig3, ax3 = plt.subplots(figsize=(7, 2.8))
    ax3.bar(["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], _cycle.values, color=["#9a6700" if _v > 0 else "#0969da" for _v in _cycle.values])
    ax3.axhline(0, color="#1f2328", lw=0.8)
    ax3.set_ylabel("ppm from the trend")
    for _side in ("top", "right"):
        ax3.spines[_side].set_visible(False)
    fig3.tight_layout()
    fig3
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ## Data, limits and references

    **Data.** `data/co2_mm_mlo.csv`, the monthly means from NOAA's Global
    Monitoring Laboratory as published on 9 October 2026, unchanged. Values
    before May 1974 are from the Scripps Institution of Oceanography. Months
    without measurements are interpolated by NOAA (negative `ndays`). From
    December 2022 to July 2023, after the eruption of Mauna Loa, the
    measurements come from Maunakea, 21 miles to the north.

    **Limits.** One site measures the air over the central Pacific, which
    represents the well-mixed atmosphere well but not every region. The
    growth rates here are differences of yearly means, not the smoothed rates
    NOAA publishes.

    **Cite the data.** Lan, X., Tans, P. and Thoning, K. W.: Trends in
    globally-averaged CO₂ determined from NOAA Global Monitoring Laboratory
    measurements, https://gml.noaa.gov/ccgg/trends/; Keeling, C. D. et al.,
    Scripps Institution of Oceanography, https://scrippsco2.ucsd.edu/. NOAA
    makes the data freely available and asks for this credit.

    Keeling, C. D. (1960). The concentration and isotopic abundances of carbon
    dioxide in the atmosphere. *Tellus* 12(2), 200–203.
    """)
    return


if __name__ == "__main__":
    app.run()