Example: TROPOMI NO\(_2\) plume

An example demonstrating the application of the CSF and Gaussian plume inversion for estimating NOx emissions from the Matimba/Medupi power plant using TROPOMI NO2 images.

[1]:
import os

import cartopy.crs as ccrs
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import ucat
import xarray as xr

import ddeq

sources = ddeq.misc.read_point_sources()
sources = sources.sel(source=['Matimba'])

filename = os.path.join(ddeq.DATA_PATH, 'Matimba_S5P_RPRO_L2__NO2____20210725T110715.nc')

DOMAIN = ddeq.misc.Domain('Matimba', 25, -25.2, 29, -22.9)
CRS = ddeq.misc.get_opt_crs(DOMAIN)

CURVE_STYLE = "BEZIER"     # "POLY" or "BEZIER"
DETECTION = "THR"         # "THR" or "WIND"
BACKGROUND = "LINEAR"      # "LINEAR" or "SMOOTH"
2025-12-04 09:16:01.001243703 [W:onnxruntime:Default, device_discovery.cc:164 DiscoverDevicesForPlatform] GPU device discovery failed: device_discovery.cc:89 ReadFileContents Failed to open file: "/sys/class/drm/card0/device/vendor"

Sentinel-5P/TROPMI NO2 dataset

[2]:
data = xr.open_dataset(filename)
[3]:
fig = ddeq.vis.show_level2(
    data, 1e6 * data['NO2'],
    sources=sources,
    vmin=0,
    vmax=300,
    label="NO$_2$ vertical column density [µmol m$^{-2}$]", do_zoom=True,
    units='umol m-2'
)
_images/example_tropomi_Matimba_4_0.png

ERA5 wind files

The code below would download ERA5 single level and pressure level. Since the files are already in ddeq.DATA_PATH, no files will be downloaded. If you want to download own ERA5 data, please the change data_path to the location on your system, where you like to save your files.

[4]:
sng_filename = ddeq.era5dl.download_single_lvl(
    time=pd.Timestamp(data.time.values),
    area=DOMAIN.extent,
    timesteps=24,
    data_path=ddeq.DATA_PATH,
    prefix="Matimba"
)

lvl_filename = ddeq.era5dl.download_pressure_lvl(
    time=pd.Timestamp(data.time.values),
    area=DOMAIN.extent,
    timesteps=24,
    data_path=ddeq.DATA_PATH,
    prefix="Matimba"
)

sng_filename, lvl_filename

[4]:
('/home/docs/checkouts/readthedocs.org/user_builds/ddeq/envs/stable/lib/python3.12/site-packages/ddeq/data/Matimba_ERA5-sl-20210725.nc',
 '/home/docs/checkouts/readthedocs.org/user_builds/ddeq/envs/stable/lib/python3.12/site-packages/ddeq/data/Matimba_ERA5-pl-20210725.nc')

Cross-sectional flux method

[5]:
# Effective wind at source locations using GNFR-A weighted winds:
winds = ddeq.era5.read(
    sng_filename,
    lvl_filename,
    method="gnfra",
    sources=sources,
    times=data.time
)
winds
[5]:
<xarray.Dataset> Size: 96B
Dimensions:          (time: 1, source: 1)
Coordinates:
  * time             (time) datetime64[ns] 8B 2021-07-25T11:44:52.595066640
  * source           (source) object 8B 'Matimba'
    number           int64 8B 0
    expver           (time) <U4 16B '0001'
    lon              (source) float64 8B 27.61
    lat              (source) float64 8B -23.67
Data variables:
    U                (time, source) float64 8B -5.856
    V                (time, source) float64 8B -2.344
    speed            (time, source) float64 8B 6.308
    speed_precision  (time, source) float64 8B 1.0
    direction        (time, source) float64 8B 68.18
Attributes:
    CREATOR:       ddeq.era5
    DATE_CREATED:  2025-12-04 09:16
    ORIGIN:        ERA-5
    METHOD:        gnfra
    DESCRIPTION:   Weighted average for U- and V-wind using GNFR-A emission p...
[6]:
if DETECTION == "THR":

    var_sys = (ucat.convert_columns(0.5e15, 'cm-2', 'mol m-2',
                                    molar_mass='NO2'))**2

    data = ddeq.dplume.detect_plumes(data, sources,
                                     variable='NO2',
                                     variable_std='NO2_std',
                                     var_sys=var_sys,
                                     filter_type='gaussian',
                                     filter_size=0.5, crs=CRS)
elif DETECTION == "WIND":
    data = ddeq.dplume.detect_from_wind(data, sources, winds, dmax=30e3, crs=CRS)
else:
    raise ValueError
[7]:
fig = ddeq.vis.show_level2(
    data, 1e6 * data['NO2'], gas='NO2',
    vmin=0,
    vmax=300,
    label="NO$_2$ columns [µmol m$^{-2}$]",
    winds=winds,
    domain=DOMAIN,
    do_zoom=False, show_clouds=False, crs=CRS,
    figwidth=4,
);
_images/example_tropomi_Matimba_10_0.png
[8]:
variable = "NO2_mass"
background = "linear"
ddeq.emissions.convert_units(data, "NO2", "NO2")
[9]:
if DETECTION == "THR":
    data = ddeq.curves.fit_to_detections(data, n_nodes=3, force_origin=True, use_weights=True)

data = ddeq.curves.compute_natural_coords(data)
data = ddeq.curves.compute_plume_areas(data, plume_width=120e3)

# estimate NO2 enhancement
ddeq.background.estimate(data, "NO2")
ddeq.emissions.compute_plume_signal(data, "NO2")

ddeq.emissions.convert_units(data, "NO2", "NO2_estimated_background")
ddeq.emissions.convert_units(data, "NO2", "NO2_minus_estimated_background")

[10]:
dx = None if DETECTION == "WIND" else 10e3

results_csf = ddeq.csf.estimate_emissions(data, winds, sources, 'NO2',
                                          xmin=10e3, dx=dx,
                                          f_model=2.24, crs=CRS,
                                          variable=variable, background=background
                                         )
/home/docs/checkouts/readthedocs.org/user_builds/ddeq/envs/stable/lib/python3.12/site-packages/ddeq/csf.py:1098: FutureWarning: In a future version of xarray the default value for data_vars will change from data_vars='all' to data_vars=None. This is likely to lead to different results when multiple datasets have matching variables with overlapping values. To opt in to new defaults and get rid of these warnings now use `set_options(use_new_combine_kwarg_defaults=True) or set data_vars explicitly.
  polygons = xr.concat(
[11]:
ddeq.vis.plot_csf_result(['NO2'], data, winds, results_csf, 'Matimba',
                         vmins=[0], vmaxs=[200e-6], crs=CRS);
_images/example_tropomi_Matimba_14_0.png
[12]:
fig = ddeq.vis.show_level2(
    data,
    1e6 * data['NO2'],
    gas='NO2',
    vmin=0,
    vmax=300,
    label="NO$_2$ columns [µmol m$^{-2}$]",
    winds=winds,
    domain=DOMAIN,
    do_zoom=False,
    show_clouds=False,
    crs=CRS,
    figwidth=4,
);
_images/example_tropomi_Matimba_15_0.png
[13]:
fig, ax  = plt.subplots(1, figsize=(8,2))
ddeq.vis.plot_along_plume(ax, 'NO2', results_csf.sel(source='Matimba'))
ax.set_xlim(right=207)
ax.set_ylim(0,150)
plt.tight_layout()

ax.legend().remove()
ax.legend(loc=1, ncol=3)
[13]:
<matplotlib.legend.Legend at 0x7d8b99ba1520>
_images/example_tropomi_Matimba_16_1.png
[14]:
fig, axes  = plt.subplots(1,3, figsize=(8,2), sharey=True)


for i, ax in zip([1,9,-1], axes):
    try:
        r = results_csf.sel(source='Matimba').isel(polygon=i)
    except IndexError:
        continue
    ddeq.vis.plot_across_section(r, gases=['NO2'], method='gauss', ax=ax,
                                 legend='simple')

    ax.text(-36, 19.5, '%d-%d km' % (r.xa/1e3, r.xb/1e3), ha='left', va='top')

for ax in axes[1:]:
    ax.set_ylim(-1, 20)
    ax.set_ylabel('')


plt.tight_layout()

_images/example_tropomi_Matimba_17_0.png
[15]:
ddeq.vis.plot_csf_result(['NO2'], data, winds, results_csf, 'Matimba',
                         vmins=[0], vmaxs=[200e-6], crs=CRS);
_images/example_tropomi_Matimba_18_0.png

Gaussian plume inversion

[16]:
priors = {'Matimba': {
    'NO2': {'Q': 3.0,       # kg/s
            'tau': 4*60**2  # seconds
           }
}}
data, results_gauss = ddeq.gauss.estimate_emissions(data, winds, sources,
                                                    ['NO2'], priors=priors,
                                                    fit_decay_times=True)
[17]:
results_gauss = ddeq.emissions.convert_NO2_to_NOx_emissions(results_gauss, f=2.38)
[18]:
fig = ddeq.vis.plot_gauss_result(data, results_gauss, ['Matimba'],
                                 'NO2', crs=CRS, vmin=0, vmax=10e-6)
_images/example_tropomi_Matimba_22_0.png

IME method

[19]:
results_ime = ddeq.ime.estimate_emissions(
    data,
    winds,
    sources,
    "NO2",
    decay_time=4.0*60**2
)
results_ime = ddeq.emissions.convert_NO2_to_NOx_emissions(results_ime, f=1.32)
[20]:
ddeq.vis.plot_ime_result(
    "NO2",
    data,
    winds,
    results_ime,
    "Matimba",
    crs=CRS,
    vmin=0,
    vmax=300e-6,
);
_images/example_tropomi_Matimba_25_0.png

LCSF method

Quick example applying the LCSF method to TROPOMI NO2 data

[21]:
# Convert from mol/m2 to molec/cm2 for LCSF method
data["NO2"] = ucat.convert_columns(data["NO2"], "mol/m2", "cm-2")
data["NO2_std"] = ucat.convert_columns(data["NO2_std"], "mol/m2", "cm-2")
[22]:
wind_field = ddeq.era5.read(
    sng_filename,
    lvl_filename,
    method="gnfra",
    times=data.time
)
[23]:
lcs_params = {}
lcs_params['use_prior'] = True
lcs_params["verbose"] = True

lcs_params['n_min_fit_pts'] = 10
lcs_params['fit_pt_slice_width'] = 15

lcs_params['f_NOx_NO2'] = 3.5
lcs_params['NOx_NO2_tau_depletion'] = 4.0 * 60**2

lcsf_results = ddeq.lcsf.estimate_emissions(data, wind_field, sources, ['NO2'], priors=priors, lcs_params=lcs_params, all_diags=True)
print(f"NOx emissions: {np.nanmedian(lcsf_results['NO2_emissions']):.1f} kt NO2 / a")
LCSF parameters
{'use_prior': True, 'verbose': True, 'n_min_fit_pts': 10, 'fit_pt_slice_width': 15, 'f_NOx_NO2': 3.5, 'NOx_NO2_tau_depletion': 14400.0, 'min_estim_emis': None, 'max_estim_emis': None, 'Enhancement_thresh': 1.0, 'max_sigma_Gauss': 5.0, 'max_rel_std_emis': 1.0, 'window_length': 100}

**************

Estimating line densities and emissions for source Matimba

10 LD found !
NO2 median emission estimate  101.68162
NOx emissions: 101.7 kt NO2 / a
[24]:
fig = ddeq.vis.plot_lcsf_result("Matimba", lcsf_results, data, sources,
                                gases=['NO2'])
/home/docs/checkouts/readthedocs.org/user_builds/ddeq/envs/stable/lib/python3.12/site-packages/ddeq/vis.py:1097: UserWarning: To plot the polygons, a coordinate reference system (crs) must be provided.
  warnings.warn(
_images/example_tropomi_Matimba_30_1.png
[ ]: