Temporal interpolation using nowcasting

Nowcast-based temporal interpolation was developed to solve the three following problems simultaneously:

  1. Data is often needed at times other than the six minutes at which radar mosaics are available. For data assimilation purposes, radar data is required at time resolutions between 1 and 7.5 minutes, depending on the timestep of the model being used.

  2. Prior to assimilation, radar data must be smoothed for its spatial resolution to match the effective resolution of the simulated precipitation. This avoids assimilating small scales that the model cannot represent.

  3. The source data contains many gaps; US radars leave some when they use scanning routines (VCPs) longer than six minutes, and Canadian data is sometimes missing because of network delays.

The animation below shows the result of the interpolation for a case over Minnesota and Wisconsin on 29 August 2022. The top row shows the source data, available every six minutes, together with its quality index. The bottom row shows the interpolated precipitation rate and its quality index, available every minute. Note how the interpolation bridges the gap when source data is unavailable.

_images/202208_movie.gif

The interpolation is performed by the obs_process module. The basic idea is that the temporally interpolated data at intermediate timesteps is estimated as a quality-weighted aggregate of advected neighbors. By selecting \(\delta_t^{\text{max}}\) such that 4 or 5 neighbors contribute to each aggregate, data gaps go mostly unnoticed.

Motion vectors are first estimated from consecutive source mosaics using the Lucas-Kanade optical flow of pysteps. Every source mosaic that is closer in time than \(\delta_t^{\text{max}}\) then contributes to the output; each one is advected to the desired time, forward or backward, with a semi-Lagrangian scheme. The weight of each contribution decreases linearly with the duration of the advection and reaches zero at \(\delta_t^{\text{max}}\). Because this weight multiplies the quality index of the advected data, an estimate that had to be advected over a long time interval is given less importance than one that comes from a nearby time. The precipitation rate is the average of the advected estimates weighted by these reduced quality indices.

_images/nowcast_time_interpolation.png

Interpolate a batch of observations

This section demonstrates the processing of a batch of observations in one call to the obs_process function. The results are then displayed in the form of a short animation.

Let’s start with the required imports and directory setup:

    import os
    import datetime
    import subprocess
    import glob
    import shutil
    import copy
    import numpy as np
    import matplotlib as mpl
    import matplotlib.pyplot as plt
    import dask
    from dask.distributed import Client, LocalCluster
    import domutils.legs as legs
    import domutils.geo_tools as geo_tools
    import domutils.radar_tools as radar_tools
    import domcmc.fst_tools as fst_tools
    import domutils._py_tools as py_tools
    
    #setting up directories
    test_data_dir    = setup_test_paths['test_data_dir']
    test_results_dir = setup_test_paths['test_results_dir']

    generated_files_dir  = os.path.join(test_results_dir, 'generated_files',   'test_radar_time_interpolation')
    generated_figure_dir = os.path.join(test_results_dir, 'generated_figures', 'test_radar_time_interpolation')
    reference_figure_dir = os.path.join(test_data_dir,    'reference_figures', 'test_radar_time_interpolation')

    py_tools.parallel_mkdir(generated_files_dir)
    py_tools.parallel_mkdir(generated_figure_dir)

obs_process is a python script callable from the shell such as:

#process data with time interpolation
python -m domutils.radar_tools.obs_process    \
          --input_t0         202206150800     \
          --input_tf         202206160000     \
          --input_dt         10               \
          --output_t0        202206150900     \
          --output_tf        202206160000     \
          --output_dt        1                \
          --t_interp_method  'nowcast'        \
          ...

However, for this example we will be running directly from Python with arguments provided by the attributes of a simple object.

# a class that mimics the output of argparse
class ArgsClass():
    def __init__(self, setup_test_paths):
        test_data_dir    = setup_test_paths['test_data_dir']
        test_results_dir = setup_test_paths['test_results_dir']

        generated_files_dir  = os.path.join(test_results_dir, 'generated_files',   'test_radar_time_interpolation')
        generated_figure_dir = os.path.join(test_results_dir, 'generated_figures', 'test_radar_time_interpolation')

        self.input_t0                 = '202208290230'
        self.input_tf                 = '202208290530'
        self.input_dt                 = '6M'
        self.output_t0                = '202208290330'
        self.output_tf                = '202208290430'
        self.output_dt                = '1M'
        self.interp_max_dt            = '13M'
        self.complete_dataset         = 'False'
        self.t_interp_method          = 'nowcast'
        self.input_data_dir           = os.path.join(test_data_dir, 'odimh5_radar_composites')
        self.input_file_struc         = '%Y/qcomp_%Y%m%d%H%M.h5'
        self.h5_latlon_file           = os.path.join(test_data_dir, 'radar_continental_2.5km_2882x2032.pickle')
        self.sample_pr_file           = os.path.join(test_data_dir, 'hrdps_5p1_prp0.fst')
        self.ncores                   = 40    # use as many cpus as you have on your system 
        self.preproc_median_filt      = '3'
        self.preproc_smooth_radius    = '4'
        self.nowcast_median_filt      = '3'
        self.output_dir               = os.path.join(generated_files_dir, 'obs_process_t_interp')
        self.output_file_format       = 'fst'
        self.output_file_struc        = '%Y%m%d%H%M.fst'
        self.figure_format            = 'svg'
        self.log_level                = 'WARNING'

We now instantiate this object for the case being demonstrated and define the domain and the times that will be used for the figures.

    import cartopy.crs as ccrs

    # a new argument object is needed for each case since obs_process
    # modifies the arguments it receives
    args = ArgsClass(setup_test_paths)

    # times of the source data that will be displayed in the animation
    anim_t0 = datetime.datetime(2022,8,29,3,42)
    anim_source_deltat = np.arange(0, 37, 6, dtype=int)    # minutes

    # domain of the figures; 300x300 km over Minnesota/Wisconsin
    pole_latitude=35.7
    pole_longitude=65.5
    lat_0 = 46.7
    delta_lat = 3.14*.5 
    lon_0 = 267.3
    delta_lon = 4.17*.5
    map_extent=[lon_0-delta_lon, lon_0+delta_lon, lat_0-delta_lat, lat_0+delta_lat]  
    proj_aea = ccrs.RotatedPole(pole_latitude=pole_latitude, pole_longitude=pole_longitude)

The processing of observations and time interpolation is done in one simple function call.

    radar_tools.obs_process(args)

To make an animation showing the time-interpolated data, we first define a function for plotting each individual panel.

def plot_panel(data,
               fig, ax_pos, title, 
               proj_aea, 
               proj_obj, colormap, 
               plot_palette=None, 
               pal_units=None, 
               show_artefacts=False):
    '''function that plots individual panels
    '''
    import matplotlib.pyplot as plt
    import cartopy.feature as cfeature

    ax = fig.add_axes(ax_pos, projection=proj_aea)
    ax.set_extent(proj_obj.rotated_extent, crs=proj_aea)
    dum = ax.annotate(title, size=32,
                      xy=(.022, .85), xycoords='axes fraction',
                      bbox=dict(boxstyle="round", fc='white', ec='white'))

    # projection from data space to image space
    projected_data = proj_obj.project_data(data)

    # plot data & palette
    colormap.plot_data(ax=ax, data=projected_data,
                       palette=plot_palette, 
                       pal_units=pal_units, pal_format='{:5.1f}', 
                       equal_legs=True)

    # add political boundaries
    ax.add_feature(cfeature.STATES.with_scale('10m'), linewidth=0.5, edgecolor='0.2')

    # show artefacts in accumulation plots
    if show_artefacts:
        ax2 = fig.add_axes(ax_pos)
        ax2.set_xlim((0.,1.))
        ax2.set_ylim((0.,1.))
        ax2.patch.set_alpha(0.0)
        ax2.set_axis_off()
        xpos = np.linspace(0.24, 0.40, 5)
        ypos = np.linspace(0.75, 0.85, 5)
        for x0, y0, dx in [(xx,yy,.1) for xx, yy in zip(xpos, ypos)]:
            ax2.arrow(x0, y0, dx, -.03,
                      width=0.015, facecolor='red', edgecolor='black', 
                      head_width=3*0.01, linewidth=2.)


def figure_for_timestep(src_delta_min, interp_delta_min, t0,
                        proj_aea,
                        input_proj_obj, output_proj_obj,
                        pr_colormap, qi_colormap, 
                        generated_figure_dir, 
                        args, fig_w, fig_h, sp_w, sp_h, rec_w, rec_h, sp_m ):

    '''make the figure for every output timestep
    '''

    import subprocess
    import datetime
    import matplotlib as mpl
    import matplotlib.pyplot as plt
    import domutils.radar_tools as radar_tools

    source_t_offset   = datetime.timedelta(seconds=src_delta_min * 60.0)
    source_valid_time  = t0 + source_t_offset
    interpolated_t_offset = datetime.timedelta(seconds=interp_delta_min * 60.0)
    interpolated_valid_time = source_valid_time + interpolated_t_offset
    
    # matplotlib global settings
    dpi = 400
    mpl.rcParams.update({
        'font.family': 'Latin Modern Roman',
        'font.size': 32,
        'axes.titlesize': 32,
        'axes.labelsize': 32,
        'xtick.labelsize': 30,
        'ytick.labelsize': 30,
        'legend.fontsize': 30,
        'figure.dpi': dpi,
        'savefig.dpi': dpi,
        })

    # instantiate figure
    fig = plt.figure(figsize=(fig_w,fig_h))

    # source data on original grid
    dat_dict = radar_tools.get_instantaneous(desired_quantity='precip_rate',
                                             valid_date=source_valid_time,
                                             data_path=args.input_data_dir,
                                             data_recipe=args.input_file_struc)
    x0 = sp_w 
    y0 = 2.*sp_h + rec_h
    ax_pos = [x0, y0, rec_w, rec_h]
    title = f'Source precip rate \n @ t0+{src_delta_min}minutes'
    plot_panel(dat_dict['precip_rate'],
               fig, ax_pos, title, 
               proj_aea, 
               input_proj_obj, pr_colormap,
               plot_palette='right',
               pal_units='[mm/h]')

    # source quality index
    x0 = sp_w + rec_w + sp_m
    y0 = 2.*sp_h + rec_h
    ax_pos = [x0, y0, rec_w, rec_h]
    title = f'Source quality index \n @ t0+{src_delta_min}minutes'
    plot_panel(dat_dict['total_quality_index'],
               fig, ax_pos, title, 
               proj_aea, 
               input_proj_obj, qi_colormap,
               plot_palette='right',
               pal_units='[unitless]')

    # Time interpolated data
    dat_dict = radar_tools.get_instantaneous(desired_quantity='precip_rate',
                                             valid_date=interpolated_valid_time,
                                             data_path=args.output_dir,
                                             data_recipe=args.output_file_struc)
    x0 = sp_w 
    y0 = sp_h
    ax_pos = [x0, y0, rec_w, rec_h]
    title = f'Interpolated precip rate \n @ t0+{src_delta_min+interp_delta_min}minutes'
    plot_panel(dat_dict['precip_rate'],
               fig, ax_pos, title, 
               proj_aea, 
               output_proj_obj, pr_colormap,
               plot_palette='right',
               pal_units='[mm/h]')

    # quality index 
    x0 = sp_w  + rec_w + sp_m
    y0 = sp_h
    ax_pos = [x0, y0, rec_w, rec_h]
    title = f' Interpolated quality index\n @ t0+{src_delta_min+interp_delta_min}minutes'
    plot_panel(dat_dict['total_quality_index'],
               fig, ax_pos, title, 
               proj_aea, 
               output_proj_obj, qi_colormap,
               plot_palette='right',
               pal_units='[unitless]')

    # save output
    date_prefix = interpolated_valid_time.strftime('%Y%m%d%H%M')
    fig_name = os.path.join(generated_figure_dir, f'{date_prefix}_frame.png')
    plt.savefig(fig_name)
    #fig_name_svg = os.path.join(generated_figure_dir, f'{date_prefix}_frame.svg')
    #plt.savefig(fig_name_svg)
    plt.close(fig)
    print(f'done with {fig_name}')

    # use "convert" to make a gif out of the png
    cmd = ['convert', fig_name, '-geometry', '15%', '-quantize', 'transparent', '-dither', 'FloydSteinberg', '-colors', '256',  fig_name.replace('png', 'gif')]
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    output, error = process.communicate()

    # we don't need the original png anymore
    os.remove(fig_name)

    return source_valid_time, interpolated_valid_time

then we setup the general characteristics of the figure being generated. See Legs Tutorial for information on the definition of color mapping objects.

    dpi = 400
    mpl.rcParams.update({
        'font.family': 'Latin Modern Roman',
        'font.size': 32,
        'axes.titlesize': 32,
        'axes.labelsize': 32,
        'xtick.labelsize': 30,
        'ytick.labelsize': 30,
        'legend.fontsize': 30,
        'figure.dpi': dpi,
        'savefig.dpi': dpi,
        })
    #pixel density of each panel
    ratio = 1.
    hpix = 1200.      #number of horizontal pixels
    vpix = ratio*hpix #number of vertical pixels
    img_res = (int(hpix),int(vpix))
    
    #size of image to plot
    fig_w = 19.                    #size of figure
    fig_h = 15.7                   #size of figure
    rec_w = 7./fig_w               #size of axes
    rec_h = ratio*(rec_w*fig_w)/fig_h #size of axes
    sp_w = .5/fig_w                #space between panel and border
    sp_m = 2.2/fig_w               #space between panels
    sp_h = .5/fig_h                #space between panels
    
    # color mapping object
    range_arr = [.1,1.,5.,10.,25.,50.,100.]
    missing = -9999.
    # colormap object for precip rates
    pr_colormap = legs.PalObj(range_arr=range_arr,
                              n_col=6,
                              over_high='extend', under_low='white',
                              excep_val=missing, 
                              excep_col='grey_200')
    # colormap for QI index
    pastel = [ [[255,190,187],[230,104, 96]],  #pale/dark red
               [[255,185,255],[147, 78,172]],  #pale/dark purple
               [[255,227,215],[205,144, 73]],  #pale/dark brown
               [[210,235,255],[ 58,134,237]],  #pale/dark blue
               [[223,255,232],[ 61,189, 63]] ] #pale/dark green
    qi_colormap = legs.PalObj(range_arr=[0., 1.],
                              dark_pos='high',
                              color_arr=pastel,
                              excep_val=[missing,0.],
                              excep_col=['grey_220','white'])
    
    # get lat/lon of input data from one of the h5 files 
    dum_h5_file = os.path.join(test_data_dir, 'odimh5_radar_composites', '2022/qcomp_202205212000.h5')
    input_ll    = radar_tools.read_h5_composite(dum_h5_file, latlon=True)
    input_lats  = input_ll['latitudes']
    input_lons  = input_ll['longitudes']
    
    # get lat/lon of output data 
    output_ll = fst_tools.get_data(args.sample_pr_file, var_name='PR', latlon=True)
    output_lats = output_ll['lat']
    output_lons = output_ll['lon']
    
    # instantiate projection object for input data
    input_proj_obj = geo_tools.ProjInds(src_lon=input_lons, src_lat=input_lats,
                                        extent=map_extent, dest_crs=proj_aea, image_res=img_res)
    
    # instantiate projection object for output data
    output_proj_obj = geo_tools.ProjInds(src_lon=output_lons, src_lat=output_lats,
                                         extent=map_extent, dest_crs=proj_aea, image_res=img_res)

Individual frames of the animation are made serially or in parallel.

    interpolated_deltat = np.arange(6) # minutes

    # making the figures only requires the file paths found in args
    # obs_process attached large arrays and projection objects to this object;
    # they are removed here so that only light-weight data is sent to the dask workers
    plot_args = copy.copy(args)
    for attr in ('dask_client', 'proj_obj', 'out_lats', 'out_lons'):
        setattr(plot_args, attr, None)

    serial=False
    if serial:
        for src_delta_min in anim_source_deltat:
            for interp_delta_min in interpolated_deltat:
                figure_for_timestep(src_delta_min, interp_delta_min, anim_t0, 
                                    proj_aea,
                                    input_proj_obj, output_proj_obj,
                                    pr_colormap, qi_colormap, 
                                    generated_figure_dir, 
                                    plot_args, fig_w, fig_h, sp_w, sp_h, rec_w, rec_h, sp_m)
    else:
        tasks = [dask.delayed(figure_for_timestep)(src_delta_min, interp_delta_min, anim_t0, 
                                                   proj_aea,
                                                   input_proj_obj, output_proj_obj,
                                                   pr_colormap, qi_colormap, 
                                                   generated_figure_dir, 
                                                   plot_args, fig_w, fig_h, sp_w, sp_h, rec_w, rec_h, sp_m)
                  for src_delta_min in anim_source_deltat
                  for interp_delta_min in interpolated_deltat]

        with LocalCluster(processes=True, n_workers=10, threads_per_worker=1) as cluster, Client(cluster) as client:
            results = dask.compute(*tasks)  # parallel execution
    

Finally, an animated gif is constructed from the frames we just made,

    date_prefix = anim_t0.strftime('%Y%m')
    movie_name = os.path.join(generated_figure_dir, f'{date_prefix}_movie.gif')
    gif_list = sorted(glob.glob(os.path.join(generated_figure_dir,f'{date_prefix}*frame.gif')))   
    cmd = ['convert', '-loop', '0', '-delay', '30']+gif_list+[movie_name]
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    output, error = process.communicate()

The animation shown at the top of this page was obtained with the code above. Running the same code with different dates and a different domain gives the animation below, for the derecho that crossed southern Quebec on 21 May 2022.

_images/202205_movie.gif

Accumulations from time interpolated data

Using nowcasting for time interpolation can be advantageous when computing accumulations from source data available at discrete times. In the example below, we compare accumulations obtained from the source data to accumulations obtained from the time interpolated data.

    duration = 30 # minutes
    end_date = anim_t0 + datetime.timedelta(minutes=30)

    # the next figure has only one row of panels
    # the panels keep the same size, only the height of the figure changes
    fig_h = 8.                         #size of figure
    rec_h = ratio*(rec_w*fig_w)/fig_h  #size of axes
    sp_h  = .5/fig_h                   #space between panel and border
    
    # instantiate figure
    fig = plt.figure(figsize=(fig_w,fig_h))
    
    # make accumulation from source data
    dat_dict = radar_tools.get_accumulation(end_date=end_date,
                                            duration=duration,
                                            input_dt=6., # minutes
                                            data_path=args.input_data_dir,
                                            data_recipe=args.input_file_struc)
    x0 = 2.*sp_w + rec_w
    y0 = sp_h 
    ax_pos = [x0, y0, rec_w, rec_h]
    title = 'Accumulation from \n source data'
    plot_panel(dat_dict['accumulation'],
               fig, ax_pos, title, 
               proj_aea, 
               input_proj_obj, pr_colormap,
               plot_palette='right',
               pal_units='mm',
               show_artefacts=True)
    
    # make accumulation from time interpolated data
    dat_dict = radar_tools.get_accumulation(end_date=end_date,
                                            duration=duration,
                                            input_dt=1., # minutes
                                            data_path=args.output_dir, 
                                            data_recipe=args.output_file_struc)
    x0 = sp_w 
    y0 = sp_h
    ax_pos = [x0, y0, rec_w, rec_h]
    title = 'Accumulation from \n time interpolated data'
    plot_panel(dat_dict['accumulation'],
               fig, ax_pos, title, 
               proj_aea,
               output_proj_obj, pr_colormap)
    
    # save output
    fig_name = os.path.join(generated_figure_dir, f'{date_prefix}_accumulation.svg')
    plt.savefig(fig_name)
    plt.close(fig)
    

The figure below shows the 30 minutes precipitation accumulation ending at 21:42 UTC on 21 May 2022, computed from:

  • the source data, available every six minutes, on the right

  • the time interpolated data, available every minute, on the left

In the panel on the right, the red arrows indicate artefacts that originate from the coarse time resolution of the source data compared to the speed at which the bow echo propagates. The accumulation on the left does not display these displacement artefacts.

_images/202205_accumulation.svg