q05·advanced

How is global atmospheric CO₂ changing, and where are the strongest sources?

atmospheregreenhouse-gasescarbon-cycleclimate Datasets: 5 30–90 min

CO₂ (carbon dioxide) is the main heat-trapping gas we add to the air by burning coal, oil, and gas. Satellites can't sniff the air directly from orbit. Instead they measure XCO₂, the average amount of CO₂ in the whole column of air from the ground up to space, written in ppm (parts per million, just a concentration). Watch that number over years and you see the planet's CO₂ climbing. Look at where it's highest and you can spot the big emitters below.

How is global atmospheric CO₂ changing, and where are the strongest sources?

CO₂ (carbon dioxide) is the main heat-trapping gas we add to the air by burning coal, oil, and gas. Satellites can’t sniff the air directly from orbit. Instead they measure XCO₂, the average amount of CO₂ in the whole column of air from the ground up to space, written in ppm (parts per million, just a concentration). Watch that number over years and you see the planet’s CO₂ climbing. Look at where it’s highest and you can spot the big emitters below.

Which data to use, and how

Use this dataset: OCO-2 XCO₂ (short name OCO2_L2_Lite_FP), which covers 2014 → today and is the easiest place to start, because the global trend is the simplest thing to compute. There’s also a newer twin, OCO-3 XCO₂ (OCO3_L2_Lite_FP), which can zoom in on a single city or power plant. Save that for after you’ve got the global picture working.

Then do four steps (the runnable code further down does all of this; this is just the idea):

  1. Pick global and a time window. For the trend you want the whole planet, so no bounding box, and a run of years (e.g. 2015 → 2025).
  2. Keep only the good measurements. Each reading carries a quality flag. Keep only the ones marked “good” (xco2_quality_flag == 0) and throw the rest away.
  3. One number per month. Average the good XCO₂ readings into a monthly value (optionally onto a coarse grid, like 2°×2° cells), giving a monthly time series.
  4. Draw the trend and the seasons. Fit the long-term rise (about 2.5 ppm per year right now) and the yearly up-and-down wiggle (plants pull CO₂ down in summer, release it in winter).

That’s the whole method. Below, “How a scientist answers this” names the exact tools, and the Run it cell does it live on synthetic data so you can see each step work.

What you can find out

  • The global CO₂ trend. The steady rise (~2.5 ppm/yr now), the yearly seasonal wiggle, and the annual growth rate.
  • Where it’s higher or lower. Highest over Asia’s industrial corridors, lowest over the Southern Ocean.
  • Individual big emitters. Megacities and power plants, using OCO-3’s zoom mode (called SAM) when the sky is clear.
  • How plants breathe. Using SIF (Solar-Induced Fluorescence, a faint glow leaves give off while photosynthesizing), you can compare forests vs croplands vs grasslands.
  • El Niño / La Niña effects. How these ocean climate cycles shift how much carbon tropical land takes up (a major OCO finding).

What it can’t tell you

  • The actual emission rate (kilograms of CO₂ per second). The satellite sees a concentration, not a flow. Turning the gas you see into the gas being emitted needs an atmospheric-transport model that runs the air backwards (an “inversion”; use the OCO MIP flux estimates).
  • One specific factory’s emissions. To attribute CO₂ to a single facility you also need accurate wind and how deep the near-ground mixing layer is. Pull those from MERRA-2 (its PBL height and 850 hPa winds).
  • Methane. OCO instruments only see CO₂. Methane lives on other sensors (TROPOMI, EMIT, GOSAT, MERLIN), not these.

Gotchas to watch for

  • Most of the data gets thrown away. Clouds and haze block the view, so cloud-screening removes 75–90% of footprints. Sparse, patchy coverage is normal. The fix is to average up to monthly (or coarser) so the gaps fill in.
  • Trust only the “good” flag. Every reading has a quality flag: keep xco2_quality_flag == 0 (good), treat == 1 as questionable, and discard == 2. Skip this and bad retrievals will poison your trend.
  • Watch the version number when comparing years. NASA re-processes the data and changes its bias corrections (e.g. v11.2 vs v10). If your time span crosses a version change, a jump might be the processing, not the air. Use one consistent version for a trend.
  • SIF is only a stand-in for photosynthesis (not gross primary production measured directly). The link between the glow and actual plant growth shifts with the ecosystem, water stress, and species mix, so read it as a proxy, not a precise number.
  • OCO-3’s zoom mode is scheduled, not guaranteed. SAM targets are chosen by a planner, so your city or plant won’t be observed on every overpass. Expect gaps in facility-scale data.

The real-data code

The Run it cell above runs the method on synthetic data (no login). Below is the whole recipe against the real archive: search OCO-2, open each Lite granule, keep only good-quality XCO₂, average per month, fit the long-term rise plus the seasonal wiggle, and plot it. It needs a free Earthdata Login.

import earthaccess, numpy as np, xarray as xr
import matplotlib.pyplot as plt

earthaccess.login(strategy="netrc")

aoi = None                          # global, for the planet-wide trend
window = ("2015-01-01", "2024-12-31")

# 1. Find the OCO-2 Lite files (one netCDF per day) over the whole window.
grans = earthaccess.search_data(short_name="OCO2_L2_Lite_FP", temporal=window)
print(f"{len(grans)} daily granules")

# 2. Open each granule, keep only good retrievals (xco2_quality_flag == 0),
#    and reduce every day to (year-month, mean ppm). OCO-2 Lite is a 1-D list of
#    sounding footprints, so this is just masked averaging — no gridding needed.
from collections import defaultdict
buckets = defaultdict(list)
for fh in earthaccess.open(grans):
    ds = xr.open_dataset(fh)
    good = ds["xco2_quality_flag"].values == 0
    xco2 = ds["xco2"].values[good]                       # ppm
    t = ds["time"].values[good].astype("datetime64[M]")  # truncate to month
    if xco2.size == 0:
        continue
    for ym in np.unique(t):
        buckets[ym].append(np.nanmean(xco2[t == ym]))

months = np.array(sorted(buckets))
xco2_m = np.array([np.nanmean(buckets[m]) for m in months])
# decimal year as the regression axis
yr = months.astype("datetime64[Y]").astype(int) + 1970
mo = months.astype("datetime64[M]").astype(int) % 12
tdec = yr + mo / 12.0

# 3. Fit trend + seasonal harmonic in one least-squares solve:
#    xco2 = a + b*t + c*sin(2πt) + d*cos(2πt).  b is the ppm/yr growth rate.
ph = 2 * np.pi * tdec
A = np.column_stack([np.ones_like(tdec), tdec, np.sin(ph), np.cos(ph)])
coef, *_ = np.linalg.lstsq(A, xco2_m, rcond=None)
growth = coef[1]
fit = A @ coef

# 4. Verdict + plot (monthly means, the straight rise, and the full seasonal fit).
print(f"Global XCO2 is rising at {growth:+.2f} ppm/yr "
      f"({xco2_m[0]:.1f} -> {xco2_m[-1]:.1f} ppm over the record)")
plt.plot(tdec, xco2_m, ".", label="monthly mean XCO2")
plt.plot(tdec, coef[0] + coef[1] * tdec, "--", c="k", label=f"trend {growth:+.2f} ppm/yr")
plt.plot(tdec, fit, c="C1", lw=1, label="trend + seasonal fit")
plt.ylabel("XCO2 (ppm)"); plt.xlabel("year"); plt.legend(); plt.tight_layout(); plt.show()

# Sanity check: ~2.5 ppm/yr should match NOAA's Mauna Loa in-situ growth rate
# (gml.noaa.gov/ccgg/trends). If it doesn't, you let in bad flags or crossed a version change.

Where the data comes from

All of it comes from NASA through one free login (Earthdata Login). OCO-2, OCO-3, and MERRA-2 all live in the same GES DISC archive and are fetched the same way with earthaccess.

Sources

How a scientist answers this
Parameters
Column-averaged dry-air CO2 mole fraction (XCO2, ppm) from OCO-2 L2 Lite (~3 km nadir, 2014–) and OCO-3 L2 Lite (including city-targeting SAM mode), filtered to good-quality soundings (xco2_quality_flag == 0); Solar-Induced Fluorescence (SIF) as a photosynthesis proxy; MERRA-2 winds/PBL for source context. Trends are reported as ppm/yr growth rate with a seasonal cycle and confidence interval.
Method
Grid quality-filtered XCO2 to a regular mesh, area-weight (cos-lat) the spatial means, fit a seasonal harmonic plus a Theil–Sen / robust linear trend with Mann–Kendall significance for the global growth rate; for point sources, difference OCO-3 SAM-mode XCO2 across a city/plume against the upwind background using MERRA-2 wind. Direct fluxes require an atmospheric-transport inversion (OCO MIP), not the columns alone.
Validation
Anchor XCO2 against TCCON ground-based total-column stations and the in-situ surface growth rate; respect quality and bias-correction flags, account for clear-sky sampling bias and the smaller SAM-mode footprint; for sources, verify against reported inventories and inversion (OCO MIP) results.
In plain EnglishMeasure the CO2 in the air column from space over many years, remove the regular up-and-down of the seasons, and read off how fast it is rising; for cities and power plants, compare the CO2 directly over the source to the clean air just upwind. Turning that into tons emitted needs a wind-and-mixing model.

Make it yours → Choose global or a regional/city box, the year range, and the quality filter in the notebook; switch OCO-2 nadir for OCO-3 SAM mode to target a specific facility.

🧪 Virtual Mock Lab stand-in data · runs in your browser · no login

A safe place to practise the method on OCO3_L2_Lite_FP. The code is the real earthaccess workflow, but we feed it stand-in data shaped like the real product — so you can run it, change the numbers, and break it without an account. When you want the real data, use the recipe under “The real-data code” above, or download the notebook below.

editable · runs in your browser

Datasets used

Recipe ready · not yet run The recipe and the Virtual Mock Lab above are ready to use. We haven't yet run this question end-to-end on real data, so it has no verified answer yet — only the questions we've actually computed carry one. That's the honest line between a method and a result.