Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/NASAARSET/PM2.5_AQ_Online_2026/llms.txt

Use this file to discover all available pages before exploring further.

This notebook-based case study applies the full satellite PM₂.₅ analysis workflow to one of South America’s most dramatic recent air-quality events: the February 2023 Central Chile megafires. Working with the SatPM V6.GL.03 monthly product and the hourly MERRA-2 CNN dataset, you will download data, compare them to ground monitors sourced directly from Chile’s SINCA network, and perform a suite of map, time-series, and statistical analyses across a domain spanning Santiago to Concepción.
This notebook runs entirely inside Google Colab and requires (1) a free NASA Earthdata account and (2) a free OpenAQ API key. Both are obtained online in a few minutes.

Open in Google Colab

🇺🇸 Exercise (EN)

English exercise notebook — fill-in-the-blank cells omitted for training

🇺🇸 Complete (EN)

English complete notebook — all cells pre-filled

🇪🇸 Ejercicio (ES)

Cuaderno de ejercicios en español

🇪🇸 Completo (ES)

Cuaderno completo en español

Notebook authors

  • Carl Malings — Morgan State University & NASA Goddard Space Flight Center
  • Sebastián Diez — Centro de Investigación en Tecnologías para la Sociedad, Universidad del Desarrollo (Chile)

Notebook walkthrough

1
Background
2
The notebook opens with context on why this event is scientifically interesting. The February 2023 megafires in Central Chile produced extreme PM₂.₅ episodes visible from both satellites and ground monitors. Because the fires generated data that many monitoring networks initially flagged as not validated, ground-truth retrieval requires care — motivating the SINCA-vs-OpenAQ comparison covered in a later step.
3
Setup: install packages, mount Drive, and log in
4
The setup cell installs and imports every dependency, mounts your Google Drive so downloaded data can persist, and prompts for credentials.
5
# Install & Import the required packages:
!pip install --quiet cartopy earthaccess

import os, getpass
from google.colab import drive
import warnings; warnings.filterwarnings('ignore')

import numpy as np
import pandas as pd
import xarray as xr

import earthaccess, requests, urllib, re, time
from urllib.parse import urljoin
from bs4 import BeautifulSoup

import matplotlib.pyplot as plt
import cartopy
import seaborn as sns

# Connect to Google Drive:
drive.mount('/content/drive')

# Store login information:
earthaccess.login()
s_key_openAQ = getpass.getpass('Enter OpenAQ API key:')
6
Save a copy of this notebook to your own Google Drive (File → Save a Copy in Drive) before running it. Create two sub-folders — SatPM_Data and MERRA2_CNN_Data — inside your project folder so downloaded files stay organised.
7
Define useful functions
8
Four helper functions are defined here and reused throughout the notebook. All are documented inline with full docstrings.
9
FunctionPurposeF_subset_and_combineOpens a list of NetCDF files, clips each to the spatial and temporal bounding box, and concatenates them into a single xarray objectF_get_OpenAQ_from_APIQueries the OpenAQ v3 REST API for reference PM₂.₅ monitors inside the bounding box and returns a (lat, lon, time)-indexed pandas DataFrameF_plot_mapRenders gridded (xarray) or point (pandas) PM₂.₅ data on a Cartopy PlateCarree map with coastlines, borders, and a colour barF_compute_metricsComputes Bias, RMSE, Mean Normalised Bias/RMSE, R², spatial correlation, and temporal correlation between a measured column and an estimated column — see /api/f-compute-metrics
10
def F_compute_metrics(f_data,
                      s_time   = 'time_month_start',
                      s_value  = 'Average',
                      s_estimate = 'SatPM',
                      b_print  = True):
    d_metrics = {}
    f_data['error'] = f_data[s_estimate] - f_data[s_value]
    d_metrics['Estimated Mean']       = f_data[s_estimate].mean()
    d_metrics['Measured Mean']        = f_data[s_value].mean()
    d_metrics['Bias']                 = f_data['error'].mean()
    d_metrics['Mean Normalized Bias'] = d_metrics['Bias'] / d_metrics['Measured Mean']
    d_metrics['RMSE']                 = np.sqrt((f_data['error']**2).mean())
    d_metrics['Mean Normalized RMSE'] = d_metrics['RMSE'] / d_metrics['Measured Mean']
    d_metrics['R^2'] = (
        1 - (f_data['error']**2).sum()
          / ((f_data[s_value] - d_metrics['Measured Mean'])**2).sum()
    )
    # ... spatial and temporal correlation loops omitted for brevity
    return d_metrics
11
Define SINCA data-scraping functions
12
Chile’s national air-quality monitoring network SINCA (Sistema de Información Nacional de Calidad del Aire) is the preferred ground-truth source for this case study. A complete adapter — F_get_SINCA_from_web — is defined here and returns data in exactly the same (lat, lon, time) indexed format as F_get_OpenAQ_from_API, so the rest of the notebook is data-source agnostic.
13
Why not just use OpenAQ for Chile?OpenAQ ingests mostly validated records. Chilean SINCA stations report fire-episode data as preliminary or not validated, so those peak values are largely absent from OpenAQ — precisely the extreme concentrations this case study targets. Additionally, OpenAQ ingests SINCA data as 24-hour rolling averages rather than hourly averages, further masking fire-episode peaks.SINCA itself exposes three parallel series per station — validated, preliminary, and not_validated — and the parser below consumes all three (preferring validated → preliminary → not_validated). In general, always check the original data source; local researchers and experts who collected the data will know best how to use it appropriately.
14
The scraper navigates SINCA’s public website using requests and BeautifulSoup, identifies PM₂.₅ stations in the requested regions, downloads hourly CSV data through the SINCA CGI endpoint, converts Chilean local time (America/Santiago) to UTC, and clips results to the bounding box.
15
def F_get_SINCA_from_web(n_lon_min, n_lat_min, n_lon_max, n_lat_max,
                         t_start, t_end, regions, l_variables=["PM2.5"]):
    """Drop-in replacement for F_get_OpenAQ_from_API, reading from SINCA.

    Returns a pandas DataFrame with a 3-level (lat, lon, time) index and a
    'PM2.5' column, with time in UTC — exactly the format the rest of the
    notebook expects. SINCA serves whole regions, so pass the region IDs that
    cover the bounding box; results are then clipped to the box.
    """
    frames = []
    for region_id in as_list(regions):
        df_st = get_sinca_region_stations(region_id)
        for _, row in df_st.iterrows():
            d = _download_one_station_pm25(row, pd.to_datetime(t_start),
                                           pd.to_datetime(t_end))
            if d is not None and len(d):
                frames.append(d)
    df = pd.concat(frames, ignore_index=True)
    # Convert Chilean local time → UTC:
    df["time"] = (df["datetime_local"]
                  .dt.tz_localize("America/Santiago",
                                  ambiguous="NaT", nonexistent="NaT")
                  .dt.tz_convert("UTC"))
    df = df[df["lat"].between(n_lat_min, n_lat_max)
          & df["lon"].between(n_lon_min, n_lon_max)]
    f_data = (df[["lat", "lon", "time", "PM2.5"]]
              .dropna(subset=["PM2.5"])
              .groupby(["lat", "lon", "time"]).mean())
    return f_data
16
The SINCA region IDs used in this case study are Roman-numeral region codes: V (Valparaíso), M (Metropolitana), VI (O’Higgins), VII (Maule), XVI (Ñuble), VIII (Biobío), IX (Araucanía).
17
Define the domain of interest
18
The spatial domain covers the area from Santiago southward to Concepción, and the time window spans the full austral summer surrounding the February 2023 fires.
19
# Define the bounding box:
n_lat_min = -38
n_lat_max = -32
n_lon_min = -73.75
n_lon_max = -69.75

# Define the start and end time:
t_start = np.datetime64('2022-12-01 00:00:00')
t_end   = np.datetime64('2023-03-31 23:59:59')

# Specify the local time zone:
s_timezome = 'America/Santiago'

# Preview the selected region:
F_plot_map(n_lat_min=n_lat_min, n_lat_max=n_lat_max,
           n_lon_min=n_lon_min, n_lon_max=n_lon_max)
20
All timestamps in the notebook are stored as UTC. The s_timezome variable is used only when converting UTC timestamps to local time for diel-cycle and daily-average visualisations.
21
Download SatPM data
22
Monthly SatPM V6.GL.03 files are fetched directly from the SatPM S3 bucket. The loop constructs the URL for each month in the analysis window, skips files already on disk, and calls F_subset_and_combine to crop every file to the bounding box before concatenating.
23
s_folder_SatPM = '/content/drive/MyDrive/Colab Notebooks/ARSET/PM25_Training_2026/SatPM_Data'

l_files_SatPM = []
for t_month in np.arange(t_start.astype('datetime64[M]'),
                          t_end.astype('datetime64[M]') + np.timedelta64(1,'M'),
                          np.timedelta64(1,'M')):
    s_year  = str(t_month)[0:4]
    s_month = str(t_month)[5:7]
    s_url = (f'https://s3.amazonaws.com/satpmdata/V6GL03/FineResolution/GL/'
             f'Monthly/{s_year}/V6GL03.CNNPM25.GL.{s_year}{s_month}-{s_year}{s_month}.nc')
    s_download_path = os.path.join(s_folder_SatPM, s_url.split('/')[-1])
    if not os.path.exists(s_download_path):
        urllib.request.urlretrieve(s_url, s_download_path)
    l_files_SatPM.append(s_download_path)

def F_time_from_SatPM_url(s_url):
    s_YYYYMM = s_url.split('.')[-2].split('-')[0]
    return np.datetime64(f'{s_YYYYMM[0:4]}-{s_YYYYMM[4:6]}-01 00:00:00')

a_data_SatPM = F_subset_and_combine(
    l_files_SatPM, n_lon_min, n_lat_min, n_lon_max, n_lat_max,
    t_start, t_end, F_time_from_file=F_time_from_SatPM_url)

# Filter out negative values:
a_data_SatPM['PM25_filtered'] = a_data_SatPM['PM25'].where(a_data_SatPM['PM25'] > 0)
a_data_SatPM.to_netcdf(os.path.join(s_folder_SatPM, 'SatPM_data_subset.nc'))
24
After downloading, the cell renders a quick map plot so you can visually verify coverage before proceeding.
25
Download MERRA-2 CNN data
26
Hourly MERRA-2 CNN files are searched and downloaded with the earthaccess Python library, which handles NASA Earthdata authentication automatically.
27
s_folder_MERRA2_CNN = '/content/drive/MyDrive/Colab Notebooks/ARSET/PM25_Training_2026/MERRA2_CNN_Data'

l_results = earthaccess.search_data(
    short_name='MERRA2_CNN_HAQAST_PM25',
    bounding_box=(n_lon_min, n_lat_min, n_lon_max, n_lat_max),
    temporal=(str(t_start), str(t_end))
)
l_files_MERRA2_CNN = earthaccess.download(l_results, s_folder_MERRA2_CNN)

a_data_MERRA2_CNN = F_subset_and_combine(
    l_files_MERRA2_CNN, n_lon_min, n_lat_min, n_lon_max, n_lat_max, t_start, t_end)

# Keep only quality-flagged values (QFLAG >= 3):
a_data_MERRA2_CNN['PM25_filtered'] = (
    a_data_MERRA2_CNN['MERRA2_CNN_Surface_PM25']
    .where(a_data_MERRA2_CNN['QFLAG'] >= 3))

a_data_MERRA2_CNN.to_netcdf(os.path.join(s_folder_MERRA2_CNN, 'MERRA2_CNN_data_subset.nc'))
28
MERRA-2 CNN data are available at 0.5° latitude × 0.625° longitude resolution with hourly time steps. The QFLAG >= 3 filter retains only retrievals the algorithm considers reliable.
29
Download monitor data: OpenAQ vs. SINCA comparison
30
Both data sources are queried for the same domain and time window, then plotted side-by-side at a single Santiago station to illustrate the validation-status issue.
31
s_folder_Main = '/content/drive/MyDrive/Colab Notebooks/ARSET/PM25_Training_2026'

# Query OpenAQ (reference monitors only):
f_data_monitors_OpenAQ = F_get_OpenAQ_from_API(
    n_lon_min, n_lat_min, n_lon_max, n_lat_max, t_start, t_end)

# Query SINCA directly:
f_data_monitors_SINCA = F_get_SINCA_from_web(
    n_lon_min, n_lat_min, n_lon_max, n_lat_max,
    t_start, t_end,
    regions=['V', 'M', 'VI', 'VII', 'XVI', 'VIII', 'IX'])
32
The comparison time-series plot at Parque O’Higgins (lat −33.464142, lon −70.660797) shows that OpenAQ data are much smoother (24-hour rolling values) and missing the sharpest peaks from the February fires. SINCA hourly data capture those peaks clearly. After this comparison the notebook sets f_data_monitors = f_data_monitors_SINCA and filters out physically implausible values (PM2.5 < 0 or > 2000 µg/m³).
33
# Side-by-side time-series comparison at Parque O'Higgins:
o_fig = plt.figure(figsize=(15, 5))
o_ax  = o_fig.add_subplot()
o_ax.plot(f_data_monitors_OpenAQ.loc[(-33.464142, -70.660797)].index,
          f_data_monitors_OpenAQ.loc[(-33.464142, -70.660797)]['PM2.5'],
          color='blue', label="Data from OpenAQ")
o_ax.plot(f_data_monitors_SINCA.loc[(-33.464142, -70.660797)].index,
          f_data_monitors_SINCA.loc[(-33.464142, -70.660797)]['PM2.5'],
          color='orange', label="Data from SINCA")
o_ax.legend()
o_ax.set_ylabel('PM$_{2.5}$ [µg/m$^{3}$]')
o_ax.set_title("Data comparison at Parque O'Higgins")
34
Analysis: maps
35
The analysis section opens by reloading any subset files cached to Drive (handy when returning to the notebook later), then generates two overlay maps — SatPM and MERRA-2 CNN with SINCA monitor points superimposed as coloured scatter points.
36
# Overlay monitors on the SatPM map:
F_plot_map(a_data_plot       = a_data_SatPM['PM25_filtered'],
           a_data_plot_point = f_data_monitors['PM25_filtered'],
           n_lat_min=n_lat_min, n_lat_max=n_lat_max,
           n_lon_min=n_lon_min, n_lon_max=n_lon_max,
           Colorbar_Label  = 'PM$_{2.5}$ [µg/m$^{3}$]',
           Plot_Title      = f'Comparing SatPM to Monitors\n(Average {t_start} to {t_end})',
           Colorbar_Limits = [0, 50])
37
Analysis: monthly averages vs. SatPM
38
Monitor data are aggregated to monthly averages (requiring at least 24 × 20 = 480 valid hourly samples per station-month) and matched to the co-located SatPM grid point. A scatter plot colour-coded by month and F_compute_metrics output quantify agreement.
39
The 480-sample threshold corresponds to 20 full days of valid hourly data in a month. Adjust n_minimum_samples_per_month if your region has sparser coverage.
40
Analysis: hourly averages vs. MERRA-2 CNN (with MERRA-2 gridding)
41
Direct hourly comparison is possible because MERRA-2 CNN provides hourly output. To enable a spatially fair comparison, monitor stations are first snapped to the nearest MERRA-2 grid cell (0.5° lat × 0.625° lon) and averaged together — a MERRA-2 pixel often covers multiple stations. A grid cell must contain at least 3 valid monitor samples at that hour to be included.
42
n_minimum_samples_per_grid = 3

# Create a new data frame for helping to compute the grid averages:
f_data_monitors_gridded = f_data_monitors.copy().reset_index()

# Assign each monitor to the nearest MERRA-2 grid centre:
f_data_monitors_gridded['lat_grid'] = round(f_data_monitors_gridded['lat'] / 0.5)  * 0.5
f_data_monitors_gridded['lon_grid'] = round(f_data_monitors_gridded['lon'] / 0.625) * 0.625

# Group by grid cell and hour — compute count, mean, and standard deviation:
f_data_combined_gridded = (
    f_data_monitors_gridded
    .groupby(['lat_grid', 'lon_grid', 'time'])[['PM25_filtered']]
    .count()
    .rename(columns={'PM25_filtered': 'Count'})
)
f_data_combined_gridded['Average'] = (
    f_data_monitors_gridded
    .groupby(['lat_grid', 'lon_grid', 'time'])[['PM25_filtered']]
    .mean()
)
f_data_combined_gridded['StDev'] = (
    f_data_monitors_gridded
    .groupby(['lat_grid', 'lon_grid', 'time'])[['PM25_filtered']]
    .std()
)

# Enforce the minimum-samples filter:
f_data_combined_gridded = (
    f_data_combined_gridded
    .where(f_data_combined_gridded['Count'] >= n_minimum_samples_per_grid)
    .dropna()
)
43
Local time-of-day and local date columns are appended at this stage for the diel-cycle and daily-average analyses that follow.
44
Analysis: diel cycle comparison
45
The 24-hour median and interquartile range are computed for both monitor grids and MERRA-2 CNN to visualise whether the model captures the diurnal PM₂.₅ pattern (e.g., overnight wood-burning peaks in Chilean winter).
46
Analysis: daily averages vs. MERRA-2 CNN
47
Gridded hourly data are further aggregated to local calendar days and a second scatter-plot / metrics block assesses performance at the daily scale — typically showing improved R² relative to hourly comparison because averaging reduces noise.
48
Analysis: threshold analysis
49
A configurable daily-average threshold (n_threshold = 100 µg/m³ for this fire event) splits the dataset into “above threshold” (fire-affected) and “below threshold” (background) periods. Performance metrics are computed separately for each regime to examine whether the satellite products track background conditions better than extreme fire episodes.
50
n_threshold = 100  # µg/m³ daily average

f_data_combined_gridded['Daily_Average_Above_Threshold'] = False
for i_row in range(len(f_data_combined_gridded)):
    n_lat  = f_data_combined_gridded.index[i_row][0]
    n_lon  = f_data_combined_gridded.index[i_row][1]
    t_time = f_data_combined_gridded.index[i_row][2]
    t_day  = f_data_combined_gridded.loc[(n_lat, n_lon, t_time), 'local_day']
    if f_data_combined_gridded_daily.loc[(n_lat, n_lon, t_day), 'Average'] > n_threshold:
        f_data_combined_gridded.loc[(n_lat, n_lon, t_time),
                                    'Daily_Average_Above_Threshold'] = True

# Metrics for non-fire days only:
F_compute_metrics(
    f_data_combined_gridded[
        f_data_combined_gridded['Daily_Average_Above_Threshold'] == False],
    s_time='time', s_value='Average', s_estimate='MERRA2_CNN')
51
Analysis: time series for key locations
52
The final cell plots daily-average time series for three pre-defined MERRA-2 grid locations, contrasting monitor observations (black) against MERRA-2 CNN estimates (red). The February 2023 fire signal is clearest at Talcahuano y Concepción, which sits at the epicentre of the fires.
53
d_location = {
    'Santiago':                 (-33.5,  -70.625),
    'Viña del Mar':             (-33.0,  -71.25),
    'Talcahuano y Concepción':  (-37.0,  -73.125),
}
s_location = 'Talcahuano y Concepción'

f_ts = f_data_combined_gridded_daily.loc[d_location[s_location]]

o_fig = plt.figure(figsize=(15, 5))
o_ax  = o_fig.add_subplot()
o_ax.plot(f_ts.index, f_ts['Average'],    color='black', label='Monitor Average')
o_ax.plot(f_ts.index, f_ts['MERRA2_CNN'], color='red',   label='MERRA-2 CNN Average')
o_ax.legend()
o_ax.set_ylabel('Daily Average PM$_{2.5}$ [µg/m$^{3}$]')
o_ax.set_title(f'Location: {s_location}')

Key concepts and cross-references

ConceptDetails
Ground-truth networkSINCA — Chile’s national air-quality monitoring network; exposes hourly validated, preliminary, and not-validated PM₂.₅ data
Aggregator comparisonOpenAQ — ingests mostly validated records; for fire episodes this means missing not-validated peaks
Monthly satellite productSatPM V6.GL.03 — 0.01° resolution, global monthly PM₂.₅ estimates
Hourly satellite productMERRA-2 CNN (DOI: 10.5067/OCKK5HCFW5N3) — 0.5° × 0.625°, hourly
Performance metricsF_compute_metrics — computes Bias, RMSE, MNB, MNRMSE, R², spatial and temporal correlations
MERRA-2 grid snappingMonitors snapped to nearest 0.5° lat × 0.625° lon grid centre; minimum 3 valid samples required per grid cell per hour
The SINCA scraper makes multiple HTTP requests to sinca.mma.gob.cl. Run it during off-peak hours if possible, and respect a small sleep between station requests (time.sleep(0.2) is included in the code). The website structure may change; if scraping fails, download data manually from the SINCA portal.

Build docs developers (and LLMs) love