This homework notebook adapts the full PM₂.₅ satellite analysis workflow — introduced in the Case Study — to a new region and time period that you configure yourself. You will study New Delhi, India during the pre-monsoon and early monsoon period of June through August 2019, comparing SatPM V6.GL.03 monthly estimates and MERRA-2 CNN hourly estimates against reference monitors retrieved from OpenAQ. The structure mirrors the case study exactly, but key domain parameters are replaced byDocumentation 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.
???? placeholders that you fill in as part of the assignment.
This notebook requires the same credentials as the case study: a NASA Earthdata account and an OpenAQ API key. Unlike the case study, no SINCA scraper is needed — OpenAQ has good coverage for Indian reference monitors.
Open in Google Colab
🇺🇸 Exercise (EN)
English homework notebook — fill in the
???? placeholders🇪🇸 Ejercicio (ES)
Cuaderno de tarea en español
Complete (answer-key) versions of the homework notebook are not publicly distributed. Check with your course instructor for access.
What the ???? placeholders mean
The exercise notebook ships with several cells containing literal ???? tokens. These are intentional gaps that you must replace with correct Python values before the cell will execute successfully. The table below lists every placeholder and the expected answer.
| Placeholder location | Variable | Value to fill in |
|---|---|---|
| Domain definition | n_lat_min | 27.5 |
| Domain definition | n_lat_max | 30 |
| Domain definition | n_lon_min | 76 |
| Domain definition | n_lon_max | 78.5 |
| Domain definition | t_start | np.datetime64('2019-06-01 00:00:00') |
| Domain definition | t_end | np.datetime64('2019-08-31 23:59:59') |
| Domain definition | s_timezome | 'Asia/Kolkata' |
| Threshold analysis | n_threshold | 50 (µg/m³) |
Notebook walkthrough
The notebook opens with the same framing as the case study: this is part of the NASA ARSET training on estimating surface PM₂.₅ from satellite data. The background cell notes that this version is intended for the Homework Assignment, signalling that certain cells are intentionally incomplete.
New Delhi is one of the world’s most PM₂.₅-polluted mega-cities. The June–August 2019 window captures the transition from the hot, dusty pre-monsoon season into early monsoon, providing a range of concentration regimes for students to explore.
Identical to the case study setup cell. All the same libraries are installed and imported, Google Drive is mounted, and Earthdata / OpenAQ credentials are collected.
# 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
drive.mount('/content/drive')
earthaccess.login()
s_key_openAQ = getpass.getpass('Enter OpenAQ API key:')
F_subset_and_combine — opens, clips, and concatenates NetCDF filesF_get_OpenAQ_from_API — queries OpenAQ v3 for reference monitorsF_plot_map — renders PM₂.₅ data on a Cartopy mapF_compute_metrics — computes Bias, RMSE, MNB, MNRMSE, R², spatial and temporal correlations (see /api/f-compute-metrics)No SINCA functions are defined — India does not have an equivalent publicly scrapeable national network, and OpenAQ provides adequate reference-monitor coverage for New Delhi.
This is the first cell containing
???? placeholders. You must supply the bounding box coordinates, time range, and timezone for New Delhi.# Define the bounding box:
n_lat_min = ???? # → 27.5
n_lat_max = ???? # → 30
n_lon_min = ???? # → 76
n_lon_max = ???? # → 78.5
# Define the start and end time:
t_start = np.datetime64('YYYY-MM-DD hh:mm:ss') # → '2019-06-01 00:00:00'
t_end = np.datetime64('YYYY-MM-DD hh:mm:ss') # → '2019-08-31 23:59:59'
# Specify the local time zone:
s_timezome = '????' # → 'Asia/Kolkata'
# 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)
Once filled in correctly,
F_plot_map will render a blank map of the New Delhi region so you can visually confirm the bounding box is correct before downloading any data.India Standard Time (IST) is UTC+5:30. The TZ database identifier is
'Asia/Kolkata'. You can verify this with Python: import pytz; pytz.timezone('Asia/Kolkata').Identical logic to the case study: the notebook loops over months in the analysis window, constructs the SatPM S3 URL for each month, downloads the file if not already present, and calls
F_subset_and_combine to produce a cropped xarray dataset.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)
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_homework.nc'))
The subsequent plot cell uses
Colorbar_Limits=[0, 60] — note this is higher than the case study default of [0, 40], reflecting the higher baseline PM₂.₅ concentrations expected in the New Delhi region.The
earthaccess search-and-download pattern is identical to the case study. The QFLAG >= 3 filter is applied to retain only reliable hourly retrievals, and results are saved to MERRA2_CNN_data_subset_homework.nc.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)
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_homework.nc'))
For India, OpenAQ is the primary and only ground-truth source in this notebook. Reference monitors (i.e.,
b_reference=True, b_lowcost=False) are queried for the bounding box and time window. There is no SINCA-vs-OpenAQ comparison step — the notebook proceeds directly with OpenAQ data.s_folder_Main = '/content/drive/MyDrive/Colab Notebooks/ARSET/PM25_Training_2026'
f_data_monitors = F_get_OpenAQ_from_API(
n_lon_min, n_lat_min, n_lon_max, n_lat_max, t_start, t_end)
# Filter out physically implausible values:
f_data_monitors['PM25_filtered'] = f_data_monitors['PM2.5'].where(
(f_data_monitors['PM2.5'] > 0) & (f_data_monitors['PM2.5'] < 1000))
f_data_monitors.to_csv(os.path.join(s_folder_Main, 'Monitor_data_subset_homework.csv'))
The upper filter is 1000 µg/m³ here (vs. 2000 µg/m³ in the Chile case study), reflecting the lower peak values expected in the New Delhi summer compared to a wildfire episode.
Two overlay maps are produced — SatPM and MERRA-2 CNN with OpenAQ monitor scatter points superimposed — using a colour scale of
[0, 60] µg/m³ appropriate for South Asian background PM₂.₅ levels.# 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, 60])
# Overlay monitors on the MERRA-2 CNN map:
F_plot_map(a_data_plot = a_data_MERRA2_CNN['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 MERRA-2 CNN to Monitors\n(Average {t_start} to {t_end})',
Colorbar_Limits = [0, 60])
Monitor data are aggregated to monthly averages (minimum 480 valid hourly samples per station-month) and the nearest SatPM and MERRA-2 CNN grid values are joined in. Scatter plots colour-coded by month and
F_compute_metrics output provide the quantitative comparison.Monitors are snapped to the nearest MERRA-2 grid cell (0.5° lat × 0.625° lon) and averaged per grid cell per hour, with a minimum of 3 valid samples required. The gridded dataset is augmented with
local_day and local_hour_of_day columns (using Asia/Kolkata timezone) for downstream analyses.n_minimum_samples_per_grid = 3
f_data_monitors_gridded = f_data_monitors.copy().reset_index()
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
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 = (
f_data_combined_gridded
.where(f_data_combined_gridded['Count'] >= n_minimum_samples_per_grid)
.dropna()
)
Median PM₂.₅ and the interquartile range (25th–75th percentile band) are plotted against local hour of day for both monitors (black) and MERRA-2 CNN (red). In New Delhi, the diel pattern is strongly influenced by traffic and boundary-layer dynamics, providing a meaningful test of MERRA-2 CNN’s diurnal accuracy.
Gridded hourly values are further aggregated to local calendar days. A scatter plot coloured by MERRA-2 latitude grid value and
F_compute_metrics output quantify agreement at the daily scale.This cell contains the second
???? placeholder. You must set n_threshold to the value specified in the homework instructions.# Set the desired threshold for analysis:
n_threshold = ???? # → 50 (µg/m³, as instructed for the homework)
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
# Performance metrics for days below the threshold:
print('Performance Metrics for MERRA-2 CNN (values below threshold):')
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')
The threshold of 50 µg/m³ corresponds to the Indian National Ambient Air Quality Standard (NAAQS) 24-hour average limit for PM₂.₅. Days above this threshold are classified as “polluted”; the metrics below threshold show how well the models capture relatively cleaner conditions.
Three New Delhi grid cells are pre-populated in the homework notebook. The initial active location is set to
'New Delhi (East)', but you can change s_location to explore any of the three.# Pre-populated MERRA-2 grid locations for New Delhi:
d_location = {
'New Delhi (East)': (28.5, 77.5),
'New Delhi (West)': (28.5, 76.875),
'New Delhi (North)': (29.0, 76.875),
}
s_location = 'New Delhi (East)'
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}')
Summary of ???? placeholders
| Cell | Variable | Correct value |
|---|---|---|
| Domain definition | n_lat_min | 27.5 |
| Domain definition | n_lat_max | 30 |
| Domain definition | n_lon_min | 76 |
| Domain definition | n_lon_max | 78.5 |
| Domain definition | t_start | np.datetime64('2019-06-01 00:00:00') |
| Domain definition | t_end | np.datetime64('2019-08-31 23:59:59') |
| Domain definition | s_timezome | 'Asia/Kolkata' |
| Threshold analysis | n_threshold | 50 |
Key concepts and cross-references
| Concept | Details |
|---|---|
| Ground-truth network | OpenAQ — global aggregator of reference PM₂.₅ monitors; provides hourly validated records for India |
| Monthly satellite product | SatPM V6.GL.03 — 0.01° resolution, global monthly PM₂.₅ |
| Hourly satellite product | MERRA-2 CNN (DOI: 10.5067/OCKK5HCFW5N3) — 0.5° × 0.625°, hourly |
| Performance metrics | F_compute_metrics — Bias, RMSE, MNB, MNRMSE, R², spatial and temporal correlations |
| MERRA-2 grid snapping | Snap to nearest 0.5° lat × 0.625° lon grid centre; minimum 3 valid samples per grid cell per hour |
| Timezone | Asia/Kolkata (IST = UTC+5:30) for diel-cycle and daily-average calculations |
