Python plot data on a triangular grid with tripcolor#

This example shows how the ICON data can be displayed with Python on the triangular grid. Anyone who has tried to do this with Matplotlib’s matplotlib.pyplot.tripcolor() routine will have been surprised by the artfully nice visualizations. The so-called ‘wrapping of lines’ of the data once around is suppressed here by masking the triangles. The triangulation is generated using Matplotlib’s tri.Triangulation(), and for plotting the colored triangles we use the pyplot.tripcolor() function.

Software requirements:

  • Python 3

  • numpy

  • xarray

  • matplotlib

  • cartopy

Example script#

triangular_grid_with_tripcolor_ICON.py

#!/usr/bin/env python
# coding: utf-8
'''
-------------------------------------------------------------------------------
DKRZ example

Plotting ICON data sets on triangular grid

This example shows how the ICON data can be displayed with Python on the
triangular grid. Anyone who has tried to do this with Matplotlib's
`matplotlib.pyplot.tripcolor()` routine will have been surprised by the
artfully nice visualizations. The so-called 'wrapping of lines' of the data
once around is suppressed here by masking the triangles. The triangulation is
generated using Matplotlib's `tri.Triangulation()`, and for plotting
the colored triangles we use the `pyplot.tripcolor()` function.

Inspired by
- Matplotlib Tripcolor Demo
    https://matplotlib.org/stable/gallery/images_contours_and_fields/tripcolor_demo.html
- easy.gems Triangular Meshes and Basic
    https://easy.gems.dkrz.de/Processing/playing_with_triangles/tripcolor.html

ICON Model Tutorial
https://www.dwd.de/DE/leistungen/nwv_icon_tutorial/pdf_einzelbaende/icon_tutorial2024.pdf;jsessionid=D9D8D98D646668F16569C7F4136E8E32.live21072?__blob=publicationFile&v=3

Content
    1.  Open ICON data and grid files
    2.  Coordinates
    3.  Mask
    4.  Variable data
    5.  Grid area
    6.  Data range and colors
    7.  Triangulation
    8.  Plotting with coordinates in radians
    9.  Plotting on a map
    10. Plotting using projections
      10.1  Orthographic
      10.2  Mollweide
      10.3  Robinson
      10.4  North polar stereographic

-------------------------------------------------------------------------------
2024 copyright DKRZ licensed under CC BY-NC-SA 4.0 <br>
               (https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)
-------------------------------------------------------------------------------
'''
import time, os
import xarray as xr
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.tri as tri
import matplotlib.colors as mcolors
import matplotlib.path as mpath
import cartopy.crs as ccrs
import cartopy.feature as cfeature

def main():
    plt.switch_backend('agg')

    #-- Let's see how long it takes.
    t1 = time.time()    #-- retrieve start time
    
    #-- Open ICON data and grid files    
    fname = '../../data/ta_ps_850.nc'     #-- data file
    gname = '../../data/r2b4_amip.nc'     #-- grid file
    
    varname = 'ta'                      #-- variable name
    
    ds_data = xr.open_dataset(fname)
    ds_grid = xr.open_dataset(gname)
    
    #-- Have a look at the different shapes.
    print(f'vlon/vlat:      {ds_grid.vlon.shape}, {ds_grid.vlat.shape}')
    print(f'vertex_of_cell: {ds_grid.vertex_of_cell.shape}')
    print(f'variable:       {ds_data[varname].shape}')
    
    #-- Data and coordinates
    #
    # vlon, vla:  longitude, latitude of the triangle vertices in radians
    vlon = ds_grid.vlon
    vlat = ds_grid.vlat
    
    # Variable data
    #
    # Now, we can use the mask array to extract the data variable for only wanted
    # cells. In the following the first time step will be used and the data will
    # be converted from K to °C.
    var = ds_data[varname].isel(time=0).squeeze()
    var = var - 273.15
    
    print(f'Variable shape: {var.size}')
    
    # Grid area
    #
    # vertex_of_cell:
    # The indices vertex of cell(:,i) denote the vertices that belong to the triangle i.
    # The vertex of cell index array is ordered counter-clockwise for each cell.
    voc = ds_grid.vertex_of_cell.T.values - 1
    
    print(f'vertex_of_cell shape: {voc.shape}')
    print(f'First indices vertex of cells: \n{voc[:3]}')
    
    # Data range and colors
    #
    # Set the contour level range, increment, and labels.
    var_min = -32.
    var_max = 28.
    var_int = 2
    
    levels = np.arange(var_min, var_max+var_int, var_int)
    nlevs  = levels.size
    labels = ['{:.2f}'.format(x) for x in levels]
    
    print(f'levels: {levels}\nnumber of levels:  {nlevs:3d}')
    
    #-- Colors
    #
    # Define the color map for the contour levels. Assign the color indices of
    # all cells in between the contour levels.
    #
    # The color assignment of the values time-consuming.
    cmap     = plt.cm.Spectral_r(range(nlevs))
    colors   = np.zeros([var.size, 4], np.float32)  #-- assign color array for triangle colors
    
    for m in range(nlevs-1):
        vind = 0
        for i in range(var.size-2):
            if (var[i] >= levels[m] and var[i] < levels[m+1]):
                colors[i,:] = cmap[m]
                vind += 1
        print(f'level {m:3d}:  {levels[m]:>5.1f} < var > {levels[m+1]:>5.1f}  -- {vind:5d} polygons considered')
    
    # set values below and above value range
    colors[np.where(var < var_min),:]  = cmap[0]
    colors[np.where(var >= var_max),:] = cmap[-1]
    
    #-- Triangulation
    #
    # The triangulation for plotting can be generated with Matplotlib's
    # `tri.Triangulation()` class. It takes the longitude and latitude vertices,
    # and the vertex of cell indices to compute the triangles.
    #
    # To avoid wrapping of the lines, a mask is also created here and assigned to
    # the triangulation object with the set_mask() method. See also
    # https://stackoverflow.com/questions/52457964/how-to-deal-with-the-undesired-triangles-that-form-between-the-edges-of-my-geo
    triang    = tri.Triangulation(vlon.values.flatten(), vlat.values.flatten(), voc)
    triangles = triang.triangles
    
    # generate the mask
    mlon = vlon.values.flatten()[triangles] - np.roll(vlon.values.flatten()[triangles], 1, axis=1)
    mlat = vlat.values.flatten()[triangles] - np.roll(vlat.values.flatten()[triangles], 1, axis=1)
    max_value = np.max(np.sqrt(mlon**2 + mlat**2), axis=1)
    max_radius = 1
    
    # assign mask to triang
    triang.set_mask(max_value > max_radius)
    
    #-- Plotting with coordinates in radians
    #
    # The vlon and vlat values are in radians and we use Matplotlib's
    # `pyplot.tripcolor()` function to generate the plot showing the colored
    # triangles.
    fig, ax = plt.subplots(figsize=(10,6))
    
    ax.grid()
    
    plot = ax.tripcolor(triang, var.values,
                        cmap='Spectral_r',
                        color=colors,
                        edgecolor='k',
                        linewidth=0.05)
    
    #-- add a color bar
    cb = plt.cm.ScalarMappable(cmap='Spectral_r',
                               norm=plt.Normalize(vmin=var_min, vmax=var_max))
    cb.set_array([])
    cbar = plt.colorbar(cb,ax=ax,
                        orientation='horizontal',
                        ticks=levels,
                        boundaries=levels,
                        format='%0.0f',
                        shrink=0.6,
                        pad=0.06,
                        aspect=30)
    plt.setp(cbar.ax.get_xticklabels()[::2], visible=False)
    cbar.set_label('[deg C]')
    
    ax.set_title('ICON: coordinates in radians', fontsize=16, weight='bold')
    
    #-- save figure to PNG file
    fig.savefig('plot_triangulation_example_ICON_in_radians.png',
                bbox_inches='tight', facecolor='white', dpi=150);
    
    #-- Plotting on a map
    #
    # Now we don't want to work with radians anyway, but with degrees and a map
    # instead, which is why the vlon and vlat values have to be converted to
    # degrees. Of course, the triangles have to be recalculated but we can reuse
    # the boolean mask from above.
    triang2 = tri.Triangulation(np.rad2deg(vlon).values.flatten(), np.rad2deg(vlat).values.flatten(), voc)
    triangles2 = triang2.triangles
    
    # assign boolean mask from above
    triang2.set_mask(max_value > max_radius)
    
    # There are two keywords used by plotting routines for the different coordinate
    # reference systems (CRS), `projection` and `transform`, that are important to
    # understand. One is the **data CRS** which is set with the `transform` keyword,
    # and the **map projection CRS** which is set with the  `projection` keyword.
    #
    # Read more about 'Understanding the transform and projection keywords' in the
    # Cartopy documentation:
    # https://scitools.org.uk/cartopy/docs/latest/tutorials/understanding_transform.html
    #
    # Here, the ICON data CRS can be set to PlateCarree (cylindrical equidistant projection).
    data_crs = ccrs.PlateCarree()
    
    # The map projection in Cartopy for a cylindrical equidistant projection is
    # called  PlateCarree.
    #
    # Set the map projection CRS
    projection = ccrs.PlateCarree()
    
    # Generate the plot
    fig, ax = plt.subplots(figsize=(14,7), subplot_kw=dict(projection=projection))
    
    ax.coastlines()
    ax.gridlines(draw_labels=True)
    
    plot = ax.tripcolor(triang2, var.values,
                        cmap='Spectral_r',
                        color=colors,
                        edgecolor='k',
                        linewidth=0.05,
                        transform=data_crs)
    
    # add a color bar
    cb = plt.cm.ScalarMappable(cmap='Spectral_r',
                               norm=plt.Normalize(vmin=var_min, vmax=var_max))
    cb.set_array([])
    cbar = plt.colorbar(cb,ax=ax,
                        orientation='horizontal',
                        ticks=levels,
                        boundaries=levels,
                        format='%0.0f',
                        shrink=0.6,
                        pad=0.06,
                        aspect=30)
    plt.setp(cbar.ax.get_xticklabels()[::2], visible=False)
    cbar.set_label('[deg C]')
    
    ax.set_title('ICON: coordinates in degrees', fontsize=16, weight='bold')
    
    # save figure to PNG file
    fig.savefig('plot_triangulation_example_ICON_in_degrees.png',
                bbox_inches='tight', facecolor='white', dpi=150);
    
    #-- Plotting using projections
    #
    # If a projection other than `ccrs.PlateCarree()` is used,  the `ax.set_global()`
    # or `ax.set_extent()` must be set and the `transform=data_crs` has to be added
    # to the `ax.tripcolor()` call.
    #
    # Read more about 'Understanding the transform and projection keywords' in the
    # Cartopy documentation:
    # https://scitools.org.uk/cartopy/docs/latest/tutorials/understanding_transform.html
    #
    # Set the data CRS
    data_crs = ccrs.PlateCarree()
    
    #-- Orthographic
    projection = ccrs.Orthographic()
    
    # Create plot
    fig, ax = plt.subplots(figsize=(8,8), subplot_kw=dict(projection=projection))
    
    ax.set_global()    #-- has to be set
    ax.coastlines()
    ax.gridlines(draw_labels=True)
    
    plot = ax.tripcolor(triang2, var.values,
                        cmap='Spectral_r',
                        color=colors,
                        edgecolor='k',
                        linewidth=0.05,
                        transform=data_crs)
    
    # add a color bar
    cb = plt.cm.ScalarMappable(cmap='Spectral_r',
                               norm=plt.Normalize(vmin=var_min, vmax=var_max))
    cb.set_array([])
    cbar = plt.colorbar(cb,ax=ax,
                        orientation='horizontal',
                        ticks=levels,
                        boundaries=levels,
                        format='%0.0f',
                        shrink=0.7,
                        pad=0.06,
                        aspect=30)
    plt.setp(cbar.ax.get_xticklabels()[::2], visible=False)
    cbar.set_label('[deg C]')
    
    ax.set_title('ICON: with Orthographic projection', fontsize=16, weight='bold')
    
    # save figure to PNG file
    fig.savefig('plot_triangulation_example_ICON_ortho_proj.png',
                bbox_inches='tight', facecolor='white', dpi=150);
    
    #-- Mollweide
    #
    # Set the map projection CRS
    projection = ccrs.Mollweide()
    
    # Create the plot
    fig, ax = plt.subplots(figsize=(8,8), subplot_kw=dict(projection=projection))
    
    ax.set_global()    #-- has to be set
    ax.coastlines()
    ax.gridlines(draw_labels=True, x_inline=False, y_inline=False)
    
    plot = ax.tripcolor(triang2, var.values,
                        cmap='Spectral_r',
                        color=colors,
                        edgecolor='k',
                        linewidth=0.05,
                        transform=data_crs)
    
    # add a color bar
    cb = plt.cm.ScalarMappable(cmap='Spectral_r',
                               norm=plt.Normalize(vmin=var_min, vmax=var_max))
    cb.set_array([])
    cbar = plt.colorbar(cb,ax=ax,
                        orientation='horizontal',
                        ticks=levels,
                        boundaries=levels,
                        format='%0.0f',
                        shrink=0.7,
                        pad=0.04,
                        aspect=30)
    plt.setp(cbar.ax.get_xticklabels()[::2], visible=False)
    cbar.set_label('[deg C]')
    
    ax.set_title('ICON: with Mollweide projection', fontsize=16, weight='bold')
    
    # save figure to PNG file
    fig.savefig('plot_triangulation_example_ICON_mollweide_proj.png',
                bbox_inches='tight', facecolor='white', dpi=150);
    
    #-- Robinson
    #
    # Set the map projection CRS
    projection = ccrs.Robinson()
    
    # Create the plot
    fig, ax = plt.subplots(figsize=(8,8), subplot_kw=dict(projection=projection))
    
    ax.set_global()    #-- has to be set
    ax.coastlines()
    ax.gridlines(draw_labels=True, x_inline=False, y_inline=False)
    
    plot = ax.tripcolor(triang2, var.values,
                        cmap='Spectral_r',
                        color=colors,
                        edgecolor='k',
                        linewidth=0.05,
                        transform=data_crs)
    
    # add a color bar
    cb = plt.cm.ScalarMappable(cmap='Spectral_r',
                               norm=plt.Normalize(vmin=var_min, vmax=var_max))
    cb.set_array([])
    cbar = plt.colorbar(cb,ax=ax,
                        orientation='horizontal',
                        ticks=levels,
                        boundaries=levels,
                        format='%0.0f',
                        shrink=0.7,
                        pad=0.04,
                        aspect=30)
    plt.setp(cbar.ax.get_xticklabels()[::2], visible=False)
    cbar.set_label('[deg C]')
    
    ax.set_title('ICON: with Robinson projection', fontsize=16, weight='bold')
    
    # save figure to PNG file
    fig.savefig('plot_triangulation_example_ICON_robinson_proj.png',
                bbox_inches='tight', facecolor='white', dpi=150);
    
    #-- Run time
    t2 = time.time()    #-- retrieve end time
    print(f'Run time: {t2 - t1:3.2f}s')


if __name__ == '__main__':
    main()

Plot result:

image0

image1