Extracting Time Series at Multiple Polygons with Xarray

Extracting Time Series at Multiple Polygons with Xarray#

Introduction#

Zonal Statistics is used to summarize the values of a raster dataset within the zones of a vector dataset. We can also extend this process over time to extract data for multiple time-steps. The xvec library works with the vector geometries directly and handles a raster’s extra time dimension automatically, giving us a vector data cube of summary values for each zone at each time-step.

This is a large computation that is enabled by cloud-hosted NightTime Lights data in the Cloud-Optimized GeoTIFF (COG) format and parallel computing on a local dask cluster.

Overview of the Task#

We select all Admin1 units of a country and calculate a sum of nighttime light pixel intensities over multiple years.

Input Layers:

  • ne_10m_admin_1_states_provinces.zip: Admin-1 (States/Provinces) shapefile from Natural Earth

  • 2015.tif, 2016.tif, … 2020.tif: Yearly global rasters of nighttime light data. These files were download from Harvard Dataverse and converted to Cloud-Optimized GeoTIFFs using GDAL.

    Example command:

    gdalwarp -of COG 2021_HasMask/LongNTL_2021.tif 2021.tif \
      -te -180 -90 180 90 -dstnodata 0 \
      -co COMPRESS=DEFLATE -co PREDICTOR=2 -co NUM_THREADS=ALL_CPUS
    

Output Layers:

  • output.csv : A CSV file with the extracted sum of nighttime lights for each admin-1 region for each year.

Data Credit:

  • Made with Natural Earth. Free vector and raster map data @ naturalearthdata.com.

  • Chen, Zuoqi; Yu, Bailang; Yang, Chengshu; Zhou, Yuyu; Yao, Shenjun; Qian, Xingjian; Wang, Congxiao; Wu, Bin; Wu, Jianping; Liao, Lingxing; Shi, Kaifang, 2020, “The global NPP-VIIRS-like nighttime light data (Version 2) for 1992-2024”, https://doi.org/10.7910/DVN/YGIVCD, Harvard Dataverse, V8

Running the Notebook: The preferred way to run this notebook is on Google Colab. Open In Colab

Setup and Data Download#

The following blocks of code will install the required packages and download the datasets to your Colab environment.

%%capture
if 'google.colab' in str(get_ipython()):
    !pip install rioxarray xvec exactextract
import os
import glob
import pandas as pd
import geopandas as gpd
import numpy as np
import xarray as xr
import rioxarray as rxr
import matplotlib.pyplot as plt
from datetime import datetime
import dask
import xvec
data_folder = 'data'
output_folder = 'output'

if not os.path.exists(data_folder):
    os.mkdir(data_folder)
if not os.path.exists(output_folder):
    os.mkdir(output_folder)

Setup a local Dask cluster. This distributes the computation across multiple workers on your computer.

from dask.distributed import Client, progress
client = Client()  # set up local cluster on the machine
client

If you are running this notebook in Colab, you will need to create and use a proxy URL to see the dashboard running on the local server.

if 'google.colab' in str(get_ipython()):
    from google.colab import output
    port_to_expose = 8787  # This is the default port for Dask dashboard
    print(output.eval_js(f'google.colab.kernel.proxyPort({port_to_expose})'))
def download(url):
    filename = os.path.join(data_folder, os.path.basename(url))
    if not os.path.exists(filename):
        from urllib.request import urlretrieve
        local, _ = urlretrieve(url, filename)
        print('Downloaded ' + local)

admin1_zipfile = 'ne_10m_admin_1_states_provinces.zip'
admin1_url = 'https://naciscdn.org/naturalearth/10m/cultural/'

download(admin1_url + admin1_zipfile)

Data Pre-Processing#

First we will read the Admin-1 shapefile and filter to a country. The iso_a2 column contains the 2-digit ISO code for the country. Here we are seelcting all provinces in Sri Lanka.

country_code = 'LK'
admin1_file_path = os.path.join(data_folder, admin1_zipfile)

admin1_df = gpd.read_file(admin1_file_path)

zones = admin1_df[admin1_df['iso_a2'] == country_code]
zones = zones[['adm1_code', 'name', 'iso_a2', 'geometry']].copy()
zones['id'] = zones.reset_index().index + 1

Plot the selected zones.

zones.explore()
Make this Notebook Trusted to load map: File -> Trust Notebook

Next we read the Yearly Night-time Lights Rasters and create an Xarray Dataset.

start_year = 2015
end_year = 2020

data_folder = 'https://github.com/spatialthoughts/geopython-tutorials/releases/download/data/'

bbox = zones.total_bounds

da_list = []

for year in range(start_year, end_year + 1):
  print(f'Processing year: {year}')
  cog_url = f'{data_folder}/{year}.tif'
  da = rxr.open_rasterio(
      cog_url,
      chunks=True).rio.clip_box(*bbox)
  dt = pd.to_datetime(year, format='%Y')
  da = da.assign_coords(time = dt)
  da = da.expand_dims(dim="time")
  da_list.append(da)
ntl_datacube = xr.concat(da_list, dim='time').chunk('auto')
ntl_datacube

Since all the rasters have a single band of information, we select the first band and drop that dimension.

ntl_datacube = ntl_datacube.sel(band=1, drop=True)
ntl_datacube
<xarray.DataArray (time: 6, y: 870, x: 498)> Size: 10MB
dask.array<getitem, shape=(6, 870, 498), dtype=float32, chunksize=(6, 870, 498), chunktype=numpy.ndarray>
Coordinates:
  * time         (time) datetime64[us] 48B 2015-01-01 2016-01-01 ... 2020-01-01
  * y            (y) float64 7kB 9.828 9.823 9.819 9.814 ... 5.933 5.929 5.924
  * x            (x) float64 4kB 79.66 79.66 79.66 79.67 ... 81.88 81.88 81.89
    spatial_ref  int64 8B 0
Attributes:
    DataType:            Generic
    AREA_OR_POINT:       Area
    BandName:            Band_1
    RepresentationType:  ATHEMATIC
    scale_factor:        1.0
    add_offset:          0.0
    long_name:           Band_1
    _FillValue:          0.0

Zonal Stats#

Now we will extract the sum of the raster pixel values for every admin-1 region in the selected country.

We will use xvec.zonal_stats() to summarize the raster for each zone. Unlike a rasterization-based approach, xvec works with the polygon geometries directly and handles the extra time dimension of the data cube automatically.

When doing Zonal Stats it is preferred to keep the raster in its original projection to minimize distortions, so we reproject the zone polygons to match the CRS of the raster.

zones_reprojected = zones.to_crs(ntl_datacube.rio.crs)

Next we call zonal_stats() on the data cube, passing the zone geometries. We use the exactextract method, which weights each pixel by the fraction of its area that falls within the polygon. Since the raster has a time dimension, the result is a DataArray with both time and geometry dimensions holding the sum of nighttime lights for each region for each year.

Dask will now distribute the computation across all chunks using the available workers.

result = ntl_datacube.xvec.zonal_stats(
    zones_reprojected.geometry,
    x_coords='x',
    y_coords='y',
    stats='sum',
    method='exactextract',
)
result
<xarray.DataArray (geometry: 25, time: 6)> Size: 1kB
array([[  450.26603581,   453.70126462,   641.04358799,   657.22159708,
          856.091326  ,  1023.99087471],
       [   50.16448297,    73.07132264,   138.93583961,   121.99383717,
          166.44989129,   242.99517986],
       [  891.88293825,   915.62305599,  1298.32442202,  1468.99126295,
         2131.75061103,  2809.644519  ],
       [   99.36647946,   113.05515795,   167.23999197,   153.38448357,
          206.97234836,   286.40056414],
       [   29.0537679 ,    29.12108924,    52.9680531 ,    79.53265119,
          122.70140373,   172.9789513 ],
       [ 2145.26039143,  1926.18740574,  2972.69804859,  3546.66011615,
         3435.32932461,  3406.12707345],
       [10953.58258164, 12576.30877821, 13450.68885318, 13803.71030424,
        14561.96517443, 14482.69555725],
       [ 9896.81902859, 11158.30323266, 12596.42662386, 13189.79185618,
        13638.94207727, 13513.16369465],
       [ 1783.76065741,  1834.41376697,  2507.19742514,  3066.66509248,
         3275.79683127,  3568.82704114],
       [ 1319.813625  ,  1338.81657387,  1713.74730933,  1870.06341453,
         2261.70063515,  2437.10363256],
...
       [  214.07716233,   200.1128915 ,   287.17256792,   288.64389756,
          335.96430141,   337.18260289],
       [  315.94823737,   316.93802912,   476.75468882,   467.04091408,
          569.18596505,   525.57743659],
       [  242.09615554,   248.41290181,   345.5923925 ,   316.27093058,
          384.3273339 ,   449.77266286],
       [  220.35957012,   289.52752202,   345.22165926,   284.13594128,
          533.52955881,   644.47392377],
       [  189.64425855,   232.43943408,   336.70497452,   305.02335929,
          512.510442  ,   541.6464435 ],
       [  829.75841711,   897.03189331,  1420.64956658,  1572.92072264,
         1930.98348809,  1769.02609777],
       [  618.51799277,   705.5887899 ,   842.81036425,   873.81159251,
         1123.54177189,  1149.90086383],
       [  238.61271622,   210.88316452,   339.16890904,   337.9022319 ,
          453.22194714,   414.20207168],
       [  188.29905581,   172.31998301,   246.94273973,   217.27999914,
          281.46499801,   367.92846693],
       [  808.33612808,   857.22741893,  1164.44139476,  1226.92326356,
         1527.48153459,  1540.34361513]])
Coordinates:
  * geometry  (geometry) geometry 200B POLYGON ((80.92291925018093 8.98891605...
    index     (geometry) int64 200B 1859 1860 1861 1862 ... 4110 4111 4112 4113
  * time      (time) datetime64[us] 48B 2015-01-01 2016-01-01 ... 2020-01-01
Indexes:
    geometry  GeometryIndex (crs=EPSG:4326)
Attributes:
    DataType:            Generic
    AREA_OR_POINT:       Area
    BandName:            Band_1
    RepresentationType:  ATHEMATIC
    scale_factor:        1.0
    add_offset:          0.0
    long_name:           Band_1
    _FillValue:          0.0

It will be useful to carry over some attributes from the original GeoDataFrame. As we have an XArray vector data cube, we add them as coordinate variables along the geometry dimension.

for column in ['adm1_code', 'name', 'iso_a2']:
    result[column] = ('geometry', zones_reprojected[column].values)
    result = result.assign_coords({column: result[column]})
result

Convert the results to a Pandas DataFrame. We now have the total of nighttime lights values for each region for each year.

stats = result.drop_vars('spatial_ref', errors='ignore').to_dataframe('sum').reset_index()
stats['year'] = stats['time'].dt.year
stats.head()
geometry time index adm1_code name iso_a2 sum year
0 POLYGON ((80.92292 8.98892, 80.92319 8.97321, ... 2015-01-01 1859 LKA-2454 Trikuṇāmalaya LK 450.266036 2015
1 POLYGON ((80.92292 8.98892, 80.92319 8.97321, ... 2016-01-01 1859 LKA-2454 Trikuṇāmalaya LK 453.701265 2016
2 POLYGON ((80.92292 8.98892, 80.92319 8.97321, ... 2017-01-01 1859 LKA-2454 Trikuṇāmalaya LK 641.043588 2017
3 POLYGON ((80.92292 8.98892, 80.92319 8.97321, ... 2018-01-01 1859 LKA-2454 Trikuṇāmalaya LK 657.221597 2018
4 POLYGON ((80.92292 8.98892, 80.92319 8.97321, ... 2019-01-01 1859 LKA-2454 Trikuṇāmalaya LK 856.091326 2019

Select the columns we need for the output.

output = stats[['adm1_code', 'name', 'iso_a2', 'year', 'sum']]
output.head()
adm1_code name iso_a2 year sum
0 LKA-2454 Trikuṇāmalaya LK 2015 450.266036
1 LKA-2454 Trikuṇāmalaya LK 2016 453.701265
2 LKA-2454 Trikuṇāmalaya LK 2017 641.043588
3 LKA-2454 Trikuṇāmalaya LK 2018 657.221597
4 LKA-2454 Trikuṇāmalaya LK 2019 856.091326

Finally, we save the result to disk.

output_file = 'output.csv'
output_path = os.path.join(output_folder, output_file)

output.to_csv(output_path, index=False)
print('Successfully written output file at {}'.format(output_path))

If you want to give feedback or share your experience with this tutorial, please comment below. (requires GitHub account)