# # Copyright (c) 2019 Jonathan Weyn # # See the file LICENSE for your rights. # """ Utilities for retrieving and processing ERA5 reanalysis data using XArray. """ import os import warnings import itertools as it import numpy as np import netCDF4 as nc import pandas as pd import xarray as xr from datetime import datetime from copy import deepcopy try: import cdsapi except ImportError: warnings.warn("module 'cdsapi' not found; retrieval of ERA5 data unavailable.") # ==================================================================================================================== # # Universal parameters and functions # ==================================================================================================================== # def _check_exists(file_name, path=False): if os.path.exists(file_name): exists = True local_file = file_name else: exists = False local_file = None if path: return exists, local_file else: return exists def get_short_name(variables): """ Return the short name of long-name variables. """ all_variable_names = deepcopy(pressure_variable_names) all_variable_names.update(surface_variable_names) if isinstance(variables, str): return all_variable_names[variables] else: return [all_variable_names[v] for v in variables] # For some reason, multiprocessing.Pool.map is placing arguments passed to the function inside another length-1 tuple. # Much clearer programming would have required arguments of obj, m, month, *args here so that the user knows to include # the ERA5 object and other arguments correctly. def call_fetch(args): obj = args[0] obj._fetch(*args[1:]) # Format strings for files to write netcdf_file_format = '' # Start and end dates of available data data_start_date = datetime(1979, 1, 1) data_end_date = datetime(2018, 12, 31) reforecast_start_date = datetime(1999, 1, 1) reforecast_end_date = datetime(2009, 12, 31, 18) # netCDF fill value fill_value = np.array(nc.default_fillvals['f4']).astype(np.float32) # Dictionaries mapping request variables to netCDF variable naming conventions. Not all-inclusive for now. pressure_variable_names = { 'divergence': 'd', 'fraction_of_cloud_cover': 'cc', 'geopotential': 'z', 'ozone_mass_mixing_ratio': 'o3', 'potential_vorticity': 'pv', 'relative_humidity': 'r', 'specific_cloud_ice_water_content': 'ciwc', 'specific_cloud_liquid_water_content': 'clwc', 'specific_humidity': 'q', 'specific_rain_water_content': 'crwc', 'specific_snow_water_content': 'cswc', 'temperature': 't', 'u_component_of_wind': 'u', 'v_component_of_wind': 'v', 'vertical_velocity': 'w', 'vorticity': 'vo', 'streamfunction': 'sf', 'velocity_potential': 'vp' } surface_variable_names = { '10m_u_component_of_wind': 'u10', '10m_v_component_of_wind': 'v10', '2m_dewpoint_temperature': 'd2m', '2m_temperature': 't2m', 'land_sea_mask': 'lsm', 'mean_sea_level_pressure': 'msl', 'orography': 'z', 'sea_surface_temperature': 'sst', 'surface_latent_heat_flux': 'slhf', 'surface_sensible_heat_flux': 'sshf', 'surface_pressure': 'sp', 'total_column_water': 'tcw', 'total_column_water_vapour': 'tcwv', 'total_precipitation': 'tp', } # ==================================================================================================================== # # ERA5Reanalysis object class # ==================================================================================================================== # class ERA5Reanalysis(object): """ Class for manipulating ERA5 Reanalysis data with xarray. Class methods include functions to download, process, and export data. """ def __init__(self, root_directory=None, file_id=''): """ Initialize an instance of the ERA5Reanalysis class. :param root_directory: str: local directory where raw files are stored. If None, defaults to ~/.era5 :param file_id: str: prepended to the processed file names. Useful if files for the same dates will be created with different parameters, i.e., hours or variables or levels. """ self.raw_files = [] self.dataset_variables = [] self.dataset_levels = [] self.dataset_dates = None if root_directory is None: self._root_directory = '%s/.era5' % os.path.expanduser('~') else: self._root_directory = root_directory self._file_id = file_id self._delete_temp = False self.level_coord = [0, 1, 2, 3, 5, 7, 10, 20, 30, 50, 70, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750] + list(range(775, 1001, 25)) self.inverse_lat = True # Data self.Dataset = None self.basemap = None self._lat_array = None self._lon_array = None @property def lat(self): if self._lat_array is not None: return self._lat_array try: lat = self.Dataset.variables['lat'][:] if len(lat.shape) > 2: self._lat_array = lat[0, ...].values return self._lat_array else: self._lat_array = lat.values return self._lat_array except AttributeError: raise AttributeError('Call to lat method is only valid after data are opened.') except KeyError: return @property def lon(self): if self._lon_array is not None: return self._lon_array try: lon = self.Dataset.variables['lon'][:] if len(lon.shape) > 2: self._lon_array = lon[0, ...].values return self._lon_array else: self._lon_array = lon.values return self._lon_array except AttributeError: raise AttributeError('Call to lon method is only valid after data are opened.') except KeyError: return def set_variables(self, variables): """ Set the variables to retrieve or open in the dataset. Overridden by arguments to the 'retrieve' method. :param variables: list of string variable names :return: """ for v in variables: try: assert str(v) in list(pressure_variable_names.keys()) except TypeError: raise TypeError('variables must be convertible to string types') except AssertionError: try: assert str(v) in list(surface_variable_names.keys()) except AssertionError: raise ValueError('variables must be either in the available pressure-level variables for the ' 'dataset (%s) or the single-level variables (%s)' % (list(pressure_variable_names.keys()), list(surface_variable_names.keys()))) self.dataset_variables = sorted(list(variables)) def set_levels(self, levels): """ Set the levels to retrieve or open in the dataset. Overridden by arguments to the 'retrieve' method. :param levels: list of integer pressure height levels (mb / hPa) :return: """ for l in levels: try: assert int(l) in self.level_coord except TypeError: raise ValueError('levels must be integers in hPa') except AssertionError: raise ValueError('levels must be within the available levels for the dataset (%s)' % self.level_coord) self.dataset_levels = sorted(list(levels)) def closest_lat_lon(self, lat, lon): """ Find the grid-point index of the closest point to the specified latitude and longitude values in loaded CFS reanalysis data. :param lat: float or int: latitude in degrees :param lon: float or int: longitude in degrees :return: """ if lon < 0.: lon += 360. distance = (self.lat - lat) ** 2 + (self.lon - lon) ** 2 min_dist = 2.5 if np.min(distance) > min_dist: raise ValueError('no latitude/longitude points close to requested lat/lon!') return np.unravel_index(np.argmin(distance, axis=None), distance.shape) def _set_file_names(self): # Sets a list of file names. self.raw_files = [] for variable in self.dataset_variables: if variable in pressure_variable_names.keys(): for level in self.dataset_levels: self.raw_files.append('%s/%s_%s_%s.nc' % (self._root_directory, self._file_id, variable, level)) else: self.raw_files.append('%s/%s_%s.nc' % (self._root_directory, self._file_id, variable)) def retrieve(self, variables, levels=(), years='all', months='all', days='all', product='reanalysis', hourly=3, n_proc=4, verbose=False, request_kwargs=None, delete_temporary=False): """ Retrieve netCDF files of ERA5 reanalysis data. Must specify the variables and pressure levels desired. Iterates over variable/level pairs for each API request. Note that with 3-hourly data, one variable/level pair can be retrieved with a single API request for all dates between 1979-2018. If more dates or higher hourly resolution is required, it is currently up to the user to perform separate retrieval requests. DO NOT use the same retrieve function in the same instance of a class to request more dates as this will overwrite previously downloaded files. Instead, create a new instance of ERA5Reanalysis, give a different file_id, and then manually concatenate the datasets loaded on each instance. :param variables: iterable of str: variables to retrieve, one at a time :param levels: iterable of int: pressure levels to retrieve, one at a time :param years: iterable: years of data. If 'all', use 1979-2018. :param months: iterable: months of data. If 'all', get all months. :param days: iterable: month days of data. If 'all', get all days. :param product: str: type of product to retrieve. Must be one of 'reanalysis', 'ensemble_members', 'ensemble_mean', or 'ensemble_spread'. Note ensemble products are only 3-hourly. :param hourly: int: hourly time resolution; e.g., 6 for data every 6 hours. :param n_proc: int: number of processes for parallel retrieval :param verbose: bool: if True, print progress statements. The API already lists progress statements. :param request_kwargs: dict: other keywords passed to the retrieval. For example, 'grid' can be used to modify the lat/lon resolution. :param delete_temporary: bool: if True, delete the temporary files from the server in favor of the edited files with correct dimensions. May be risky to delete the raw files. """ # Parameter checks request_kwargs = {} or request_kwargs self.set_variables(variables) self.set_levels(levels) if delete_temporary: self._delete_temp = True if years == 'all': years = list(range(data_start_date.year, data_end_date.year + 1)) else: for y in years: try: assert data_start_date.year <= int(y) <= data_end_date.year except TypeError: raise ValueError('years must be integers') except AssertionError: raise ValueError('years must be within the available dates for ERA5 (%d-%d)' % (data_start_date.year, data_end_date.year)) years = [str(y) for y in years] if months == 'all': months = list(range(1, 13)) else: for m in months: try: assert 1 <= int(m) <= 12 except TypeError: raise ValueError('months must be integers') except AssertionError: raise ValueError('months must be integers from 1 to 12') months = ['%02d' % m for m in months] if days == 'all': days = list(range(1, 32)) else: for d in days: try: assert 1 <= int(d) <= 31 except TypeError: raise ValueError('days must be integers') except AssertionError: raise ValueError('days must be integers from 1 to 31') days = ['%02d' % d for d in days] assert product in ['reanalysis', 'ensemble_members', 'ensemble_mean', 'ensemble_spread'], \ "'product' must be one of 'reanalysis', 'ensemble_members', 'ensemble_mean', 'ensemble_spread'" if hourly < 1 or hourly > 24: raise ValueError('hourly interval must be between 1 and 24') if product.startswith('ensemble'): assert hourly % 3 == 0, "'hourly' must be a multiple of 3 for ensemble products" hour_daterange = pd.date_range('2000-01-01 00:00', '2000-01-01 23:00', freq='%dh' % hourly) hours = [d.strftime('%H:%M') for d in hour_daterange] if len(variables) == 0: print('ERA5Reanalysis.retrieve: no variables specified; will do nothing.') return assert hasattr(levels, '__iter__'), "'levels' must be iterable" if int(n_proc) < 0: raise ValueError("'n_proc' must be an integer >= 0") # Create the requests requests = [] self._set_file_names() for variable in self.dataset_variables: if variable in pressure_variable_names.keys(): for level in self.dataset_levels: request = { 'product_type': product, 'format': 'netcdf', 'variable': variable, 'pressure_level': level, 'year': years, 'month': months, 'day': days, 'time': hours } request.update(request_kwargs) requests.append(request) else: request = { 'product_type': product, 'format': 'netcdf', 'variable': variable, 'year': years, 'month': months, 'day': days, 'time': hours } request.update(request_kwargs) requests.append(request) # Create a multi-processing tool, if necessary if n_proc == 0 or n_proc > 1: try: import multiprocessing if n_proc == 0: n_proc = multiprocessing.cpu_count() except ImportError: warnings.warn("'multiprocessing' module not available; falling back to serial") n_proc = 1 if n_proc == 1: for file, request in zip(self.raw_files, requests): self._fetch(request, file, verbose) else: pool = multiprocessing.Pool(processes=n_proc) pool.map(call_fetch, zip(it.repeat(self), requests, self.raw_files, it.repeat(verbose))) pool.close() pool.terminate() pool.join() def _fetch(self, request, file_name, verbose): # Check for existing file if _check_exists(file_name): print('ERA5Reanalysis.retrieve: WARNING: file %s already exists; omitting' % file_name) return # Fetch the file c = cdsapi.Client() pid = os.getpid() if request['variable'] in pressure_variable_names.keys(): if verbose: print('PID %s: ERA5Reanalysis.retrieve: fetching %s at %s mb' % (pid, request['variable'], request['pressure_level'])) c.retrieve('reanalysis-era5-pressure-levels', request, file_name + '.tmp') # Add a level dimension to the file (not present by default) if verbose: print('PID %s: Adding level dimension' % pid) self._process_temp_file(file_name, float(request['pressure_level'])) else: if verbose: print('PID %s: ERA5Reanalysis.retrieve: fetching %s' % (pid, request['variable'])) c.retrieve('reanalysis-era5-single-levels', request, file_name) def _process_temp_file(self, file_name, level): ds = xr.open_dataset(file_name + '.tmp') ds = ds.expand_dims('level', axis=1).assign_coords(level=np.array([level], dtype=np.float32)) ds.to_netcdf(file_name) if self._delete_temp: os.remove(file_name + '.tmp') def open(self, **dataset_kwargs): """ Open an xarray multi-file Dataset for the processed files. Set the variables and levels with the instance set_variables and set_levels methods. Once opened, this Dataset is accessible by self.Dataset. :param dataset_kwargs: kwargs passed to xarray.open_mfdataset() """ if len(self.dataset_variables) == 0: raise ValueError('set the variables to open with the set_variables() method') self._set_file_names() self.Dataset = xr.open_mfdataset(self.raw_files, **dataset_kwargs) self.dataset_dates = self.Dataset['time'] def close(self): """ Close an opened Dataset on self. """ if self.Dataset is not None: self.Dataset.close() self.Dataset = None self.dataset_dates = None self._lon_array = None self._lat_array = None else: raise ValueError('no Dataset to close') def generate_basemap(self, llcrnrlat=None, llcrnrlon=None, urcrnrlat=None, urcrnrlon=None): """ Generates a Basemap object for graphical plot of ERA5 data on a 2-D plane. Bounding box parameters are either given, or if None, read from the extremes of the loaded lat/lon data. Other projection parameters are set to the default ERA5 configuration. :param llcrnrlat: float: lower left corner latitude :param llcrnrlon: float: lower left corner longitude :param urcrnrlat: float: upper right corner latitude :param urcrnrlon: float: upper right corner longitude :return: """ from mpl_toolkits.basemap import Basemap try: default = llcrnrlat * llcrnrlon * urcrnrlat * urcrnrlon # error if any are None default = False except TypeError: default = True if default: try: lat = self.lat lon = self.lon except (AttributeError, KeyError): raise ValueError('I can generate a default Basemap with None parameters, but only if I have some ' 'data loaded first!') llcrnrlon, llcrnrlat = lon[0, 0], lat[-1, -1] urcrnrlon, urcrnrlat = lon[-1, -1], lat[0, 0] basemap = Basemap(projection='cyl', llcrnrlat=llcrnrlat, urcrnrlat=urcrnrlat, llcrnrlon=llcrnrlon, urcrnrlon=urcrnrlon, resolution='l') self.basemap = basemap